diff --git a/app/routes/homeassistant.py b/app/routes/homeassistant.py index c3846fb..89bb6da 100644 --- a/app/routes/homeassistant.py +++ b/app/routes/homeassistant.py @@ -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) @@ -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 }) @@ -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: @@ -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 }) @@ -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 }) @@ -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 }) @@ -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']), diff --git a/app/routes/stations.py b/app/routes/stations.py index fd9a8dc..b85eafa 100644 --- a/app/routes/stations.py +++ b/app/routes/stations.py @@ -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//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(): diff --git a/app/services/notifications.py b/app/services/notifications.py index 1b494ff..0647230 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -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 diff --git a/app/templates/stations/prices.html b/app/templates/stations/prices.html index 051424d..fa77684 100644 --- a/app/templates/stations/prices.html +++ b/app/templates/stations/prices.html @@ -32,12 +32,26 @@

{{ _('Price Histor diff --git a/config.py b/config.py index 0bea5b0..62d87ba 100644 --- a/config.py +++ b/config.py @@ -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' diff --git a/requirements.txt b/requirements.txt index dfad6e5..35cbebb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_api_ha.py b/tests/test_api_ha.py index 064efd3..5685f8e 100644 --- a/tests/test_api_ha.py +++ b/tests/test_api_ha.py @@ -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): @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 @@ -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): @@ -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 @@ -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', @@ -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( diff --git a/tests/test_stations.py b/tests/test_stations.py index e488112..e491456 100644 --- a/tests/test_stations.py +++ b/tests/test_stations.py @@ -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