From 539a3a6d9bf4f9fa579c288522a6c563a40510ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:26:02 +0000 Subject: [PATCH 1/5] deps: update weasyprint requirement from >=68.1 to >=69.0 Updates the requirements on [weasyprint](https://github.com/Kozea/WeasyPrint) to permit the latest version. - [Release notes](https://github.com/Kozea/WeasyPrint/releases) - [Changelog](https://github.com/Kozea/WeasyPrint/blob/main/docs/changelog.rst) - [Commits](https://github.com/Kozea/WeasyPrint/compare/v68.1...v69.0) --- updated-dependencies: - dependency-name: weasyprint dependency-version: '69.0' dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 35cbebb..060bbb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ werkzeug>=3.1.8 python-dotenv>=1.2.2 pillow>=12.2.0 gunicorn>=25.3.0 -weasyprint>=68.1 +weasyprint>=69.0 requests>=2.34.2 python-dateutil>=2.9.0.post0 pytest>=9.0.3 From 3b295d7f174258b60fb0fa8c9b31596baca016a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:26:07 +0000 Subject: [PATCH 2/5] deps: update coverage requirement from >=7.14.0 to >=7.14.1 Updates the requirements on [coverage](https://github.com/coveragepy/coveragepy) to permit the latest version. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.14.0...7.14.1) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.14.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 35cbebb..fcedd63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,4 @@ requests>=2.34.2 python-dateutil>=2.9.0.post0 pytest>=9.0.3 pytest-cov>=7.1.0 -coverage>=7.14.0 +coverage>=7.14.1 From 987bd9721c53086be729b88cba9fff2b92bf713f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:09:54 +0000 Subject: [PATCH 3/5] deps: update gunicorn requirement from >=25.3.0 to >=26.0.0 Updates the requirements on [gunicorn](https://github.com/benoitc/gunicorn) to permit the latest version. - [Release notes](https://github.com/benoitc/gunicorn/releases) - [Commits](https://github.com/benoitc/gunicorn/compare/25.3.0...26.0.0) --- updated-dependencies: - dependency-name: gunicorn dependency-version: 26.0.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bb62312..4bcb38b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ flask-babel>=4.0.0 werkzeug>=3.1.8 python-dotenv>=1.2.2 pillow>=12.2.0 -gunicorn>=25.3.0 +gunicorn>=26.0.0 weasyprint>=69.0 requests>=2.34.2 python-dateutil>=2.9.0.post0 From 9defc9aaeefda4217455ef1007ae6486a2437a5e Mon Sep 17 00:00:00 2001 From: Danny McClelland Date: Thu, 4 Jun 2026 20:55:31 +0100 Subject: [PATCH 4/5] feat: add fuel discount, notes and mileage allowance Implements three feature requests: - #209 Fuel discount: optional discount-per-unit on fuel logs. When the total cost isn't entered it's computed as volume * (price - discount), matching the amount actually paid with a loyalty discount. - #204 Notes: freeform per-vehicle notes with a date and optional odometer reading, for recording things that don't fit fuel/expenses/maintenance (e.g. a DPF regeneration). Full CRUD with a menu-visibility toggle. - #208 Mileage allowance: track allowance income received for business use. Totals offset a vehicle's running costs; the dashboard and vehicle detail page surface the allowance and resulting net cost. Full CRUD with a menu-visibility toggle. Adds Flask-Migrate migrations for the new column and tables (idempotent, following the existing defensive pattern) and test coverage for all three. --- app/__init__.py | 4 +- app/models.py | 84 ++++++++++++ app/routes/allowance.py | 116 ++++++++++++++++ app/routes/auth.py | 4 + app/routes/fuel.py | 19 ++- app/routes/main.py | 15 ++- app/routes/notes.py | 124 ++++++++++++++++++ app/routes/vehicles.py | 2 + app/templates/allowance/form.html | 110 ++++++++++++++++ app/templates/allowance/index.html | 79 +++++++++++ app/templates/auth/settings.html | 12 ++ app/templates/base.html | 16 ++- app/templates/dashboard.html | 38 ++++++ app/templates/fuel/form.html | 14 +- app/templates/notes/form.html | 92 +++++++++++++ app/templates/notes/index.html | 104 +++++++++++++++ app/templates/vehicles/view.html | 12 ++ ...a5b6_add_discount_per_unit_to_fuel_logs.py | 33 +++++ .../versions/c2d3e4f5a6b7_add_notes_table.py | 48 +++++++ ...d4e5f6a7b8_add_mileage_allowances_table.py | 49 +++++++ tests/test_allowance.py | 105 +++++++++++++++ tests/test_fuel.py | 49 +++++++ tests/test_notes.py | 114 ++++++++++++++++ 23 files changed, 1235 insertions(+), 8 deletions(-) create mode 100644 app/routes/allowance.py create mode 100644 app/routes/notes.py create mode 100644 app/templates/allowance/form.html create mode 100644 app/templates/allowance/index.html create mode 100644 app/templates/notes/form.html create mode 100644 app/templates/notes/index.html create mode 100644 migrations/versions/c1d2e3f4a5b6_add_discount_per_unit_to_fuel_logs.py create mode 100644 migrations/versions/c2d3e4f5a6b7_add_notes_table.py create mode 100644 migrations/versions/c3d4e5f6a7b8_add_mileage_allowances_table.py create mode 100644 tests/test_allowance.py create mode 100644 tests/test_notes.py diff --git a/app/__init__.py b/app/__init__.py index 0fd3b48..59ec15e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -324,7 +324,7 @@ def format_date_filter(value, style='default'): fmt = formats.get(style, formats['default']) return value.strftime(fmt) - from app.routes import main, auth, vehicles, fuel, expenses, api, reminders, maintenance, documents, stations, recurring, homeassistant, calendar, trips, charging + from app.routes import main, auth, vehicles, fuel, expenses, api, reminders, maintenance, documents, stations, recurring, homeassistant, calendar, trips, charging, notes, allowance app.register_blueprint(main.bp) app.register_blueprint(auth.bp) app.register_blueprint(vehicles.bp) @@ -340,6 +340,8 @@ def format_date_filter(value, style='default'): app.register_blueprint(calendar.bp) app.register_blueprint(trips.bp) app.register_blueprint(charging.bp) + app.register_blueprint(notes.bp) + app.register_blueprint(allowance.bp) # Health check endpoint for container orchestration @app.route('/health') diff --git a/app/models.py b/app/models.py index bcb8595..90ae656 100644 --- a/app/models.py +++ b/app/models.py @@ -88,6 +88,8 @@ class User(UserMixin, db.Model): show_menu_stations = db.Column(db.Boolean, default=True) show_menu_trips = db.Column(db.Boolean, default=True) show_menu_charging = db.Column(db.Boolean, default=True) + show_menu_notes = db.Column(db.Boolean, default=True) # issue #204 + show_menu_allowance = db.Column(db.Boolean, default=True) # issue #208 show_quick_entry = db.Column(db.Boolean, default=False) # Show quick entry button in navbar # Relationships @@ -254,6 +256,14 @@ def get_total_expense_cost(self): def get_total_cost(self): return self.get_total_fuel_cost() + self.get_total_expense_cost() + self.get_total_charging_cost() + def get_total_allowance(self): + """Total mileage-allowance income recorded for this vehicle (issue #208).""" + return sum(a.amount for a in self.mileage_allowances.all() if a.amount) or 0 + + def get_net_cost(self): + """Running cost after mileage allowance is deducted (issue #208).""" + return self.get_total_cost() - self.get_total_allowance() + @property def vehicle_type_label(self): return dict(VEHICLE_TYPES).get(self.vehicle_type, self.vehicle_type.replace('_', ' ').title()) @@ -582,6 +592,7 @@ class FuelLog(db.Model): odometer = db.Column(db.Float, nullable=False) # stored in km volume = db.Column(db.Float) # stored in liters price_per_unit = db.Column(db.Float) # price per liter + discount_per_unit = db.Column(db.Float) # optional loyalty discount per liter (issue #209) total_cost = db.Column(db.Float) fuel_type = db.Column(db.String(20), nullable=True) # overrides vehicle primary; set when vehicle has secondary fuel type @@ -656,6 +667,7 @@ def to_dict(self, consumption_unit=None, volume_unit='L'): 'odometer': self.odometer, 'volume': self.volume, 'price_per_unit': self.price_per_unit, + 'discount_per_unit': self.discount_per_unit, 'total_cost': self.total_cost, 'is_full_tank': self.is_full_tank, 'is_missed': self.is_missed, @@ -1462,3 +1474,75 @@ class FuelPriceHistory(db.Model): # Relationships station = db.relationship('FuelStation', backref=db.backref('price_history', lazy='dynamic')) user = db.relationship('User', backref=db.backref('fuel_price_history', lazy='dynamic')) + + +class Note(db.Model): + """Freeform note attached to a vehicle, with optional odometer reading. + + Issue #204: a place to record things that don't fit fuel/expenses/maintenance + (e.g. a DPF regeneration) without inventing a cost. + """ + __tablename__ = 'notes' + + id = db.Column(db.Integer, primary_key=True) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + date = db.Column(db.Date, nullable=False, default=datetime.utcnow) + title = db.Column(db.String(200)) + content = db.Column(db.Text, nullable=False) + odometer = db.Column(db.Float) # optional, stored in vehicle odometer unit + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships — backref is `note_entries` to avoid clashing with Vehicle.notes column + vehicle = db.relationship('Vehicle', backref=db.backref('note_entries', lazy='dynamic', cascade='all, delete-orphan')) + user = db.relationship('User', backref=db.backref('notes', lazy='dynamic')) + + def to_dict(self): + return { + 'id': self.id, + 'vehicle_id': self.vehicle_id, + 'date': self.date.isoformat() if self.date else None, + 'title': self.title, + 'content': self.content, + 'odometer': self.odometer, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + +class MileageAllowance(db.Model): + """Mileage-allowance income for a vehicle used for business (issue #208). + + Records money received per the recorded distance; the totals offset the + vehicle's running costs (see Vehicle.get_net_cost). + """ + __tablename__ = 'mileage_allowances' + + id = db.Column(db.Integer, primary_key=True) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + date = db.Column(db.Date, nullable=False, default=datetime.utcnow) + description = db.Column(db.String(200)) + distance = db.Column(db.Float) # optional, stored in vehicle odometer unit + rate_per_unit = db.Column(db.Float) # optional reimbursement rate per distance unit + amount = db.Column(db.Float, nullable=False) # total amount received + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + vehicle = db.relationship('Vehicle', backref=db.backref('mileage_allowances', lazy='dynamic', cascade='all, delete-orphan')) + user = db.relationship('User', backref=db.backref('mileage_allowances', lazy='dynamic')) + + def to_dict(self): + return { + 'id': self.id, + 'vehicle_id': self.vehicle_id, + 'date': self.date.isoformat() if self.date else None, + 'description': self.description, + 'distance': self.distance, + 'rate_per_unit': self.rate_per_unit, + 'amount': self.amount, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } diff --git a/app/routes/allowance.py b/app/routes/allowance.py new file mode 100644 index 0000000..aacc9be --- /dev/null +++ b/app/routes/allowance.py @@ -0,0 +1,116 @@ +from datetime import datetime +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_required, current_user +from flask_babel import gettext as _ +from app import db +from app.models import Vehicle, MileageAllowance + +bp = Blueprint('allowance', __name__, url_prefix='/allowance') + + +@bp.route('/') +@login_required +def index(): + vehicles = current_user.get_all_vehicles() + vehicle_ids = [v.id for v in vehicles] + + allowances = MileageAllowance.query.filter( + MileageAllowance.vehicle_id.in_(vehicle_ids) + ).order_by(MileageAllowance.date.desc()).all() + + return render_template('allowance/index.html', allowances=allowances, vehicles=vehicles) + + +@bp.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + vehicles = current_user.get_all_vehicles() + + if not vehicles: + flash(_('Please add a vehicle first'), 'info') + return redirect(url_for('vehicles.new')) + + if request.method == 'POST': + vehicle_id = int(request.form.get('vehicle_id')) + vehicle = Vehicle.query.get_or_404(vehicle_id) + + # Check access + if vehicle not in vehicles: + flash(_('Access denied'), 'error') + return redirect(url_for('allowance.index')) + + try: + date_str = request.form.get('date') + allowance = MileageAllowance( + vehicle_id=vehicle_id, + user_id=current_user.id, + date=datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else datetime.now().date(), + description=request.form.get('description') or None, + distance=float(request.form.get('distance')) if request.form.get('distance') else None, + rate_per_unit=float(request.form.get('rate_per_unit')) if request.form.get('rate_per_unit') else None, + amount=float(request.form.get('amount')), + ) + except (ValueError, TypeError): + flash(_('Invalid data submitted. Please check the date and amount fields.'), 'error') + return render_template('allowance/form.html', allowance=None, vehicles=vehicles, + selected_vehicle_id=vehicle_id) + + db.session.add(allowance) + db.session.commit() + flash(_('Mileage allowance added successfully'), 'success') + return redirect(url_for('vehicles.view', vehicle_id=vehicle_id)) + + selected_vehicle_id = request.args.get('vehicle_id', type=int) or current_user.default_vehicle_id + + return render_template('allowance/form.html', allowance=None, vehicles=vehicles, + selected_vehicle_id=selected_vehicle_id) + + +@bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(allowance_id): + allowance = MileageAllowance.query.get_or_404(allowance_id) + vehicles = current_user.get_all_vehicles() + + # Check access + if allowance.vehicle not in vehicles: + flash(_('Access denied'), 'error') + return redirect(url_for('allowance.index')) + + if request.method == 'POST': + try: + date_str = request.form.get('date') + allowance.date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else allowance.date + allowance.description = request.form.get('description') or None + allowance.distance = float(request.form.get('distance')) if request.form.get('distance') else None + allowance.rate_per_unit = float(request.form.get('rate_per_unit')) if request.form.get('rate_per_unit') else None + allowance.amount = float(request.form.get('amount')) + except (ValueError, TypeError): + flash(_('Invalid data submitted. Please check the date and amount fields.'), 'error') + return render_template('allowance/form.html', allowance=allowance, vehicles=vehicles, + selected_vehicle_id=allowance.vehicle_id) + + db.session.commit() + flash(_('Mileage allowance updated successfully'), 'success') + return redirect(url_for('vehicles.view', vehicle_id=allowance.vehicle_id)) + + return render_template('allowance/form.html', allowance=allowance, vehicles=vehicles, + selected_vehicle_id=allowance.vehicle_id) + + +@bp.route('//delete', methods=['POST']) +@login_required +def delete(allowance_id): + allowance = MileageAllowance.query.get_or_404(allowance_id) + vehicles = current_user.get_all_vehicles() + + # Check access + if allowance.vehicle not in vehicles: + flash(_('Access denied'), 'error') + return redirect(url_for('allowance.index')) + + vehicle_id = allowance.vehicle_id + db.session.delete(allowance) + db.session.commit() + flash(_('Mileage allowance deleted successfully'), 'success') + return redirect(url_for('vehicles.view', vehicle_id=vehicle_id)) diff --git a/app/routes/auth.py b/app/routes/auth.py index 77dfffc..5891043 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -143,6 +143,8 @@ def get_start_page_url(user): 'stations': 'stations.index', 'trips': 'trips.index', 'charging': 'charging.index', + 'notes': 'notes.index', + 'allowance': 'allowance.index', } route = page_routes.get(start_page, 'main.dashboard') return url_for(route) @@ -352,6 +354,8 @@ def menu_preferences(): current_user.show_menu_stations = request.form.get('show_menu_stations') == 'on' current_user.show_menu_trips = request.form.get('show_menu_trips') == 'on' current_user.show_menu_charging = request.form.get('show_menu_charging') == 'on' + current_user.show_menu_notes = request.form.get('show_menu_notes') == 'on' + current_user.show_menu_allowance = request.form.get('show_menu_allowance') == 'on' current_user.show_quick_entry = request.form.get('show_quick_entry') == 'on' db.session.commit() flash(_('Menu preferences updated'), 'success') diff --git a/app/routes/fuel.py b/app/routes/fuel.py index ad15ef7..d5dc7ad 100644 --- a/app/routes/fuel.py +++ b/app/routes/fuel.py @@ -80,6 +80,13 @@ def new(): flash(err, 'error') return render_template('fuel/new.html', vehicles=vehicles) + discount_per_unit = None + if request.form.get('discount_per_unit'): + discount_per_unit, err = validate_positive_number(request.form.get('discount_per_unit'), 'Discount per unit', max_value=1000) + if err: + flash(err, 'error') + return render_template('fuel/new.html', vehicles=vehicles) + total_cost, err = validate_positive_number(request.form.get('total_cost'), 'Total cost', max_value=100000) if err: flash(err, 'error') @@ -92,6 +99,7 @@ def new(): odometer=odometer, volume=volume, price_per_unit=price_per_unit, + discount_per_unit=discount_per_unit, total_cost=total_cost, fuel_type=request.form.get('fuel_type') or None, is_full_tank=request.form.get('is_full_tank') == 'on', @@ -100,9 +108,10 @@ def new(): notes=request.form.get('notes') ) - # Calculate total cost if not provided + # Calculate total cost if not provided, applying any per-unit discount (#209) if log.volume and log.price_per_unit and not log.total_cost: - log.total_cost = round(log.volume * log.price_per_unit, 2) + effective_price = log.price_per_unit - (log.discount_per_unit or 0) + log.total_cost = round(log.volume * effective_price, 2) db.session.add(log) db.session.commit() @@ -186,6 +195,7 @@ def edit(log_id): log.odometer = float(request.form.get('odometer')) log.volume = float(request.form.get('volume')) if request.form.get('volume') else None log.price_per_unit = float(request.form.get('price_per_unit')) if request.form.get('price_per_unit') else None + log.discount_per_unit = float(request.form.get('discount_per_unit')) if request.form.get('discount_per_unit') else None log.total_cost = float(request.form.get('total_cost')) if request.form.get('total_cost') else None log.fuel_type = request.form.get('fuel_type') or None log.is_full_tank = request.form.get('is_full_tank') == 'on' @@ -193,9 +203,10 @@ def edit(log_id): log.station = request.form.get('station') log.notes = request.form.get('notes') - # Calculate total cost if not provided + # Calculate total cost if not provided, applying any per-unit discount (#209) if log.volume and log.price_per_unit and not log.total_cost: - log.total_cost = round(log.volume * log.price_per_unit, 2) + effective_price = log.price_per_unit - (log.discount_per_unit or 0) + log.total_cost = round(log.volume * effective_price, 2) # Reconcile fuel price history with the edited log. # Issue #170: linking a station to an existing log via edit must diff --git a/app/routes/main.py b/app/routes/main.py index c129eb8..4bea9af 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from sqlalchemy import func from app import db -from app.models import Vehicle, FuelLog, Expense, ChargingSession, FuelPriceHistory, FuelStation +from app.models import Vehicle, FuelLog, Expense, ChargingSession, FuelPriceHistory, FuelStation, MileageAllowance bp = Blueprint('main', __name__) @@ -26,6 +26,8 @@ def index(): 'stations': 'stations.index', 'trips': 'trips.index', 'charging': 'charging.index', + 'notes': 'notes.index', + 'allowance': 'allowance.index', } route = page_routes.get(start_page, 'main.dashboard') return redirect(url_for(route)) @@ -97,8 +99,17 @@ def dashboard(): ).scalar() total_charging_cost = charging_result or 0 + # Total mileage allowance received (#208) — offsets running costs + total_allowance = 0 + if vehicle_ids: + allowance_result = db.session.query(func.sum(MileageAllowance.amount)).filter( + MileageAllowance.vehicle_id.in_(vehicle_ids) + ).scalar() + total_allowance = allowance_result or 0 + # Calculate cost per distance total_cost = total_fuel_cost + total_expense_cost + total_charging_cost + net_cost = total_cost - total_allowance cost_per_distance = None if total_distance > 0: cost_per_distance = total_cost / total_distance @@ -124,6 +135,8 @@ def dashboard(): total_fuel_cost=total_fuel_cost, total_expense_cost=total_expense_cost, total_charging_cost=total_charging_cost, + total_allowance=total_allowance, + net_cost=net_cost, total_distance=total_distance, cost_per_distance=cost_per_distance, recent_logs=recent_logs, diff --git a/app/routes/notes.py b/app/routes/notes.py new file mode 100644 index 0000000..824bc57 --- /dev/null +++ b/app/routes/notes.py @@ -0,0 +1,124 @@ +from datetime import datetime +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_required, current_user +from flask_babel import gettext as _ +from app import db +from app.models import Vehicle, Note + +bp = Blueprint('notes', __name__, url_prefix='/notes') + + +@bp.route('/') +@login_required +def index(): + vehicles = current_user.get_all_vehicles() + vehicle_ids = [v.id for v in vehicles] + + notes = Note.query.filter( + Note.vehicle_id.in_(vehicle_ids) + ).order_by(Note.date.desc()).all() + + return render_template('notes/index.html', notes=notes, vehicles=vehicles) + + +@bp.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + vehicles = current_user.get_all_vehicles() + + if not vehicles: + flash(_('Please add a vehicle first'), 'info') + return redirect(url_for('vehicles.new')) + + if request.method == 'POST': + vehicle_id = int(request.form.get('vehicle_id')) + vehicle = Vehicle.query.get_or_404(vehicle_id) + + # Check access + if vehicle not in vehicles: + flash(_('Access denied'), 'error') + return redirect(url_for('notes.index')) + + try: + date_str = request.form.get('date') + note = Note( + vehicle_id=vehicle_id, + user_id=current_user.id, + date=datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else datetime.now().date(), + title=request.form.get('title') or None, + content=request.form.get('content'), + odometer=float(request.form.get('odometer')) if request.form.get('odometer') else None, + ) + except (ValueError, TypeError): + flash(_('Invalid data submitted. Please check the date and odometer fields.'), 'error') + return render_template('notes/form.html', note=None, vehicles=vehicles, + selected_vehicle_id=vehicle_id) + + if not note.content: + flash(_('Please enter some note text'), 'error') + return render_template('notes/form.html', note=None, vehicles=vehicles, + selected_vehicle_id=vehicle_id) + + db.session.add(note) + db.session.commit() + flash(_('Note added successfully'), 'success') + return redirect(url_for('vehicles.view', vehicle_id=vehicle_id)) + + selected_vehicle_id = request.args.get('vehicle_id', type=int) or current_user.default_vehicle_id + + return render_template('notes/form.html', note=None, vehicles=vehicles, + selected_vehicle_id=selected_vehicle_id) + + +@bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(note_id): + note = Note.query.get_or_404(note_id) + vehicles = current_user.get_all_vehicles() + + # Check access + if note.vehicle not in vehicles: + flash(_('Access denied'), 'error') + return redirect(url_for('notes.index')) + + if request.method == 'POST': + try: + date_str = request.form.get('date') + note.date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else note.date + note.title = request.form.get('title') or None + note.content = request.form.get('content') + note.odometer = float(request.form.get('odometer')) if request.form.get('odometer') else None + except (ValueError, TypeError): + flash(_('Invalid data submitted. Please check the date and odometer fields.'), 'error') + return render_template('notes/form.html', note=note, vehicles=vehicles, + selected_vehicle_id=note.vehicle_id) + + if not note.content: + flash(_('Please enter some note text'), 'error') + return render_template('notes/form.html', note=note, vehicles=vehicles, + selected_vehicle_id=note.vehicle_id) + + db.session.commit() + flash(_('Note updated successfully'), 'success') + return redirect(url_for('vehicles.view', vehicle_id=note.vehicle_id)) + + return render_template('notes/form.html', note=note, vehicles=vehicles, + selected_vehicle_id=note.vehicle_id) + + +@bp.route('//delete', methods=['POST']) +@login_required +def delete(note_id): + note = Note.query.get_or_404(note_id) + vehicles = current_user.get_all_vehicles() + + # Check access + if note.vehicle not in vehicles: + flash(_('Access denied'), 'error') + return redirect(url_for('notes.index')) + + vehicle_id = note.vehicle_id + db.session.delete(note) + db.session.commit() + flash(_('Note deleted successfully'), 'success') + return redirect(url_for('vehicles.view', vehicle_id=vehicle_id)) diff --git a/app/routes/vehicles.py b/app/routes/vehicles.py index 5695138..abe4bd3 100644 --- a/app/routes/vehicles.py +++ b/app/routes/vehicles.py @@ -130,6 +130,8 @@ def view(vehicle_id): 'avg_charging_consumption': vehicle.get_average_charging_consumption(), 'cost_per_kwh': vehicle.get_cost_per_kwh(), 'total_cost': vehicle.get_total_cost(), + 'total_allowance': vehicle.get_total_allowance(), + 'net_cost': vehicle.get_net_cost(), 'total_distance': vehicle.get_total_distance(vehicle.get_effective_odometer_unit()), 'avg_consumption': vehicle.get_average_consumption(current_user.consumption_unit, current_user.volume_unit), 'cost_per_distance': vehicle.get_cost_per_distance(), diff --git a/app/templates/allowance/form.html b/app/templates/allowance/form.html new file mode 100644 index 0000000..6816210 --- /dev/null +++ b/app/templates/allowance/form.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}{% if allowance %}{{ _('Edit Allowance') }}{% else %}{{ _('Add Allowance') }}{% endif %}{% endblock %} + +{% block content %} +
+
+ ← {{ _('Back to mileage allowance') }} +

{% if allowance %}{{ _('Edit Allowance') }}{% else %}{{ _('Add Allowance') }}{% endif %}

+
+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

{{ _('Optional. If set with distance, the amount is calculated for you.') }}

+
+ +
+ + +
+
+
+ +
+ + {{ _('Cancel') }} + + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/allowance/index.html b/app/templates/allowance/index.html new file mode 100644 index 0000000..1e51b6a --- /dev/null +++ b/app/templates/allowance/index.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}{{ _('Mileage Allowance') }}{% endblock %} + +{% block content %} +
+
+

{{ _('Mileage Allowance') }}

+

{{ _('Record allowance income for business use; it offsets your running costs') }}

+
+ + + + + {{ _('Add Allowance') }} + +
+ +{% if allowances %} +
+
+ + + + + + + + + + + + + {% for allowance in allowances %} + + + + + + + + + {% endfor %} + +
{{ _('Date') }}{{ _('Vehicle') }}{{ _('Description') }}{{ _('Distance') }}{{ _('Amount') }}{{ _('Actions') }}
{{ allowance.date|format_date }} + + {{ allowance.vehicle.name }} + + {{ allowance.description or '-' }} + {% if allowance.distance %}{{ allowance.distance|int }} {{ allowance.vehicle.get_effective_odometer_unit() }}{% else %}-{% endif %} + {{ "%.2f"|format(allowance.amount) }} {{ current_user.currency }} + {{ _('Edit') }} +
+ + +
+
+
+
+{% else %} +
+ + + +

{{ _('No allowance entries') }}

+

{{ _('Track mileage allowance you receive for business journeys.') }}

+ +
+{% endif %} +{% endblock %} diff --git a/app/templates/auth/settings.html b/app/templates/auth/settings.html index 126234d..5c91313 100644 --- a/app/templates/auth/settings.html +++ b/app/templates/auth/settings.html @@ -411,6 +411,8 @@

{{ _('Menu & Navig + + @@ -496,6 +498,16 @@

{{ _('Show Me class="rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500"> {{ _('EV Charging') }} + + diff --git a/app/templates/base.html b/app/templates/base.html index 63dbf89..6b3fc9c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -288,7 +288,9 @@ (current_user.show_menu_recurring is not defined or current_user.show_menu_recurring) or (current_user.show_menu_documents is not defined or current_user.show_menu_documents) or (current_user.show_menu_stations is not defined or current_user.show_menu_stations) or - (current_user.show_menu_charging is not defined or current_user.show_menu_charging) %} + (current_user.show_menu_charging is not defined or current_user.show_menu_charging) or + (current_user.show_menu_notes is not defined or current_user.show_menu_notes) or + (current_user.show_menu_allowance is not defined or current_user.show_menu_allowance) %} {% if show_more_menu %}
@@ -386,6 +394,12 @@ {% if current_user.show_menu_stations is not defined or current_user.show_menu_stations %} {{ _('Fuel Stations') }} {% endif %} + {% if current_user.show_menu_allowance is not defined or current_user.show_menu_allowance %} + {{ _('Mileage Allowance') }} + {% endif %} + {% if current_user.show_menu_notes is not defined or current_user.show_menu_notes %} + {{ _('Notes') }} + {% endif %} {{ _('Quick Fuel Entry') }} {{ _('Settings') }} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 9c982e7..1b93256 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -125,6 +125,44 @@

Dashboard

+ + {% if total_allowance and total_allowance > 0 %} +
+
+
+
+ + + +
+
+
+
{{ _('Mileage Allowance') }}
+
-{{ "%.2f"|format(total_allowance) }} {{ current_user.currency }}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
{{ _('Net Cost') }}
+
{{ "%.2f"|format(net_cost) }} {{ current_user.currency }}
+
+
+
+
+
+ {% endif %}
diff --git a/app/templates/fuel/form.html b/app/templates/fuel/form.html index 82228e0..232e16c 100644 --- a/app/templates/fuel/form.html +++ b/app/templates/fuel/form.html @@ -74,6 +74,16 @@

{% if log %}Ed class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">

+
+ + +

{{ _('Optional loyalty discount, subtracted from the price per unit.') }}

+
+
{% if log %}Ed function calculateTotal() { const volume = parseFloat(document.getElementById('volume').value) || 0; const pricePerUnit = parseFloat(document.getElementById('price_per_unit').value) || 0; + const discountPerUnit = parseFloat(document.getElementById('discount_per_unit').value) || 0; if (volume && pricePerUnit) { - document.getElementById('total_cost').value = (volume * pricePerUnit).toFixed(2); + const effectivePrice = Math.max(pricePerUnit - discountPerUnit, 0); + document.getElementById('total_cost').value = (volume * effectivePrice).toFixed(2); } } diff --git a/app/templates/notes/form.html b/app/templates/notes/form.html new file mode 100644 index 0000000..c294add --- /dev/null +++ b/app/templates/notes/form.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}{% if note %}{{ _('Edit Note') }}{% else %}{{ _('Add Note') }}{% endif %}{% endblock %} + +{% block content %} +
+
+ ← {{ _('Back to notes') }} +

{% if note %}{{ _('Edit Note') }}{% else %}{{ _('Add Note') }}{% endif %}

+
+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + {{ _('Cancel') }} + + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/notes/index.html b/app/templates/notes/index.html new file mode 100644 index 0000000..2734a68 --- /dev/null +++ b/app/templates/notes/index.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}{{ _('Notes') }}{% endblock %} + +{% block content %} +
+
+

{{ _('Notes') }}

+

{{ _('Record events and observations that don\'t fit fuel, expenses or maintenance') }}

+
+ + + + + {{ _('Add Note') }} + +
+ +{% if notes %} +
+
+ + + + + + + + + + + + + {% for note in notes %} + + + + + + + + + + + + + {% endfor %} + +
{{ _('Date') }}{{ _('Vehicle') }}{{ _('Note') }}{{ _('Odometer') }}{{ _('Actions') }}
+ + + + {{ note.date|format_date }} + + {{ note.vehicle.name }} + + {{ note.title or (note.content[:60] ~ ('…' if note.content|length > 60 else '')) }} + {% if note.odometer %}{{ note.odometer|int }} {{ note.vehicle.get_effective_odometer_unit() }}{% else %}-{% endif %} + + {{ _('Edit') }} +
+ + +
+
+
+
+{% else %} +
+ + + +

{{ _('No notes') }}

+

{{ _('Jot down anything worth remembering about your vehicles.') }}

+ +
+{% endif %} + + +{% endblock %} diff --git a/app/templates/vehicles/view.html b/app/templates/vehicles/view.html index 356a89e..5c0cacc 100644 --- a/app/templates/vehicles/view.html +++ b/app/templates/vehicles/view.html @@ -89,6 +89,18 @@

{{ vehicle.name }}<
{{ "%.2f"|format(stats.total_expense_cost) }}
{{ current_user.currency }}

+ {% if stats.total_allowance and stats.total_allowance > 0 %} +
+
{{ _('Mileage Allowance') }}
+
-{{ "%.2f"|format(stats.total_allowance) }}
+
{{ current_user.currency }}
+
+
+
{{ _('Net Cost') }}
+
{{ "%.2f"|format(stats.net_cost) }}
+
{{ current_user.currency }}
+
+ {% endif %}
{{ _('Total Distance') }}
{{ "%.0f"|format(stats.total_distance) }}
diff --git a/migrations/versions/c1d2e3f4a5b6_add_discount_per_unit_to_fuel_logs.py b/migrations/versions/c1d2e3f4a5b6_add_discount_per_unit_to_fuel_logs.py new file mode 100644 index 0000000..97ff6c4 --- /dev/null +++ b/migrations/versions/c1d2e3f4a5b6_add_discount_per_unit_to_fuel_logs.py @@ -0,0 +1,33 @@ +"""add discount_per_unit to fuel_logs + +Revision ID: c1d2e3f4a5b6 +Revises: 42b26bf6d488 +Create Date: 2026-06-04 19:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision = 'c1d2e3f4a5b6' +down_revision = '42b26bf6d488' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if 'fuel_logs' not in inspector.get_table_names(): + return + existing_cols = [col['name'] for col in inspector.get_columns('fuel_logs')] + if 'discount_per_unit' in existing_cols: + return + + with op.batch_alter_table('fuel_logs', schema=None) as batch_op: + batch_op.add_column(sa.Column('discount_per_unit', sa.Float(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('fuel_logs', schema=None) as batch_op: + batch_op.drop_column('discount_per_unit') diff --git a/migrations/versions/c2d3e4f5a6b7_add_notes_table.py b/migrations/versions/c2d3e4f5a6b7_add_notes_table.py new file mode 100644 index 0000000..7fa5ca8 --- /dev/null +++ b/migrations/versions/c2d3e4f5a6b7_add_notes_table.py @@ -0,0 +1,48 @@ +"""add notes table and show_menu_notes preference + +Revision ID: c2d3e4f5a6b7 +Revises: c1d2e3f4a5b6 +Create Date: 2026-06-04 19:01:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision = 'c2d3e4f5a6b7' +down_revision = 'c1d2e3f4a5b6' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + + if 'notes' not in inspector.get_table_names(): + op.create_table( + 'notes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('vehicle_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('odometer', sa.Float(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id']), + sa.ForeignKeyConstraint(['user_id'], ['users.id']), + sa.PrimaryKeyConstraint('id'), + ) + + if 'users' in inspector.get_table_names(): + user_cols = [col['name'] for col in inspector.get_columns('users')] + if 'show_menu_notes' not in user_cols: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('show_menu_notes', sa.Boolean(), nullable=True, server_default=sa.true())) + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('show_menu_notes') + op.drop_table('notes') diff --git a/migrations/versions/c3d4e5f6a7b8_add_mileage_allowances_table.py b/migrations/versions/c3d4e5f6a7b8_add_mileage_allowances_table.py new file mode 100644 index 0000000..a62c3ba --- /dev/null +++ b/migrations/versions/c3d4e5f6a7b8_add_mileage_allowances_table.py @@ -0,0 +1,49 @@ +"""add mileage_allowances table and show_menu_allowance preference + +Revision ID: c3d4e5f6a7b8 +Revises: c2d3e4f5a6b7 +Create Date: 2026-06-04 19:02:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision = 'c3d4e5f6a7b8' +down_revision = 'c2d3e4f5a6b7' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + + if 'mileage_allowances' not in inspector.get_table_names(): + op.create_table( + 'mileage_allowances', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('vehicle_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('description', sa.String(length=200), nullable=True), + sa.Column('distance', sa.Float(), nullable=True), + sa.Column('rate_per_unit', sa.Float(), nullable=True), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id']), + sa.ForeignKeyConstraint(['user_id'], ['users.id']), + sa.PrimaryKeyConstraint('id'), + ) + + if 'users' in inspector.get_table_names(): + user_cols = [col['name'] for col in inspector.get_columns('users')] + if 'show_menu_allowance' not in user_cols: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('show_menu_allowance', sa.Boolean(), nullable=True, server_default=sa.true())) + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('show_menu_allowance') + op.drop_table('mileage_allowances') diff --git a/tests/test_allowance.py b/tests/test_allowance.py new file mode 100644 index 0000000..176b1e0 --- /dev/null +++ b/tests/test_allowance.py @@ -0,0 +1,105 @@ +import pytest +from datetime import date +from app import db +from app.models import MileageAllowance + + +@pytest.fixture(scope='function') +def sample_allowance(app, test_user, sample_vehicle): + allowance = MileageAllowance( + vehicle_id=sample_vehicle.id, + user_id=test_user.id, + date=date(2024, 1, 25), + description='January business miles', + distance=200.0, + rate_per_unit=0.45, + amount=90.0, + ) + db.session.add(allowance) + db.session.commit() + return allowance + + +class TestAllowanceIndex: + def test_index_requires_auth(self, client): + resp = client.get('/allowance/', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + def test_index_returns_200(self, auth_client): + resp = auth_client.get('/allowance/') + assert resp.status_code == 200 + + def test_index_shows_allowances(self, auth_client, sample_allowance): + resp = auth_client.get('/allowance/') + assert resp.status_code == 200 + assert b'January business miles' in resp.data + + +class TestAllowanceNew: + def test_new_requires_auth(self, client): + resp = client.get('/allowance/new', follow_redirects=False) + assert resp.status_code == 302 + + def test_get_new_form_returns_200(self, auth_client, sample_vehicle): + resp = auth_client.get('/allowance/new') + assert resp.status_code == 200 + + def test_create_allowance(self, auth_client, sample_vehicle, test_user): + resp = auth_client.post('/allowance/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'date': '2024-02-10', + 'description': 'February miles', + 'distance': '100', + 'rate_per_unit': '0.45', + 'amount': '45.00', + }, follow_redirects=True) + assert resp.status_code == 200 + allowance = MileageAllowance.query.filter_by(description='February miles').first() + assert allowance is not None + assert allowance.amount == 45.0 + assert allowance.user_id == test_user.id + + def test_create_allowance_amount_only(self, auth_client, sample_vehicle): + resp = auth_client.post('/allowance/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'date': '2024-02-12', + 'amount': '120.50', + }, follow_redirects=True) + assert resp.status_code == 200 + allowance = MileageAllowance.query.filter_by(amount=120.5).first() + assert allowance is not None + assert allowance.distance is None + assert allowance.rate_per_unit is None + + +class TestAllowanceEdit: + def test_edit_requires_auth(self, client, sample_allowance): + resp = client.get(f'/allowance/{sample_allowance.id}/edit', follow_redirects=False) + assert resp.status_code == 302 + + def test_edit_allowance(self, auth_client, sample_allowance): + resp = auth_client.post(f'/allowance/{sample_allowance.id}/edit', data={ + 'date': '2024-01-25', + 'description': 'January business miles', + 'amount': '100.00', + }, follow_redirects=True) + assert resp.status_code == 200 + db.session.refresh(sample_allowance) + assert sample_allowance.amount == 100.0 + + +class TestAllowanceDelete: + def test_delete_allowance(self, auth_client, sample_allowance): + allowance_id = sample_allowance.id + resp = auth_client.post(f'/allowance/{allowance_id}/delete', follow_redirects=True) + assert resp.status_code == 200 + assert MileageAllowance.query.get(allowance_id) is None + + +class TestAllowanceOffsetsCost: + def test_total_allowance_and_net_cost(self, app, sample_vehicle, sample_fuel_log, sample_allowance): + # sample_fuel_log total_cost = 60.0; sample_allowance amount = 90.0 + assert sample_vehicle.get_total_allowance() == 90.0 + gross = sample_vehicle.get_total_cost() + assert sample_vehicle.get_net_cost() == gross - 90.0 diff --git a/tests/test_fuel.py b/tests/test_fuel.py index 9664640..be2cf99 100644 --- a/tests/test_fuel.py +++ b/tests/test_fuel.py @@ -48,6 +48,55 @@ def test_create_fuel_log(self, auth_client, sample_vehicle, test_user): assert log.volume == 45.0 assert log.user_id == test_user.id + def test_discount_applied_to_calculated_total(self, auth_client, sample_vehicle): + # No total_cost given: server computes volume * (price - discount) (#209) + resp = auth_client.post('/fuel/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'date': '2024-03-02', + 'odometer': '15100', + 'volume': '50.0', + 'price_per_unit': '1.50', + 'discount_per_unit': '0.10', + 'is_full_tank': 'on', + }, follow_redirects=True) + assert resp.status_code == 200 + log = FuelLog.query.filter_by(vehicle_id=sample_vehicle.id, odometer=15100.0).first() + assert log is not None + assert log.discount_per_unit == 0.10 + # 50 * (1.50 - 0.10) = 70.00 + assert log.total_cost == 70.0 + + def test_explicit_total_overrides_discount_calc(self, auth_client, sample_vehicle): + resp = auth_client.post('/fuel/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'date': '2024-03-03', + 'odometer': '15200', + 'volume': '50.0', + 'price_per_unit': '1.50', + 'discount_per_unit': '0.10', + 'total_cost': '68.0', + 'is_full_tank': 'on', + }, follow_redirects=True) + assert resp.status_code == 200 + log = FuelLog.query.filter_by(vehicle_id=sample_vehicle.id, odometer=15200.0).first() + assert log is not None + assert log.total_cost == 68.0 + + def test_no_discount_is_none(self, auth_client, sample_vehicle): + resp = auth_client.post('/fuel/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'date': '2024-03-04', + 'odometer': '15300', + 'volume': '40.0', + 'price_per_unit': '1.50', + 'is_full_tank': 'on', + }, follow_redirects=True) + assert resp.status_code == 200 + log = FuelLog.query.filter_by(vehicle_id=sample_vehicle.id, odometer=15300.0).first() + assert log is not None + assert log.discount_per_unit is None + assert log.total_cost == 60.0 + def test_new_redirects_to_vehicles_if_none(self, auth_client): # No vehicles exist for this user resp = auth_client.get('/fuel/new', follow_redirects=False) diff --git a/tests/test_notes.py b/tests/test_notes.py new file mode 100644 index 0000000..44e9103 --- /dev/null +++ b/tests/test_notes.py @@ -0,0 +1,114 @@ +import pytest +from datetime import date +from app import db +from app.models import Note + + +@pytest.fixture(scope='function') +def sample_note(app, test_user, sample_vehicle): + note = Note( + vehicle_id=sample_vehicle.id, + user_id=test_user.id, + date=date(2024, 1, 18), + title='DPF regeneration', + content='Active regen completed on the motorway.', + odometer=12345.0, + ) + db.session.add(note) + db.session.commit() + return note + + +class TestNotesIndex: + def test_index_requires_auth(self, client): + resp = client.get('/notes/', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + def test_index_returns_200(self, auth_client): + resp = auth_client.get('/notes/') + assert resp.status_code == 200 + + def test_index_shows_notes(self, auth_client, sample_note): + resp = auth_client.get('/notes/') + assert resp.status_code == 200 + assert b'DPF regeneration' in resp.data + + +class TestNotesNew: + def test_new_requires_auth(self, client): + resp = client.get('/notes/new', follow_redirects=False) + assert resp.status_code == 302 + + def test_get_new_form_returns_200(self, auth_client, sample_vehicle): + resp = auth_client.get('/notes/new') + assert resp.status_code == 200 + + def test_create_note(self, auth_client, sample_vehicle, test_user): + resp = auth_client.post('/notes/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'date': '2024-02-10', + 'title': 'Tyre pressure check', + 'content': 'All four at 32 psi.', + 'odometer': '13000', + }, follow_redirects=True) + assert resp.status_code == 200 + note = Note.query.filter_by(vehicle_id=sample_vehicle.id, title='Tyre pressure check').first() + assert note is not None + assert note.content == 'All four at 32 psi.' + assert note.odometer == 13000.0 + assert note.user_id == test_user.id + + def test_create_note_without_content_rejected(self, auth_client, sample_vehicle): + resp = auth_client.post('/notes/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'date': '2024-02-10', + 'content': '', + }, follow_redirects=True) + assert resp.status_code == 200 + assert Note.query.filter_by(vehicle_id=sample_vehicle.id).count() == 0 + + def test_create_note_optional_odometer(self, auth_client, sample_vehicle): + resp = auth_client.post('/notes/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'date': '2024-02-11', + 'content': 'No odometer recorded.', + }, follow_redirects=True) + assert resp.status_code == 200 + note = Note.query.filter_by(content='No odometer recorded.').first() + assert note is not None + assert note.odometer is None + + +class TestNotesEdit: + def test_edit_requires_auth(self, client, sample_note): + resp = client.get(f'/notes/{sample_note.id}/edit', follow_redirects=False) + assert resp.status_code == 302 + + def test_get_edit_form_returns_200(self, auth_client, sample_note): + resp = auth_client.get(f'/notes/{sample_note.id}/edit') + assert resp.status_code == 200 + + def test_edit_note(self, auth_client, sample_note): + resp = auth_client.post(f'/notes/{sample_note.id}/edit', data={ + 'date': '2024-01-18', + 'title': 'DPF regeneration', + 'content': 'Updated: regen completed twice.', + 'odometer': '12400', + }, follow_redirects=True) + assert resp.status_code == 200 + db.session.refresh(sample_note) + assert sample_note.content == 'Updated: regen completed twice.' + assert sample_note.odometer == 12400.0 + + +class TestNotesDelete: + def test_delete_requires_auth(self, client, sample_note): + resp = client.post(f'/notes/{sample_note.id}/delete', follow_redirects=False) + assert resp.status_code == 302 + + def test_delete_note(self, auth_client, sample_note): + note_id = sample_note.id + resp = auth_client.post(f'/notes/{note_id}/delete', follow_redirects=True) + assert resp.status_code == 200 + assert Note.query.get(note_id) is None From 3b4b1a8da2ce09b0619023b86fdbf1ac67f1b789 Mon Sep 17 00:00:00 2001 From: Danny McClelland Date: Thu, 4 Jun 2026 22:40:22 +0100 Subject: [PATCH 5/5] chore: bump version to 0.23.0 --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 62d87ba..ed551cb 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,7 @@ basedir = Path(__file__).parent.absolute() -APP_VERSION = '0.22.6' +APP_VERSION = '0.23.0' RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable') GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA GITHUB_REPO = 'dannymcc/may'