Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 33 additions & 15 deletions app/routes/homeassistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,9 @@ def vehicles(user):
'registration': v.registration,
'fuel_type': v.fuel_type,
'current_odometer': latest_log.odometer if latest_log else 0,
'unit_distance': v.unit_distance,
'unit_volume': v.unit_volume,
'currency': v.currency
'unit_distance': user.distance_unit,
'unit_volume': user.volume_unit,
'currency': user.currency
}
result['vehicles'].append(vehicle_data)

Expand Down Expand Up @@ -137,9 +137,9 @@ def vehicle_detail(vehicle_id, user):
'registration': vehicle.registration,
'fuel_type': vehicle.fuel_type,
'current_odometer': latest_log.odometer if latest_log else 0,
'unit_distance': vehicle.unit_distance,
'unit_volume': vehicle.unit_volume,
'currency': vehicle.currency
'unit_distance': user.distance_unit,
'unit_volume': user.volume_unit,
'currency': user.currency
})


Expand Down Expand Up @@ -183,7 +183,7 @@ def vehicle_stats(vehicle_id, user):
# Skip first fill for consumption calculation (don't know previous fill level)
if len(fuel_logs) >= 2 and total_distance > 0:
consumption_fuel = sum(log.volume for log in fuel_logs[1:])
if vehicle.unit_distance == 'mi':
if user.distance_unit == 'mi':
# MPG (higher is better)
avg_consumption = total_distance / consumption_fuel if consumption_fuel > 0 else 0
else:
Expand Down Expand Up @@ -215,15 +215,15 @@ def vehicle_stats(vehicle_id, user):
'total_expenses': round(float(total_expenses), 2),
'total_cost': round(total_fuel_cost + float(total_expenses), 2),
'avg_consumption': round(avg_consumption, 2),
'consumption_unit': 'mpg' if vehicle.unit_distance == 'mi' else 'L/100km',
'consumption_unit': 'mpg' if user.distance_unit == 'mi' else 'L/100km',
'fill_count': len(fuel_logs),
'last_fill_date': last_fill.date.isoformat() if last_fill else None,
'last_fill_volume': last_fill.volume if last_fill else None,
'last_fill_cost': last_fill.total_cost if last_fill else None,
'current_odometer': latest_log.odometer if latest_log else 0,
'distance_unit': vehicle.unit_distance,
'volume_unit': vehicle.unit_volume,
'currency': vehicle.currency,
'distance_unit': user.distance_unit,
'volume_unit': user.volume_unit,
'currency': user.currency,
'currency_symbol': vehicle.currency_symbol
})

Expand All @@ -241,12 +241,21 @@ def alerts(user):
).all()

for schedule in schedules:
if schedule.is_due() or schedule.is_overdue():
current_odometer = schedule.vehicle.get_last_odometer()
is_due = schedule.is_due(current_odometer)
date_overdue = bool(schedule.next_due_date and schedule.next_due_date < date.today())
odometer_overdue = bool(
schedule.next_due_odometer is not None and
current_odometer is not None and
current_odometer > schedule.next_due_odometer
)

if is_due:
alerts.append({
'type': 'maintenance',
'vehicle': schedule.vehicle.name,
'title': schedule.name,
'status': 'overdue' if schedule.is_overdue() else 'due',
'status': 'overdue' if (date_overdue or odometer_overdue) else 'due',
'due_odometer': schedule.next_due_odometer,
'due_date': schedule.next_due_date.isoformat() if schedule.next_due_date else None
})
Expand Down Expand Up @@ -298,12 +307,20 @@ def alerts(user):
).all()

for reminder in reminders:
if reminder.is_due():
if reminder.is_overdue():
alerts.append({
'type': 'reminder',
'vehicle': reminder.vehicle.name,
'title': reminder.title,
'status': 'due',
'status': 'overdue',
'due_date': reminder.due_date.isoformat() if reminder.due_date else None
})
elif reminder.is_upcoming(reminder.notify_days_before or 7):
alerts.append({
'type': 'reminder',
'vehicle': reminder.vehicle.name,
'title': reminder.title,
'status': 'upcoming',
'due_date': reminder.due_date.isoformat() if reminder.due_date else None
})

Expand Down Expand Up @@ -382,6 +399,7 @@ def add_fuel(user):
try:
fuel_log = FuelLog(
vehicle_id=data['vehicle_id'],
user_id=user.id,
date=date.fromisoformat(data['date']),
odometer=float(data['odometer']),
volume=float(data['volume']),
Expand Down
22 changes: 22 additions & 0 deletions app/routes/stations.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ def price_history(station_id):
return render_template('stations/prices.html', station=station, prices=prices, prices_json=prices_json)


@bp.route('/prices/<int:price_id>/delete', methods=['POST'])
@login_required
def delete_price(price_id):
"""Delete a single fuel price history entry.

Lets users clean up stale or orphan rows in the Cheapest Fuel table
(e.g. entries left behind by test logs or pre-cleanup-logic versions).
"""
price = FuelPriceHistory.query.get_or_404(price_id)

if price.user_id != current_user.id:
flash(_('You can only delete your own price entries'), 'error')
return redirect(url_for('stations.price_history', station_id=price.station_id))

station_id = price.station_id
db.session.delete(price)
db.session.commit()

flash(_('Price entry deleted'), 'success')
return redirect(url_for('stations.price_history', station_id=station_id))


@bp.route('/cheapest')
@login_required
def cheapest():
Expand Down
1 change: 1 addition & 0 deletions app/services/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def send_ntfy(topic, title, message, priority='default'):
'Title': title,
'Priority': priority,
'Tags': 'car',
'User-Agent': 'May-Vehicle-Manager/1.0',
})
with urlopen(req, timeout=10) as response:
return True, None
Expand Down
18 changes: 16 additions & 2 deletions app/templates/stations/prices.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,26 @@ <h2 class="text-lg font-medium text-gray-900 dark:text-white">{{ _('Price Histor
<!-- Price List -->
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
{% for price in prices %}
<li class="px-6 py-4 flex items-center justify-between">
<li class="px-6 py-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ price.date|format_date }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 capitalize">{{ price.fuel_type }}</p>
</div>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ "%.3f"|format(price.price_per_unit) }} {{ current_user.currency }}/{{ current_user.volume_unit }}</p>
<div class="flex items-center gap-4">
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ "%.3f"|format(price.price_per_unit) }} {{ current_user.currency }}/{{ current_user.volume_unit }}</p>
{% if price.user_id == current_user.id %}
<form method="post" action="{{ url_for('stations.delete_price', price_id=price.id) }}"
onsubmit="return confirm('{{ _('Delete this price entry? This cannot be undone.') }}');">
<button type="submit"
class="text-gray-400 hover:text-red-600 dark:hover:text-red-400"
title="{{ _('Delete this entry') }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
</svg>
</button>
</form>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
Expand Down
2 changes: 1 addition & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
basedir = Path(__file__).parent.absolute()


APP_VERSION = '0.22.5'
APP_VERSION = '0.22.6'
RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable')
GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA
GITHUB_REPO = 'dannymcc/may'
Expand Down
10 changes: 5 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ flask-wtf>=1.3.0
flask-babel>=4.0.0
werkzeug>=3.1.8
python-dotenv>=1.2.2
pillow>=10.4.0
pillow>=12.2.0
gunicorn>=25.3.0
weasyprint>=62.0
requests>=2.33.1
weasyprint>=68.1
requests>=2.34.2
python-dateutil>=2.9.0.post0
pytest>=9.0.3
pytest-cov>=4.1.0
coverage>=7.13.5
pytest-cov>=7.1.0
coverage>=7.14.0
68 changes: 53 additions & 15 deletions tests/test_api_ha.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
"""Tests for the Home Assistant API endpoints.

Note: Some HA routes reference Vehicle.unit_distance and Expense.amount which
do not exist in the current Vehicle/Expense models. Tests that trigger those
code paths are marked xfail to document the known issue without blocking the suite.
"""
"""Tests for the Home Assistant API endpoints."""
import pytest
from datetime import date
from datetime import date, timedelta
from app import db as _db_ext
from app.models import User, Vehicle, FuelLog
from app.models import User, Vehicle, FuelLog, MaintenanceSchedule, Reminder


def make_ha_headers(api_key):
Expand Down Expand Up @@ -114,7 +109,6 @@ def test_list_vehicles_empty(self, client, ha_headers):
assert 'vehicles' in data
assert data['count'] == 0

@pytest.mark.xfail(reason="HA route uses Vehicle.unit_distance which does not exist in model")
def test_list_vehicles_with_vehicle(self, client, ha_headers, ha_vehicle):
resp = client.get('/api/ha/vehicles', headers=ha_headers)
assert resp.status_code == 200
Expand All @@ -124,14 +118,19 @@ def test_list_vehicles_with_vehicle(self, client, ha_headers, ha_vehicle):
assert v['name'] == 'HA Test Car'
assert 'current_odometer' in v
assert 'fuel_type' in v
assert v['unit_distance'] == 'km'
assert v['unit_volume'] == 'L'
assert v['currency'] == 'USD'

@pytest.mark.xfail(reason="HA route uses Vehicle.unit_distance which does not exist in model")
def test_vehicle_detail(self, client, ha_headers, ha_vehicle):
resp = client.get(f'/api/ha/vehicles/{ha_vehicle.id}', headers=ha_headers)
assert resp.status_code == 200
data = resp.get_json()
assert data['id'] == ha_vehicle.id
assert data['name'] == 'HA Test Car'
assert data['unit_distance'] == 'km'
assert data['unit_volume'] == 'L'
assert data['currency'] == 'USD'

def test_vehicle_detail_not_found(self, client, ha_headers):
resp = client.get('/api/ha/vehicles/99999', headers=ha_headers)
Expand All @@ -144,7 +143,6 @@ def test_vehicle_detail_other_user(self, client, ha_headers, sample_vehicle):


class TestHaVehicleStats:
@pytest.mark.xfail(reason="HA route uses Expense.amount which does not exist in model")
def test_vehicle_stats_no_fuel_logs(self, client, ha_headers, ha_vehicle):
resp = client.get(f'/api/ha/vehicles/{ha_vehicle.id}/stats', headers=ha_headers)
assert resp.status_code == 200
Expand All @@ -153,19 +151,20 @@ def test_vehicle_stats_no_fuel_logs(self, client, ha_headers, ha_vehicle):
assert 'total_distance' in data
assert 'avg_consumption' in data

@pytest.mark.xfail(reason="HA route uses Vehicle.unit_distance which does not exist in model")
def test_vehicle_stats_with_fuel_logs(self, client, ha_headers, ha_vehicle, ha_fuel_log):
resp = client.get(f'/api/ha/vehicles/{ha_vehicle.id}/stats', headers=ha_headers)
assert resp.status_code == 200
data = resp.get_json()
assert data['fill_count'] == 2
assert data['total_fuel'] > 0
assert data['distance_unit'] == 'km'
assert data['volume_unit'] == 'L'
assert data['currency'] == 'USD'

def test_vehicle_stats_not_found(self, client, ha_headers):
resp = client.get('/api/ha/vehicles/99999/stats', headers=ha_headers)
assert resp.status_code == 404

@pytest.mark.xfail(reason="HA route uses Expense.amount which does not exist in model")
def test_vehicle_stats_with_days_param(self, client, ha_headers, ha_vehicle, ha_fuel_log):
resp = client.get(f'/api/ha/vehicles/{ha_vehicle.id}/stats?days=365', headers=ha_headers)
assert resp.status_code == 200
Expand All @@ -174,6 +173,35 @@ def test_vehicle_stats_with_days_param(self, client, ha_headers, ha_vehicle, ha_


class TestHaAlerts:
@pytest.fixture
def due_maintenance(self, ha_user, ha_vehicle):
schedule = MaintenanceSchedule(
vehicle_id=ha_vehicle.id,
user_id=ha_user.id,
name='Oil Change',
maintenance_type='oil_change',
next_due_date=date.today() - timedelta(days=1),
is_active=True,
)
_db_ext.session.add(schedule)
_db_ext.session.commit()
return schedule

@pytest.fixture
def overdue_reminder(self, ha_user, ha_vehicle):
reminder = Reminder(
vehicle_id=ha_vehicle.id,
user_id=ha_user.id,
title='MOT',
reminder_type='mot',
due_date=date.today() - timedelta(days=2),
notify_days_before=7,
is_completed=False,
)
_db_ext.session.add(reminder)
_db_ext.session.commit()
return reminder

def test_alerts_no_issues(self, client, ha_headers):
resp = client.get('/api/ha/alerts', headers=ha_headers)
assert resp.status_code == 200
Expand All @@ -188,6 +216,15 @@ def test_alerts_requires_auth(self, client):
resp = client.get('/api/ha/alerts')
assert resp.status_code == 401

def test_alerts_include_maintenance_and_reminder(self, client, ha_headers, due_maintenance, overdue_reminder):
resp = client.get('/api/ha/alerts', headers=ha_headers)
assert resp.status_code == 200
data = resp.get_json()
assert data['count'] == 2
statuses = {(item['type'], item['status']) for item in data['alerts']}
assert ('maintenance', 'overdue') in statuses
assert ('reminder', 'overdue') in statuses


class TestHaSummary:
def test_summary_no_vehicles(self, client, ha_headers):
Expand All @@ -199,7 +236,6 @@ def test_summary_no_vehicles(self, client, ha_headers):
assert 'alerts_count' in data
assert data['total_vehicles'] == 0

@pytest.mark.xfail(reason="HA route uses Expense.amount which does not exist in model")
def test_summary_with_vehicle(self, client, ha_headers, ha_vehicle, ha_fuel_log):
resp = client.get('/api/ha/summary', headers=ha_headers)
assert resp.status_code == 200
Expand All @@ -208,7 +244,6 @@ def test_summary_with_vehicle(self, client, ha_headers, ha_vehicle, ha_fuel_log)


class TestHaAddFuel:
@pytest.mark.xfail(reason="HA add_fuel route does not set user_id on FuelLog (nullable=False), causing DB integrity error")
def test_add_fuel_success(self, client, ha_headers, ha_vehicle):
resp = client.post(
'/api/ha/fuel/add',
Expand All @@ -227,6 +262,9 @@ def test_add_fuel_success(self, client, ha_headers, ha_vehicle):
data = resp.get_json()
assert data['success'] is True
assert 'id' in data
log = FuelLog.query.get(data['id'])
assert log is not None
assert log.user_id == ha_vehicle.owner_id

def test_add_fuel_missing_required_field(self, client, ha_headers, ha_vehicle):
resp = client.post(
Expand Down
35 changes: 35 additions & 0 deletions tests/test_stations.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,38 @@ def test_cheapest_returns_200(self, auth_client):
def test_cheapest_with_prices(self, auth_client, station_with_prices):
resp = auth_client.get('/stations/cheapest')
assert resp.status_code == 200


class TestStationDeletePrice:
def test_delete_price_requires_auth(self, client, station_with_prices):
price = FuelPriceHistory.query.filter_by(station_id=station_with_prices.id).first()
resp = client.post(f'/stations/prices/{price.id}/delete', follow_redirects=False)
assert resp.status_code == 302
assert '/auth/login' in resp.headers['Location']

def test_delete_price_removes_entry(self, auth_client, station_with_prices):
price = FuelPriceHistory.query.filter_by(station_id=station_with_prices.id).first()
price_id = price.id
resp = auth_client.post(f'/stations/prices/{price_id}/delete', follow_redirects=True)
assert resp.status_code == 200
assert FuelPriceHistory.query.get(price_id) is None

def test_delete_price_other_user_blocked(self, auth_client, sample_station):
from app.models import User
other = User(username='other', email='other@example.com')
other.set_password('pw')
db.session.add(other)
db.session.commit()
price = FuelPriceHistory(
station_id=sample_station.id,
user_id=other.id,
date=date(2024, 4, 1),
fuel_type='petrol',
price_per_unit=1.55,
)
db.session.add(price)
db.session.commit()
price_id = price.id
resp = auth_client.post(f'/stations/prices/{price_id}/delete', follow_redirects=True)
assert resp.status_code == 200
assert FuelPriceHistory.query.get(price_id) is not None