diff --git a/app/models.py b/app/models.py index bcf3d3f..b69ea9a 100644 --- a/app/models.py +++ b/app/models.py @@ -1095,6 +1095,38 @@ def to_dict(self): } +class TripTemplate(db.Model): + """Reusable trip templates for common routes""" + __tablename__ = 'trip_templates' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=True) + + name = db.Column(db.String(100), nullable=False) + purpose = db.Column(db.String(20), nullable=False) + start_location = db.Column(db.String(200)) + end_location = db.Column(db.String(200)) + description = db.Column(db.String(200)) + notes = db.Column(db.Text) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref=db.backref('trip_templates', lazy='dynamic')) + + def to_dict(self): + return { + 'id': self.id, + 'vehicle_id': self.vehicle_id, + 'name': self.name, + 'purpose': self.purpose, + 'start_location': self.start_location, + 'end_location': self.end_location, + 'description': self.description, + 'notes': self.notes, + } + + class ChargingSession(db.Model): """EV charging session logging""" __tablename__ = 'charging_sessions' diff --git a/app/routes/trips.py b/app/routes/trips.py index 27533bd..af1987d 100644 --- a/app/routes/trips.py +++ b/app/routes/trips.py @@ -3,7 +3,8 @@ from flask_login import login_required, current_user from flask_babel import gettext as _ from app import db -from app.models import Vehicle, Trip, TRIP_PURPOSES +from flask import jsonify +from app.models import Vehicle, Trip, TripTemplate, TRIP_PURPOSES bp = Blueprint('trips', __name__, url_prefix='/trips') @@ -98,6 +99,13 @@ def new(): # Pre-select vehicle if provided selected_vehicle_id = request.args.get('vehicle_id', type=int) or current_user.default_vehicle_id + # Pre-fill from template if requested + preload_template_id = request.args.get('template_id', type=int) + if preload_template_id: + tmpl = TripTemplate.query.get(preload_template_id) + if tmpl and tmpl.user_id == current_user.id and tmpl.vehicle_id: + selected_vehicle_id = tmpl.vehicle_id + # Get last odometer for selected vehicle last_odometer = 0 if selected_vehicle_id: @@ -107,12 +115,16 @@ def new(): elif len(vehicles) == 1: last_odometer = vehicles[0].get_last_odometer() + templates = TripTemplate.query.filter_by(user_id=current_user.id).order_by(TripTemplate.name).all() + return render_template('trips/form.html', trip=None, vehicles=vehicles, purposes=TRIP_PURPOSES, selected_vehicle_id=selected_vehicle_id, - last_odometer=last_odometer) + last_odometer=last_odometer, + templates=templates, + preload_template_id=preload_template_id) @bp.route('//edit', methods=['GET', 'POST']) @@ -146,7 +158,8 @@ def edit(trip_id): vehicles=vehicles, purposes=TRIP_PURPOSES, selected_vehicle_id=trip.vehicle_id, - last_odometer=trip.vehicle.get_last_odometer()) + last_odometer=trip.vehicle.get_last_odometer(), + templates=[]) @bp.route('//delete', methods=['POST']) @@ -166,6 +179,121 @@ def delete(trip_id): return redirect(url_for('trips.index')) +@bp.route('/templates') +@login_required +def templates_index(): + """List trip templates""" + templates = TripTemplate.query.filter_by(user_id=current_user.id).order_by(TripTemplate.name).all() + vehicles = current_user.get_all_vehicles() + return render_template('trips/templates_index.html', + templates=templates, + vehicles=vehicles, + purposes=TRIP_PURPOSES) + + +@bp.route('/templates/new', methods=['GET', 'POST']) +@login_required +def templates_new(): + """Create a trip template""" + vehicles = current_user.get_all_vehicles() + + if request.method == 'POST': + vehicle_id = request.form.get('vehicle_id') + if vehicle_id: + vehicle_id = int(vehicle_id) + vehicle = Vehicle.query.get_or_404(vehicle_id) + if vehicle not in vehicles: + flash(_('Access denied'), 'error') + return redirect(url_for('trips.templates_index')) + else: + vehicle_id = None + + template = TripTemplate( + user_id=current_user.id, + vehicle_id=vehicle_id, + name=request.form.get('name'), + purpose=request.form.get('purpose'), + start_location=request.form.get('start_location'), + end_location=request.form.get('end_location'), + description=request.form.get('description'), + notes=request.form.get('notes'), + ) + db.session.add(template) + db.session.commit() + flash(_('Template saved'), 'success') + return redirect(url_for('trips.templates_index')) + + return render_template('trips/template_form.html', + template=None, + vehicles=vehicles, + purposes=TRIP_PURPOSES) + + +@bp.route('/templates//edit', methods=['GET', 'POST']) +@login_required +def templates_edit(template_id): + """Edit a trip template""" + template = TripTemplate.query.get_or_404(template_id) + if template.user_id != current_user.id: + flash(_('Access denied'), 'error') + return redirect(url_for('trips.templates_index')) + + vehicles = current_user.get_all_vehicles() + + if request.method == 'POST': + vehicle_id = request.form.get('vehicle_id') + if vehicle_id: + vehicle_id = int(vehicle_id) + vehicle = Vehicle.query.get_or_404(vehicle_id) + if vehicle not in vehicles: + flash(_('Access denied'), 'error') + return redirect(url_for('trips.templates_index')) + else: + vehicle_id = None + + template.vehicle_id = vehicle_id + template.name = request.form.get('name') + template.purpose = request.form.get('purpose') + template.start_location = request.form.get('start_location') + template.end_location = request.form.get('end_location') + template.description = request.form.get('description') + template.notes = request.form.get('notes') + + db.session.commit() + flash(_('Template updated'), 'success') + return redirect(url_for('trips.templates_index')) + + return render_template('trips/template_form.html', + template=template, + vehicles=vehicles, + purposes=TRIP_PURPOSES) + + +@bp.route('/templates//delete', methods=['POST']) +@login_required +def templates_delete(template_id): + """Delete a trip template""" + template = TripTemplate.query.get_or_404(template_id) + if template.user_id != current_user.id: + flash(_('Access denied'), 'error') + return redirect(url_for('trips.templates_index')) + + db.session.delete(template) + db.session.commit() + flash(_('Template deleted'), 'success') + return redirect(url_for('trips.templates_index')) + + +@bp.route('/templates//data') +@login_required +def templates_data(template_id): + """Return template data as JSON for pre-filling the trip form""" + template = TripTemplate.query.get_or_404(template_id) + if template.user_id != current_user.id: + return jsonify({'error': 'Access denied'}), 403 + return jsonify(template.to_dict()) + + @bp.route('/report') @login_required def report(): diff --git a/app/templates/trips/form.html b/app/templates/trips/form.html index 420a30e..579f7e0 100644 --- a/app/templates/trips/form.html +++ b/app/templates/trips/form.html @@ -8,6 +8,22 @@

{% if trip %}{{ _('Edit Trip') }}{% else %}{{ _('Log Trip') }}{% endif %}

+ {% if not trip and templates %} +
+
+ + + {{ _('Manage') }} +
+
+ {% endif %} +
@@ -118,6 +134,25 @@

{% if trip %}{

{% endblock %} diff --git a/app/templates/trips/index.html b/app/templates/trips/index.html index 43151b3..9b97b75 100644 --- a/app/templates/trips/index.html +++ b/app/templates/trips/index.html @@ -8,6 +8,13 @@

{{ _('Trips') }}{{ _('Track your trips for mileage and tax deductions') }}

+ + + + + {{ _('Templates') }} + diff --git a/app/templates/trips/template_form.html b/app/templates/trips/template_form.html new file mode 100644 index 0000000..68b7743 --- /dev/null +++ b/app/templates/trips/template_form.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% block title %}{% if template %}{{ _('Edit Template') }}{% else %}{{ _('New Template') }}{% endif %}{% endblock %} + +{% block content %} +
+
+ ← {{ _('Back to templates') }} +

+ {% if template %}{{ _('Edit Template') }}{% else %}{{ _('New Template') }}{% endif %} +

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

{{ _('Pre-selects this vehicle when using the template') }}

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + {{ _('Cancel') }} + + +
+ +
+{% endblock %} diff --git a/app/templates/trips/templates_index.html b/app/templates/trips/templates_index.html new file mode 100644 index 0000000..cb670aa --- /dev/null +++ b/app/templates/trips/templates_index.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}{{ _('Trip Templates') }}{% endblock %} + +{% block content %} +
+
+ ← {{ _('Back to trips') }} +

{{ _('Trip Templates') }}

+

{{ _('Save common routes to quickly fill in trip details') }}

+
+ + + + + {{ _('New Template') }} + +
+ +{% if templates %} +
+
    + {% for tmpl in templates %} +
  • +
    +
    +
    +

    {{ tmpl.name }}

    + + {{ dict(purposes)[tmpl.purpose] if tmpl.purpose in dict(purposes) else tmpl.purpose|capitalize }} + +
    +

    + {% if tmpl.vehicle %}{{ tmpl.vehicle.name }}{% else %}{{ _('Any vehicle') }}{% endif %} + {% if tmpl.description %}· {{ tmpl.description }}{% endif %} +

    + {% if tmpl.start_location or tmpl.end_location %} +

    + {{ tmpl.start_location or '?' }} → {{ tmpl.end_location or '?' }} +

    + {% endif %} +
    +
    + + {{ _('Use') }} + + + + + + +
    + + +
    +
    +
    +
  • + {% endfor %} +
+
+{% else %} +
+ + + +

{{ _('No templates yet') }}

+

{{ _('Save common routes as templates to log trips faster.') }}

+ +
+{% endif %} +{% endblock %} diff --git a/config.py b/config.py index d234540..77ddd6f 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,7 @@ basedir = Path(__file__).parent.absolute() -APP_VERSION = '0.17.0' +APP_VERSION = '0.18.0' 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/migrations/versions/b2c3d4e5f6a7_add_trip_templates.py b/migrations/versions/b2c3d4e5f6a7_add_trip_templates.py new file mode 100644 index 0000000..93a1e59 --- /dev/null +++ b/migrations/versions/b2c3d4e5f6a7_add_trip_templates.py @@ -0,0 +1,44 @@ +"""add trip_templates table + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-21 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'b2c3d4e5f6a7' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade(): + from sqlalchemy import inspect + bind = op.get_bind() + inspector = inspect(bind) + if 'trip_templates' in inspector.get_table_names(): + return + + op.create_table( + 'trip_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('vehicle_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('purpose', sa.String(20), nullable=False), + sa.Column('start_location', sa.String(200), nullable=True), + sa.Column('end_location', sa.String(200), nullable=True), + sa.Column('description', sa.String(200), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id']), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id']), + sa.PrimaryKeyConstraint('id'), + ) + + +def downgrade(): + op.drop_table('trip_templates') diff --git a/tests/test_trips.py b/tests/test_trips.py index 65c87bb..7dfbe67 100644 --- a/tests/test_trips.py +++ b/tests/test_trips.py @@ -1,6 +1,7 @@ +import json import pytest from app import db -from app.models import Trip +from app.models import Trip, TripTemplate from datetime import date @@ -102,6 +103,205 @@ def test_delete_trip(self, auth_client, sample_trip): assert Trip.query.get(trip_id) is None +@pytest.fixture +def sample_template(app, test_user, sample_vehicle): + tmpl = TripTemplate( + user_id=test_user.id, + vehicle_id=sample_vehicle.id, + name='Office Commute', + purpose='commute', + start_location='Home', + end_location='Office', + description='Daily commute', + notes='Via motorway', + ) + db.session.add(tmpl) + db.session.commit() + return tmpl + + +class TestTripTemplatesIndex: + def test_requires_auth(self, client): + resp = client.get('/trips/templates', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + def test_returns_200(self, auth_client): + resp = auth_client.get('/trips/templates') + assert resp.status_code == 200 + + def test_shows_templates(self, auth_client, sample_template): + resp = auth_client.get('/trips/templates') + assert resp.status_code == 200 + assert b'Office Commute' in resp.data + + def test_empty_state(self, auth_client): + resp = auth_client.get('/trips/templates') + assert b'No templates yet' in resp.data + + +class TestTripTemplatesNew: + def test_requires_auth(self, client): + resp = client.get('/trips/templates/new', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + def test_get_form_returns_200(self, auth_client, sample_vehicle): + resp = auth_client.get('/trips/templates/new') + assert resp.status_code == 200 + + def test_create_template_with_vehicle(self, auth_client, sample_vehicle, test_user): + resp = auth_client.post('/trips/templates/new', data={ + 'name': 'Client Visit', + 'vehicle_id': str(sample_vehicle.id), + 'purpose': 'business', + 'start_location': 'Office', + 'end_location': 'Client HQ', + 'description': 'Weekly client visit', + 'notes': 'Bring laptop', + }, follow_redirects=True) + assert resp.status_code == 200 + tmpl = TripTemplate.query.filter_by(name='Client Visit').first() + assert tmpl is not None + assert tmpl.user_id == test_user.id + assert tmpl.vehicle_id == sample_vehicle.id + assert tmpl.purpose == 'business' + assert tmpl.start_location == 'Office' + assert tmpl.end_location == 'Client HQ' + + def test_create_template_without_vehicle(self, auth_client, test_user): + resp = auth_client.post('/trips/templates/new', data={ + 'name': 'Any Vehicle Trip', + 'vehicle_id': '', + 'purpose': 'personal', + }, follow_redirects=True) + assert resp.status_code == 200 + tmpl = TripTemplate.query.filter_by(name='Any Vehicle Trip').first() + assert tmpl is not None + assert tmpl.vehicle_id is None + + def test_create_redirects_to_index(self, auth_client, sample_vehicle): + resp = auth_client.post('/trips/templates/new', data={ + 'name': 'Test Template', + 'vehicle_id': '', + 'purpose': 'personal', + }, follow_redirects=False) + assert resp.status_code == 302 + assert '/trips/templates' in resp.headers['Location'] + + +class TestTripTemplatesEdit: + def test_requires_auth(self, client, sample_template): + resp = client.get(f'/trips/templates/{sample_template.id}/edit', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + def test_get_form_returns_200(self, auth_client, sample_template): + resp = auth_client.get(f'/trips/templates/{sample_template.id}/edit') + assert resp.status_code == 200 + assert b'Office Commute' in resp.data + + def test_edit_template(self, auth_client, sample_template): + resp = auth_client.post(f'/trips/templates/{sample_template.id}/edit', data={ + 'name': 'Updated Commute', + 'vehicle_id': str(sample_template.vehicle_id), + 'purpose': 'personal', + 'start_location': 'New Home', + 'end_location': 'Office', + }, follow_redirects=True) + assert resp.status_code == 200 + db.session.refresh(sample_template) + assert sample_template.name == 'Updated Commute' + assert sample_template.purpose == 'personal' + assert sample_template.start_location == 'New Home' + + def test_cannot_edit_other_users_template(self, client, app, sample_template): + from app.models import User + other = User(username='other2', email='other2@example.com') + other.set_password('Pass123!') + db.session.add(other) + db.session.commit() + client.post('/auth/login', data={'username': 'other2', 'password': 'Pass123!'}, follow_redirects=True) + resp = client.post(f'/trips/templates/{sample_template.id}/edit', data={ + 'name': 'Hijacked', 'vehicle_id': '', 'purpose': 'personal', + }, follow_redirects=True) + db.session.refresh(sample_template) + assert sample_template.name == 'Office Commute' + + +class TestTripTemplatesDelete: + def test_requires_auth(self, client, sample_template): + resp = client.post(f'/trips/templates/{sample_template.id}/delete', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + def test_delete_template(self, auth_client, sample_template): + tmpl_id = sample_template.id + resp = auth_client.post(f'/trips/templates/{tmpl_id}/delete', follow_redirects=True) + assert resp.status_code == 200 + assert TripTemplate.query.get(tmpl_id) is None + + def test_cannot_delete_other_users_template(self, client, app, sample_template): + from app.models import User + other = User(username='other3', email='other3@example.com') + other.set_password('Pass123!') + db.session.add(other) + db.session.commit() + client.post('/auth/login', data={'username': 'other3', 'password': 'Pass123!'}, follow_redirects=True) + resp = client.post(f'/trips/templates/{sample_template.id}/delete', follow_redirects=True) + assert TripTemplate.query.get(sample_template.id) is not None + + +class TestTripTemplatesData: + def test_requires_auth(self, client, sample_template): + resp = client.get(f'/trips/templates/{sample_template.id}/data', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + def test_returns_json(self, auth_client, sample_template): + resp = auth_client.get(f'/trips/templates/{sample_template.id}/data') + assert resp.status_code == 200 + assert resp.content_type == 'application/json' + + def test_json_contains_template_fields(self, auth_client, sample_template): + resp = auth_client.get(f'/trips/templates/{sample_template.id}/data') + data = json.loads(resp.data) + assert data['id'] == sample_template.id + assert data['vehicle_id'] == sample_template.vehicle_id + assert data['purpose'] == 'commute' + assert data['start_location'] == 'Home' + assert data['end_location'] == 'Office' + assert data['description'] == 'Daily commute' + assert data['notes'] == 'Via motorway' + + def test_cannot_access_other_users_template_data(self, client, app, sample_template): + from app.models import User + other = User(username='other4', email='other4@example.com') + other.set_password('Pass123!') + db.session.add(other) + db.session.commit() + client.post('/auth/login', data={'username': 'other4', 'password': 'Pass123!'}, follow_redirects=True) + resp = client.get(f'/trips/templates/{sample_template.id}/data') + assert resp.status_code == 403 + + +class TestTripNewWithTemplate: + def test_new_form_shows_template_selector(self, auth_client, sample_vehicle, sample_template): + resp = auth_client.get('/trips/new') + assert resp.status_code == 200 + assert b'Load template' in resp.data + assert b'Office Commute' in resp.data + + def test_new_form_no_template_selector_without_templates(self, auth_client, sample_vehicle): + resp = auth_client.get('/trips/new') + assert resp.status_code == 200 + assert b'Load template' not in resp.data + + def test_template_id_param_accepted(self, auth_client, sample_vehicle, sample_template): + resp = auth_client.get(f'/trips/new?template_id={sample_template.id}') + assert resp.status_code == 200 + + class TestTripReport: def test_report_requires_auth(self, client): resp = client.get('/trips/report', follow_redirects=False)