diff --git a/app/models.py b/app/models.py index d45cd58..03953b7 100644 --- a/app/models.py +++ b/app/models.py @@ -2,6 +2,7 @@ from datetime import date, datetime, timedelta from werkzeug.security import generate_password_hash, check_password_hash from flask_login import UserMixin +from flask_babel import lazy_gettext as _l from app import db # Currency symbols for display in UI @@ -269,8 +270,12 @@ def get_total_distance(self, distance_unit=None): return 0 return logs[-1].odometer - logs[0].odometer - def get_average_consumption(self): - """Calculate average fuel consumption""" + def get_average_consumption(self, consumption_unit=None): + """Calculate average fuel consumption. + + Args: + consumption_unit: 'L/100km', 'mpg', or 'mpg_us'. If None, returns L/100km. + """ logs = self.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.odometer).all() if len(logs) < 2: return None @@ -278,7 +283,9 @@ def get_average_consumption(self): total_fuel = sum(log.volume for log in logs[1:] if log.volume) total_distance = logs[-1].odometer - logs[0].odometer - if total_distance > 0: + if total_distance > 0 and total_fuel > 0: + if consumption_unit in ('mpg', 'mpg_us'): + return total_distance / total_fuel return (total_fuel / total_distance) * 100 # L/100km return None @@ -358,7 +365,7 @@ def to_dict(self): 'total_fuel_cost': round(self.get_total_fuel_cost(), 2), 'total_expense_cost': round(self.get_total_expense_cost(), 2), 'total_distance': round(self.get_total_distance(), 2), - 'average_consumption': round(self.get_average_consumption(), 2) if self.get_average_consumption() else None, + 'average_consumption': round(avg, 2) if (avg := self.get_average_consumption()) else None, 'last_odometer': self.get_last_odometer() } } @@ -389,8 +396,12 @@ class FuelLog(db.Model): attachments = db.relationship('Attachment', backref='fuel_log', lazy='dynamic', cascade='all, delete-orphan') - def get_consumption(self): - """Calculate consumption for this fill-up""" + def get_consumption(self, consumption_unit=None): + """Calculate consumption for this fill-up. + + Args: + consumption_unit: 'L/100km', 'mpg', or 'mpg_us'. If None, returns L/100km. + """ if not self.is_full_tank or not self.volume: return None @@ -402,12 +413,15 @@ def get_consumption(self): if prev_log: distance = self.odometer - prev_log.odometer - if distance > 0: + if distance > 0 and self.volume > 0: + if consumption_unit in ('mpg', 'mpg_us'): + return distance / self.volume return (self.volume / distance) * 100 # L/100km return None - def to_dict(self): + def to_dict(self, consumption_unit=None): """Serialize fuel log to dictionary for API""" + consumption = self.get_consumption(consumption_unit) return { 'id': self.id, 'vehicle_id': self.vehicle_id, @@ -420,7 +434,7 @@ def to_dict(self): 'is_missed': self.is_missed, 'station': self.station, 'notes': self.notes, - 'consumption': round(self.get_consumption(), 2) if self.get_consumption() else None, + 'consumption': round(consumption, 2) if consumption else None, 'created_at': self.created_at.isoformat() if self.created_at else None } @@ -607,152 +621,152 @@ def get_all_branding(): # Predefined vehicle specification types VEHICLE_SPEC_TYPES = [ - ('tire_size_front', 'Front Tire Size'), - ('tire_size_rear', 'Rear Tire Size'), - ('wheel_size', 'Wheel Size'), - ('oil_type', 'Engine Oil Type'), - ('oil_capacity', 'Oil Capacity'), - ('coolant_type', 'Coolant Type'), - ('wiper_front', 'Front Wiper Size'), - ('wiper_rear', 'Rear Wiper Size'), - ('battery_type', 'Battery Type'), - ('spark_plug', 'Spark Plug Type'), - ('air_filter', 'Air Filter Part #'), - ('cabin_filter', 'Cabin Filter Part #'), - ('brake_pads_front', 'Front Brake Pads'), - ('brake_pads_rear', 'Rear Brake Pads'), - ('transmission_fluid', 'Transmission Fluid'), - ('custom', 'Custom'), + ('tire_size_front', _l('Front Tire Size')), + ('tire_size_rear', _l('Rear Tire Size')), + ('wheel_size', _l('Wheel Size')), + ('oil_type', _l('Engine Oil Type')), + ('oil_capacity', _l('Oil Capacity')), + ('coolant_type', _l('Coolant Type')), + ('wiper_front', _l('Front Wiper Size')), + ('wiper_rear', _l('Rear Wiper Size')), + ('battery_type', _l('Battery Type')), + ('spark_plug', _l('Spark Plug Type')), + ('air_filter', _l('Air Filter Part #')), + ('cabin_filter', _l('Cabin Filter Part #')), + ('brake_pads_front', _l('Front Brake Pads')), + ('brake_pads_rear', _l('Rear Brake Pads')), + ('transmission_fluid', _l('Transmission Fluid')), + ('custom', _l('Custom')), ] # Expense categories EXPENSE_CATEGORIES = [ - ('maintenance', 'Maintenance'), - ('repairs', 'Repairs'), - ('insurance', 'Insurance'), - ('tax', 'Road Tax'), - ('registration', 'Registration'), - ('parking', 'Parking'), - ('tolls', 'Tolls'), - ('cleaning', 'Cleaning'), - ('accessories', 'Accessories'), - ('other', 'Other') + ('maintenance', _l('Maintenance')), + ('repairs', _l('Repairs')), + ('insurance', _l('Insurance')), + ('tax', _l('Road Tax')), + ('registration', _l('Registration')), + ('parking', _l('Parking')), + ('tolls', _l('Tolls')), + ('cleaning', _l('Cleaning')), + ('accessories', _l('Accessories')), + ('other', _l('Other')) ] # Vehicle types VEHICLE_TYPES = [ - ('car', 'Car'), - ('van', 'Van'), - ('motorbike', 'Motorbike'), - ('scooter', 'Scooter'), - ('truck', 'Truck'), - ('suv', 'SUV'), - ('tractor', 'Tractor'), - ('atv_utv', 'ATV/UTV'), - ('boat', 'Boat'), - ('other', 'Other') + ('car', _l('Car')), + ('van', _l('Van')), + ('motorbike', _l('Motorbike')), + ('scooter', _l('Scooter')), + ('truck', _l('Truck')), + ('suv', _l('SUV')), + ('tractor', _l('Tractor')), + ('atv_utv', _l('ATV/UTV')), + ('boat', _l('Boat')), + ('other', _l('Other')) ] # Tracking unit options TRACKING_UNITS = [ - ('mileage', 'Mileage (km/mi)'), - ('hours', 'Hours'), + ('mileage', _l('Mileage (km/mi)')), + ('hours', _l('Hours')), ] # Odometer unit options (for per-vehicle override) ODOMETER_UNITS = [ - ('km', 'Kilometres (km)'), - ('mi', 'Miles (mi)'), + ('km', _l('Kilometres (km)')), + ('mi', _l('Miles (mi)')), ] # Fuel types FUEL_TYPES = [ - ('petrol', 'Petrol/Gasoline'), - ('diesel', 'Diesel'), - ('electric', 'Electric'), - ('hybrid', 'Hybrid'), - ('plugin_hybrid', 'Plug-in Hybrid'), - ('lpg', 'LPG'), - ('cng', 'CNG'), - ('hydrogen', 'Hydrogen'), - ('e85', 'E85/Flex Fuel'), - ('other', 'Other') + ('petrol', _l('Petrol/Gasoline')), + ('diesel', _l('Diesel')), + ('electric', _l('Electric')), + ('hybrid', _l('Hybrid')), + ('plugin_hybrid', _l('Plug-in Hybrid')), + ('lpg', _l('LPG')), + ('cng', _l('CNG')), + ('hydrogen', _l('Hydrogen')), + ('e85', _l('E85/Flex Fuel')), + ('other', _l('Other')) ] # Reminder types REMINDER_TYPES = [ - ('mot', 'MOT/Inspection'), - ('service', 'Service Due'), - ('insurance', 'Insurance Renewal'), - ('tax', 'Road Tax'), - ('registration', 'Registration Renewal'), - ('warranty', 'Warranty Expiry'), - ('tire_change', 'Tire Change'), - ('oil_change', 'Oil Change'), - ('custom', 'Custom') + ('mot', _l('MOT/Inspection')), + ('service', _l('Service Due')), + ('insurance', _l('Insurance Renewal')), + ('tax', _l('Road Tax')), + ('registration', _l('Registration Renewal')), + ('warranty', _l('Warranty Expiry')), + ('tire_change', _l('Tire Change')), + ('oil_change', _l('Oil Change')), + ('custom', _l('Custom')) ] # Recurrence options RECURRENCE_OPTIONS = [ - ('none', 'No Repeat'), - ('monthly', 'Monthly'), - ('quarterly', 'Quarterly (3 months)'), - ('biannual', 'Every 6 months'), - ('yearly', 'Yearly'), + ('none', _l('No Repeat')), + ('monthly', _l('Monthly')), + ('quarterly', _l('Quarterly (3 months)')), + ('biannual', _l('Every 6 months')), + ('yearly', _l('Yearly')), ] # Trip purposes for tax deductions TRIP_PURPOSES = [ - ('business', 'Business'), - ('personal', 'Personal'), - ('commute', 'Commute'), - ('medical', 'Medical'), - ('charity', 'Charity'), - ('other', 'Other'), + ('business', _l('Business')), + ('personal', _l('Personal')), + ('commute', _l('Commute')), + ('medical', _l('Medical')), + ('charity', _l('Charity')), + ('other', _l('Other')), ] # EV charger types CHARGER_TYPES = [ - ('home', 'Home Charging'), - ('level1', 'Level 1'), - ('level2', 'Level 2'), - ('dcfc', 'DC Fast Charge'), - ('tesla', 'Tesla Supercharger'), - ('other', 'Other'), + ('home', _l('Home Charging')), + ('level1', _l('Level 1')), + ('level2', _l('Level 2')), + ('dcfc', _l('DC Fast Charge')), + ('tesla', _l('Tesla Supercharger')), + ('other', _l('Other')), ] # Maintenance schedule types MAINTENANCE_TYPES = [ - ('oil_change', 'Oil Change'), - ('oil_filter', 'Oil Filter'), - ('air_filter', 'Air Filter'), - ('cabin_filter', 'Cabin/Pollen Filter'), - ('fuel_filter', 'Fuel Filter'), - ('spark_plugs', 'Spark Plugs'), - ('brake_pads', 'Brake Pads'), - ('brake_fluid', 'Brake Fluid'), - ('coolant', 'Coolant Flush'), - ('transmission', 'Transmission Service'), - ('timing_belt', 'Timing Belt'), - ('serpentine_belt', 'Serpentine Belt'), - ('tire_rotation', 'Tire Rotation'), - ('wheel_alignment', 'Wheel Alignment'), - ('battery', 'Battery Check/Replace'), - ('wiper_blades', 'Wiper Blades'), - ('full_service', 'Full Service'), - ('custom', 'Custom'), + ('oil_change', _l('Oil Change')), + ('oil_filter', _l('Oil Filter')), + ('air_filter', _l('Air Filter')), + ('cabin_filter', _l('Cabin/Pollen Filter')), + ('fuel_filter', _l('Fuel Filter')), + ('spark_plugs', _l('Spark Plugs')), + ('brake_pads', _l('Brake Pads')), + ('brake_fluid', _l('Brake Fluid')), + ('coolant', _l('Coolant Flush')), + ('transmission', _l('Transmission Service')), + ('timing_belt', _l('Timing Belt')), + ('serpentine_belt', _l('Serpentine Belt')), + ('tire_rotation', _l('Tire Rotation')), + ('wheel_alignment', _l('Wheel Alignment')), + ('battery', _l('Battery Check/Replace')), + ('wiper_blades', _l('Wiper Blades')), + ('full_service', _l('Full Service')), + ('custom', _l('Custom')), ] # Document types DOCUMENT_TYPES = [ - ('insurance', 'Insurance Policy'), - ('registration', 'Registration/V5C'), - ('mot', 'MOT Certificate'), - ('service_record', 'Service Record'), - ('purchase', 'Purchase Invoice'), - ('warranty', 'Warranty Document'), - ('manual', 'Owner\'s Manual'), - ('other', 'Other'), + ('insurance', _l('Insurance Policy')), + ('registration', _l('Registration/V5C')), + ('mot', _l('MOT Certificate')), + ('service_record', _l('Service Record')), + ('purchase', _l('Purchase Invoice')), + ('warranty', _l('Warranty Document')), + ('manual', _l("Owner's Manual")), + ('other', _l('Other')), ] @@ -854,7 +868,7 @@ class RecurringExpense(db.Model): name = db.Column(db.String(100), nullable=False) category = db.Column(db.String(50), nullable=False) description = db.Column(db.String(200)) - amount = db.Column(db.Float, nullable=False) + amount = db.Column(db.Float, nullable=True) vendor = db.Column(db.String(100)) # Recurrence settings @@ -1101,22 +1115,22 @@ def to_dict(self): # Part types for vehicle parts catalog PART_TYPES = [ - ('oil', 'Engine Oil'), - ('oil_filter', 'Oil Filter'), - ('air_filter', 'Air Filter'), - ('fuel_filter', 'Fuel Filter'), - ('cabin_filter', 'Cabin Filter'), - ('spark_plug', 'Spark Plug'), - ('brake_pad', 'Brake Pad'), - ('brake_fluid', 'Brake Fluid'), - ('coolant', 'Coolant'), - ('transmission_fluid', 'Transmission Fluid'), - ('battery', 'Battery'), - ('tire', 'Tire'), - ('belt', 'Belt'), - ('wiper', 'Wiper Blade'), - ('bulb', 'Light Bulb'), - ('other', 'Other'), + ('oil', _l('Engine Oil')), + ('oil_filter', _l('Oil Filter')), + ('air_filter', _l('Air Filter')), + ('fuel_filter', _l('Fuel Filter')), + ('cabin_filter', _l('Cabin Filter')), + ('spark_plug', _l('Spark Plug')), + ('brake_pad', _l('Brake Pad')), + ('brake_fluid', _l('Brake Fluid')), + ('coolant', _l('Coolant')), + ('transmission_fluid', _l('Transmission Fluid')), + ('battery', _l('Battery')), + ('tire', _l('Tire')), + ('belt', _l('Belt')), + ('wiper', _l('Wiper Blade')), + ('bulb', _l('Light Bulb')), + ('other', _l('Other')), ] diff --git a/app/routes/api.py b/app/routes/api.py index f434977..5f7ff3b 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -274,7 +274,7 @@ def vehicle_stats(vehicle_id): logs = vehicle.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.date).all() consumption_data = [] for log in logs: - consumption = log.get_consumption() + consumption = log.get_consumption(current_user.consumption_unit) if consumption: consumption_data.append({ 'date': log.date.isoformat(), @@ -296,7 +296,7 @@ def vehicle_stats(vehicle_id): 'total_fuel_cost': vehicle.get_total_fuel_cost(), 'total_expense_cost': vehicle.get_total_expense_cost(), 'total_distance': vehicle.get_total_distance(vehicle.get_effective_odometer_unit()), - 'avg_consumption': vehicle.get_average_consumption() + 'avg_consumption': vehicle.get_average_consumption(current_user.consumption_unit) }) diff --git a/app/routes/fuel.py b/app/routes/fuel.py index f221b6b..20891eb 100644 --- a/app/routes/fuel.py +++ b/app/routes/fuel.py @@ -227,6 +227,14 @@ def delete(log_id): if os.path.exists(file_path): os.remove(file_path) + # Clean up matching fuel price history entries + if log.price_per_unit and log.date: + FuelPriceHistory.query.filter( + FuelPriceHistory.user_id == current_user.id, + FuelPriceHistory.date == log.date, + FuelPriceHistory.price_per_unit == log.price_per_unit + ).delete() + db.session.delete(log) db.session.commit() flash(_('Fuel log deleted successfully'), 'success') @@ -249,7 +257,7 @@ def quick(): stations = FuelStation.query.order_by( FuelStation.is_favorite.desc(), FuelStation.times_used.desc() - ).limit(10).all() + ).all() if request.method == 'POST': vehicle_id = int(request.form.get('vehicle_id')) diff --git a/app/routes/vehicles.py b/app/routes/vehicles.py index a192320..62960f8 100644 --- a/app/routes/vehicles.py +++ b/app/routes/vehicles.py @@ -124,7 +124,7 @@ def view(vehicle_id): 'total_expense_cost': vehicle.get_total_expense_cost(), 'total_cost': vehicle.get_total_cost(), 'total_distance': vehicle.get_total_distance(vehicle.get_effective_odometer_unit()), - 'avg_consumption': vehicle.get_average_consumption(), + 'avg_consumption': vehicle.get_average_consumption(current_user.consumption_unit), 'fuel_logs_count': vehicle.fuel_logs.count(), 'expenses_count': vehicle.expenses.count() } @@ -365,7 +365,7 @@ def report(vehicle_id): 'total_expense_cost': vehicle.get_total_expense_cost(), 'total_cost': vehicle.get_total_cost(), 'total_distance': vehicle.get_total_distance(vehicle.get_effective_odometer_unit()), - 'avg_consumption': vehicle.get_average_consumption(), + 'avg_consumption': vehicle.get_average_consumption(current_user.consumption_unit), 'fuel_logs_count': len(fuel_logs), 'expenses_count': len(expenses) } diff --git a/app/templates/auth/settings.html b/app/templates/auth/settings.html index 137a8a5..fcdec40 100644 --- a/app/templates/auth/settings.html +++ b/app/templates/auth/settings.html @@ -11,7 +11,7 @@

{{ _('Settings