diff --git a/.env.example b/.env.example index 9b7c289..d377637 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,17 @@ # May Configuration -# Copy this file to .env and update the values +# Copy this file to .env and update the values. +# Keep .env next to config.py; it is read when May starts. +# Variables set in the real environment take precedence over this file. # Secret key for session encryption (generate a random string for production) SECRET_KEY=your-secret-key-here -# Database URL (default: SQLite in data folder) -# DATABASE_URL=sqlite:///data/may.db +# Database URL (default: SQLite in the app's own data folder) +# Uncomment and set an absolute path to move the database elsewhere. +# Note the slashes: sqlite:///path is relative, sqlite:////path is absolute. +# DATABASE_URL=sqlite:////absolute/path/to/may/data/may.db +# PostgreSQL is also supported: +# DATABASE_URL=postgresql://user:password@host:5432/may -# Upload folder for attachments -# UPLOAD_FOLDER=/app/data/uploads +# Upload folder for attachments (default: the app's own data/uploads folder) +# UPLOAD_FOLDER=/absolute/path/to/may/data/uploads diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 656c2e5..2560ed1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.12' cache: 'pip' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..909d0d6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +This file starts at 0.28.0. Notes for earlier releases are on the +[GitHub releases page](https://github.com/dannymcc/may/releases). + +## [0.28.0] - 2026-08-23 + +### Added + +- Vehicle PDF reports can now include receipt images. The vehicle page has a + "PDF + Receipts" button alongside the existing "PDF" one; it appends the + images attached to the fuel logs and expenses in the report. Anything that + cannot be inlined — a PDF scan, a missing file, or one that would push the + report past the 20 MB image budget — is listed at the end of the report + rather than dropped silently. + ([#219](https://github.com/dannymcc/may/issues/219)) +- Expenses accept more than one receipt. Select several files when adding or + editing an expense, and the expandable row in the expense list links to each + one. Files rejected for an unsupported extension are now reported rather than + dropped silently. ([#234](https://github.com/dannymcc/may/issues/234)) +- API v1 endpoints for trips and charging sessions: list, create, read, update + and delete under `/api/v1/vehicles/{id}/trips`, `/api/v1/trips/{id}`, + `/api/v1/vehicles/{id}/charging` and `/api/v1/charging/{id}`, plus + `/api/v1/trip-purposes` and `/api/v1/charger-types`. Documented at `/api/docs`. + ([#295](https://github.com/dannymcc/may/issues/295)) +- Dashboard charts label their value axis with your currency, and tooltips show + it too. ([#289](https://github.com/dannymcc/may/issues/289)) +- Initial Hungarian translation files, contributed by + [@burgatshow](https://github.com/burgatshow). Hungarian is not yet offered in + the language picker while the remaining strings are filled in. + ([#290](https://github.com/dannymcc/may/pull/290)) + +### Fixed + +- `.env` settings were silently ignored. `config.py` now loads the `.env` file + sitting next to it before reading the environment. Real environment variables + still take precedence, so Docker deployments are unaffected. + ([#297](https://github.com/dannymcc/may/issues/297)) +- Deleting an entry from the fuel log bounced you to the vehicle page; it now + leaves you where you were. ([#298](https://github.com/dannymcc/may/issues/298)) + +### Changed + +- The expense list loads attachments in a single query rather than one per row. +- README and `.env.example` corrected: the real defaults for `DATABASE_URL` and + `UPLOAD_FOLDER` are inside the application directory, the `sqlite:///` versus + `sqlite:////` distinction is spelled out, and there is a note that `.env` does + not drive those two keys under Docker Compose. +- The supported languages table in the README now lists Arabic, Czech, Russian + and Turkish, which were already available in the app. +- Dependencies: `psycopg2-binary` >= 2.9.12 + ([#280](https://github.com/dannymcc/may/pull/280)), `coverage` >= 7.15.4 + ([#288](https://github.com/dannymcc/may/pull/288)), and `actions/setup-python` + bumped from 6 to 7 in CI ([#263](https://github.com/dannymcc/may/pull/263)). diff --git a/CLAUDE.md b/CLAUDE.md index 2a09dfe..8e091b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Migrations run automatically on container startup via the entrypoint script. ### GitHub Actions Workflow -The project uses GitHub Actions (`.github/workflows/docker-build.yml`) to automatically build and push Docker images: +The project uses GitHub Actions (`.github/workflows/docker.yml`) to automatically build and push Docker images: 1. On push to `main`, `dev`, or new tags, the workflow triggers 2. Builds a multi-platform Docker image (linux/amd64, linux/arm64) @@ -117,7 +117,7 @@ The project uses GitHub Actions (`.github/workflows/docker-build.yml`) to automa ### Creating a Release -1. Update `APP_VERSION` in `config.py` +1. Update `APP_VERSION` in `config.py` and add the release section to `CHANGELOG.md` 2. Commit the version bump to `dev` 3. Create a pull request from `dev` to `main` with comprehensive changelog 4. Merge the PR to `main` diff --git a/README.md b/README.md index 6fe7755..a1d696b 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Named after James May, completing the trio of Top Gear presenters (alongside [Cl - **👥 Multi-User**: Share vehicles between family members or team members - **📊 Analytics Dashboard**: View spending trends and consumption statistics with interactive charts - **📎 Attachment Support**: Upload receipts and documents to fuel logs and expenses -- **📄 PDF Reports**: Generate comprehensive vehicle reports for record-keeping +- **📄 PDF Reports**: Generate comprehensive vehicle reports for record-keeping, optionally with receipt images attached - **🔧 Customizable Units**: Support for metric/imperial, multiple currencies - **🎛️ Menu Customization**: Show/hide menu items and set your preferred start page - **🌍 Internationalization**: Available in multiple languages (English, German, Spanish, French, and more) @@ -128,22 +128,32 @@ Copy `.env.example` to `.env` and configure: # Secret key for session encryption SECRET_KEY=your-secure-random-string -# Database location (default: SQLite) -DATABASE_URL=sqlite:///data/may.db +# Database location (optional, defaults to SQLite in the app's data folder) +# Note the slashes: sqlite:///path is relative, sqlite:////path is absolute. +DATABASE_URL=sqlite:////srv/may/data/may.db # PostgreSQL is also supported: # DATABASE_URL=postgresql://user:password@host:5432/may -# Upload folder for attachments -UPLOAD_FOLDER=/app/data/uploads +# Upload folder for attachments (optional) +UPLOAD_FOLDER=/srv/may/data/uploads ``` +The `.env` file must sit next to `config.py` in the application directory, and +it is read when May starts. Variables set in the real environment take +precedence over `.env`. + +Under Docker Compose, `.env` is only used for `${VAR}` substitution in +`docker-compose.yml` (for example `SECRET_KEY`). `DATABASE_URL` and +`UPLOAD_FOLDER` are set in the compose `environment:` block, so changing them +in `.env` has no effect — edit `docker-compose.yml` instead. + ### Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `SECRET_KEY` | Session encryption key | Random | -| `DATABASE_URL` | Database connection string (SQLite or PostgreSQL) | `sqlite:///data/may.db` | -| `UPLOAD_FOLDER` | Path for file uploads | `/app/data/uploads` | +| `DATABASE_URL` | Database connection string (SQLite or PostgreSQL) | SQLite at `data/may.db` inside the application directory (`/app/data/may.db` in Docker) | +| `UPLOAD_FOLDER` | Path for file uploads | `data/uploads` inside the application directory (`/app/data/uploads` in Docker) | | `PUID` | User ID the container runs as (linuxserver.io convention) | `1000` | | `PGID` | Group ID the container runs as (linuxserver.io convention) | `1000` | | `TAILWIND_ASSET_URL` | Local Tailwind Play CDN JS path | `/static/vendor/tailwindcss.js` | @@ -170,6 +180,7 @@ Add and manage your vehicles with detailed information: - **Vehicle Sharing**: Mark a vehicle as "Shared" to make it visible and loggable by all users on the instance - **Upcoming Maintenance**: Vehicle detail pages show a live panel of scheduled maintenance tasks, with overdue and due-soon alerts - **Parts & Consumables**: Collapsible section on the vehicle page remembers your expand/collapse preference per vehicle +- **PDF Report**: The "PDF" button downloads a summary of the vehicle, its fuel logs and its expenses. "PDF + Receipts" does the same and appends the receipt images attached to those entries, which is the version to hand to an accountant or employer. Non-image attachments (PDF scans, for example) are listed at the end of the report rather than embedded. ### Fuel Logs Track every fill-up with: @@ -188,7 +199,7 @@ Categorize all vehicle-related costs: - Accessories - Other expenses -Record odometer readings alongside costs, and expand any expense row to see vendor and notes details inline. +Record odometer readings alongside costs, and expand any expense row to see vendor, notes, and links to any attached receipts inline. An expense can have several receipts — select more than one file when adding or editing it. ### Reminders Never miss important dates: @@ -253,7 +264,8 @@ curl -H "Authorization: Bearer may_your_api_key" \ http://localhost:5050/api/v1/vehicles ``` -See the API documentation at `/api/docs` when logged in. +Vehicles, fuel logs, expenses, trips, and charging sessions can all be read and +created through the API. See the API documentation at `/api/docs` when logged in. ## 🔗 Integrations @@ -294,14 +306,16 @@ May is available in the following languages: | Language | Code | Language | Code | |----------|------|----------|------| -| English | `en` | Swedish | `sv` | +| English | `en` | Swedish (Svenska) | `sv` | | German (Deutsch) | `de` | Danish (Dansk) | `da` | | Spanish (Español) | `es` | Norwegian (Norsk) | `no` | | French (Français) | `fr` | Finnish (Suomi) | `fi` | | Italian (Italiano) | `it` | Japanese (日本語) | `ja` | | Dutch (Nederlands) | `nl` | Chinese (中文) | `zh` | | Portuguese (Português) | `pt` | Korean (한국어) | `ko` | -| Polish (Polski) | `pl` | | | +| Polish (Polski) | `pl` | Czech (Čeština) | `cs` | +| Russian (Русский) | `ru` | Turkish (Türkçe) | `tr` | +| Arabic (العربية) | `ar` | | | You can change your language in **Settings > Units & Values > Language**. diff --git a/app/routes/api.py b/app/routes/api.py index 1a6e00e..afddc30 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -812,6 +812,421 @@ def api_delete_expense(expense_id): return jsonify({'success': True, 'message': 'Expense deleted'}) +# ============================================================================= +# Public API v1 - Trips +# ============================================================================= + +def _parse_api_time(value): + """Parse a time string from an API request. + + Accepts HH:MM (as the web forms submit) and HH:MM:SS (as ``to_dict`` + returns), so values can be round-tripped. Raises ValueError otherwise. + """ + if not isinstance(value, str): + raise ValueError(f'Invalid time format: {value}') + for fmt in ('%H:%M', '%H:%M:%S'): + try: + return datetime.strptime(value, fmt).time() + except ValueError: + continue + raise ValueError(f'Invalid time format: {value}') + + +@bp.route('/v1/vehicles//trips', methods=['GET']) +@api_auth_required +def api_list_trips(vehicle_id): + """ + List trips for a vehicle + + Query parameters: + - limit: Maximum number of results (default: 100) + - offset: Number of results to skip (default: 0) + - purpose: Filter by purpose + - sort: Sort order, 'asc' or 'desc' by date (default: desc) + """ + user = get_api_user() + vehicle = Vehicle.query.get_or_404(vehicle_id) + + if vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Vehicle not found or access denied', 'code': 'not_found'}), 404 + + limit = min(request.args.get('limit', 100, type=int), 500) + offset = request.args.get('offset', 0, type=int) + purpose = request.args.get('purpose') + sort = request.args.get('sort', 'desc') + + query = vehicle.trips + if purpose: + query = query.filter_by(purpose=purpose) + + if sort == 'asc': + query = query.order_by(Trip.date.asc(), Trip.id.asc()) + else: + query = query.order_by(Trip.date.desc(), Trip.id.desc()) + + total = query.count() + trips = query.offset(offset).limit(limit).all() + + return jsonify({ + 'trips': [trip.to_dict() for trip in trips], + 'count': len(trips), + 'total': total, + 'limit': limit, + 'offset': offset + }) + + +@bp.route('/v1/vehicles//trips', methods=['POST']) +@api_auth_required +def api_create_trip(vehicle_id): + """ + Create a trip + + Required fields: date, start_odometer, purpose + Optional fields: end_odometer, description, start_location, end_location, notes + """ + user = get_api_user() + vehicle = Vehicle.query.get_or_404(vehicle_id) + + if vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Vehicle not found or access denied', 'code': 'not_found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'JSON body required', 'code': 'invalid_request'}), 400 + + required = ['date', 'start_odometer', 'purpose'] + for field in required: + if data.get(field) is None or data.get(field) == '': + return jsonify({'error': f'{field} is required', 'code': 'validation_error'}), 400 + + valid_purposes = [p[0] for p in TRIP_PURPOSES] + if data['purpose'] not in valid_purposes: + return jsonify({ + 'error': f'purpose must be one of: {", ".join(valid_purposes)}', + 'code': 'validation_error' + }), 400 + + try: + date = datetime.strptime(data['date'], '%Y-%m-%d').date() + except (TypeError, ValueError): + return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD', 'code': 'validation_error'}), 400 + + try: + start_odometer = parse_decimal(data['start_odometer']) + end_odometer = parse_decimal(data.get('end_odometer')) + except ValueError: + return jsonify({'error': 'Odometer values must be numeric', 'code': 'validation_error'}), 400 + + trip = Trip( + vehicle_id=vehicle_id, + user_id=user.id, + date=date, + start_odometer=start_odometer, + end_odometer=end_odometer, + purpose=data['purpose'], + description=data.get('description'), + start_location=data.get('start_location'), + end_location=data.get('end_location'), + notes=data.get('notes') + ) + + db.session.add(trip) + db.session.commit() + + return jsonify(trip.to_dict()), 201 + + +@bp.route('/v1/trips/', methods=['GET']) +@api_auth_required +def api_get_trip(trip_id): + """Get a specific trip""" + user = get_api_user() + trip = Trip.query.get_or_404(trip_id) + + if trip.vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Trip not found or access denied', 'code': 'not_found'}), 404 + + return jsonify(trip.to_dict()) + + +@bp.route('/v1/trips/', methods=['PUT', 'PATCH']) +@api_auth_required +def api_update_trip(trip_id): + """Update a trip""" + user = get_api_user() + trip = Trip.query.get_or_404(trip_id) + + if trip.vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Trip not found or access denied', 'code': 'not_found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'JSON body required', 'code': 'invalid_request'}), 400 + + if 'date' in data: + try: + trip.date = datetime.strptime(data['date'], '%Y-%m-%d').date() + except (TypeError, ValueError): + return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD', 'code': 'validation_error'}), 400 + + if 'purpose' in data: + valid_purposes = [p[0] for p in TRIP_PURPOSES] + if data['purpose'] not in valid_purposes: + return jsonify({ + 'error': f'purpose must be one of: {", ".join(valid_purposes)}', + 'code': 'validation_error' + }), 400 + trip.purpose = data['purpose'] + + try: + if 'start_odometer' in data: + start_odometer = parse_decimal(data['start_odometer']) + if start_odometer is None: + return jsonify({'error': 'start_odometer is required', 'code': 'validation_error'}), 400 + trip.start_odometer = start_odometer + if 'end_odometer' in data: + trip.end_odometer = parse_decimal(data['end_odometer']) + except ValueError: + return jsonify({'error': 'Odometer values must be numeric', 'code': 'validation_error'}), 400 + + if 'description' in data: + trip.description = data['description'] + if 'start_location' in data: + trip.start_location = data['start_location'] + if 'end_location' in data: + trip.end_location = data['end_location'] + if 'notes' in data: + trip.notes = data['notes'] + + db.session.commit() + return jsonify(trip.to_dict()) + + +@bp.route('/v1/trips/', methods=['DELETE']) +@api_auth_required +def api_delete_trip(trip_id): + """Delete a trip""" + user = get_api_user() + trip = Trip.query.get_or_404(trip_id) + + if trip.vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Trip not found or access denied', 'code': 'not_found'}), 404 + + db.session.delete(trip) + db.session.commit() + + return jsonify({'success': True, 'message': 'Trip deleted'}) + + +# ============================================================================= +# Public API v1 - Charging Sessions +# ============================================================================= + +@bp.route('/v1/vehicles//charging', methods=['GET']) +@api_auth_required +def api_list_charging_sessions(vehicle_id): + """ + List charging sessions for a vehicle + + Query parameters: + - limit: Maximum number of results (default: 100) + - offset: Number of results to skip (default: 0) + - charger_type: Filter by charger type + - sort: Sort order, 'asc' or 'desc' by date (default: desc) + """ + user = get_api_user() + vehicle = Vehicle.query.get_or_404(vehicle_id) + + if vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Vehicle not found or access denied', 'code': 'not_found'}), 404 + + limit = min(request.args.get('limit', 100, type=int), 500) + offset = request.args.get('offset', 0, type=int) + charger_type = request.args.get('charger_type') + sort = request.args.get('sort', 'desc') + + query = vehicle.charging_sessions + if charger_type: + query = query.filter_by(charger_type=charger_type) + + if sort == 'asc': + query = query.order_by(ChargingSession.date.asc(), ChargingSession.id.asc()) + else: + query = query.order_by(ChargingSession.date.desc(), ChargingSession.id.desc()) + + total = query.count() + sessions = query.offset(offset).limit(limit).all() + + return jsonify({ + 'charging_sessions': [s.to_dict() for s in sessions], + 'count': len(sessions), + 'total': total, + 'limit': limit, + 'offset': offset + }) + + +@bp.route('/v1/vehicles//charging', methods=['POST']) +@api_auth_required +def api_create_charging_session(vehicle_id): + """ + Create a charging session + + Required fields: date + Optional fields: start_time, end_time, odometer, kwh_added, start_soc, end_soc, + cost_per_kwh, total_cost, charger_type, location, network, notes + """ + user = get_api_user() + vehicle = Vehicle.query.get_or_404(vehicle_id) + + if vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Vehicle not found or access denied', 'code': 'not_found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'JSON body required', 'code': 'invalid_request'}), 400 + + if not data.get('date'): + return jsonify({'error': 'date is required (YYYY-MM-DD)', 'code': 'validation_error'}), 400 + + try: + date = datetime.strptime(data['date'], '%Y-%m-%d').date() + except (TypeError, ValueError): + return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD', 'code': 'validation_error'}), 400 + + if data.get('charger_type'): + valid_charger_types = [c[0] for c in CHARGER_TYPES] + if data['charger_type'] not in valid_charger_types: + return jsonify({ + 'error': f'charger_type must be one of: {", ".join(valid_charger_types)}', + 'code': 'validation_error' + }), 400 + + try: + start_time = _parse_api_time(data['start_time']) if data.get('start_time') else None + end_time = _parse_api_time(data['end_time']) if data.get('end_time') else None + except ValueError: + return jsonify({'error': 'Invalid time format. Use HH:MM', 'code': 'validation_error'}), 400 + + try: + session = ChargingSession( + vehicle_id=vehicle_id, + user_id=user.id, + date=date, + start_time=start_time, + end_time=end_time, + odometer=parse_decimal(data.get('odometer')), + kwh_added=parse_decimal(data.get('kwh_added')), + start_soc=int(data['start_soc']) if data.get('start_soc') is not None else None, + end_soc=int(data['end_soc']) if data.get('end_soc') is not None else None, + cost_per_kwh=parse_decimal(data.get('cost_per_kwh')), + total_cost=parse_decimal(data.get('total_cost')), + charger_type=data.get('charger_type'), + location=data.get('location'), + network=data.get('network'), + notes=data.get('notes') + ) + except (TypeError, ValueError): + return jsonify({'error': 'Numeric fields must be valid numbers', 'code': 'validation_error'}), 400 + + # Auto-calculate total cost if not provided + if session.kwh_added and session.cost_per_kwh and not session.total_cost: + session.total_cost = round(session.kwh_added * session.cost_per_kwh, 2) + + db.session.add(session) + db.session.commit() + + return jsonify(session.to_dict()), 201 + + +@bp.route('/v1/charging/', methods=['GET']) +@api_auth_required +def api_get_charging_session(session_id): + """Get a specific charging session""" + user = get_api_user() + charge = ChargingSession.query.get_or_404(session_id) + + if charge.vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Charging session not found or access denied', 'code': 'not_found'}), 404 + + return jsonify(charge.to_dict()) + + +@bp.route('/v1/charging/', methods=['PUT', 'PATCH']) +@api_auth_required +def api_update_charging_session(session_id): + """Update a charging session""" + user = get_api_user() + charge = ChargingSession.query.get_or_404(session_id) + + if charge.vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Charging session not found or access denied', 'code': 'not_found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'JSON body required', 'code': 'invalid_request'}), 400 + + if 'date' in data: + try: + charge.date = datetime.strptime(data['date'], '%Y-%m-%d').date() + except (TypeError, ValueError): + return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD', 'code': 'validation_error'}), 400 + + if 'charger_type' in data: + if data['charger_type']: + valid_charger_types = [c[0] for c in CHARGER_TYPES] + if data['charger_type'] not in valid_charger_types: + return jsonify({ + 'error': f'charger_type must be one of: {", ".join(valid_charger_types)}', + 'code': 'validation_error' + }), 400 + charge.charger_type = data['charger_type'] + + try: + for field in ('start_time', 'end_time'): + if field in data: + setattr(charge, field, _parse_api_time(data[field]) if data[field] else None) + except ValueError: + return jsonify({'error': 'Invalid time format. Use HH:MM', 'code': 'validation_error'}), 400 + + try: + for field in ('odometer', 'kwh_added', 'cost_per_kwh', 'total_cost'): + if field in data: + setattr(charge, field, parse_decimal(data[field])) + for field in ('start_soc', 'end_soc'): + if field in data: + setattr(charge, field, int(data[field]) if data[field] is not None else None) + except (TypeError, ValueError): + return jsonify({'error': 'Numeric fields must be valid numbers', 'code': 'validation_error'}), 400 + + if 'location' in data: + charge.location = data['location'] + if 'network' in data: + charge.network = data['network'] + if 'notes' in data: + charge.notes = data['notes'] + + db.session.commit() + return jsonify(charge.to_dict()) + + +@bp.route('/v1/charging/', methods=['DELETE']) +@api_auth_required +def api_delete_charging_session(session_id): + """Delete a charging session""" + user = get_api_user() + charge = ChargingSession.query.get_or_404(session_id) + + if charge.vehicle not in user.get_all_vehicles(): + return jsonify({'error': 'Charging session not found or access denied', 'code': 'not_found'}), 404 + + db.session.delete(charge) + db.session.commit() + + return jsonify({'success': True, 'message': 'Charging session deleted'}) + + # ============================================================================= # Public API v1 - Metadata # ============================================================================= @@ -825,6 +1240,24 @@ def api_list_categories(): }) +@bp.route('/v1/trip-purposes', methods=['GET']) +@api_auth_required +def api_list_trip_purposes(): + """List all trip purposes""" + return jsonify({ + 'purposes': [{'id': p[0], 'name': p[1]} for p in TRIP_PURPOSES] + }) + + +@bp.route('/v1/charger-types', methods=['GET']) +@api_auth_required +def api_list_charger_types(): + """List all charger types""" + return jsonify({ + 'charger_types': [{'id': c[0], 'name': c[1]} for c in CHARGER_TYPES] + }) + + # ============================================================================= # DVLA Integration (UK Vehicles) # ============================================================================= diff --git a/app/routes/expenses.py b/app/routes/expenses.py index 729355b..26f25f7 100644 --- a/app/routes/expenses.py +++ b/app/routes/expenses.py @@ -27,6 +27,56 @@ def parse_optional_float(value): return parse_decimal(value) +def _save_attachments(expense, files): + """Save uploaded receipts against an expense (#234). + + Accepts any number of files, ignores empty file inputs, and returns the + names of the files skipped because of a disallowed extension so the + caller can tell the user rather than dropping them silently. + """ + skipped = [] + for file in files: + if not file or not file.filename: + continue + if not allowed_file(file.filename): + skipped.append(file.filename) + continue + + filename = f"{uuid.uuid4().hex}_{secure_filename(file.filename)}" + file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) + + db.session.add(Attachment( + filename=filename, + original_filename=file.filename, + file_type=file.filename.rsplit('.', 1)[1].lower(), + expense_id=expense.id + )) + return skipped + + +def _flash_skipped_attachments(skipped): + if skipped: + flash(_('These files were not saved because the file type is not ' + 'supported: %(names)s') % {'names': ', '.join(skipped)}, 'warning') + + +def _attachments_by_expense(expense_ids): + """Attachments for the listed expenses, keyed by expense id. + + Expense.attachments is lazy='dynamic', so loading them in the template + would run one query per row. + """ + grouped = {} + if not expense_ids: + return grouped + attachments = Attachment.query.filter( + Attachment.expense_id.in_(expense_ids) + ).order_by(Attachment.id).all() + for attachment in attachments: + grouped.setdefault(attachment.expense_id, []).append(attachment) + return grouped + + def _known_vendors(vehicle_ids): """Distinct vendors previously used on the user's expenses (#213).""" if not vehicle_ids: @@ -70,7 +120,8 @@ def index(): ).group_by(Expense.vendor).order_by(func.sum(Expense.cost).desc()).all() return render_template('expenses/index.html', expenses=expenses, vehicles=vehicles, - vendor_totals=vendor_rows) + vendor_totals=vendor_rows, + expense_attachments=_attachments_by_expense([e.id for e in expenses])) @bp.route('/new', methods=['GET', 'POST']) @@ -123,21 +174,10 @@ def new(): db.session.commit() - # Handle attachment upload - if 'attachment' in request.files: - file = request.files['attachment'] - if file and file.filename and allowed_file(file.filename): - filename = f"{uuid.uuid4().hex}_{secure_filename(file.filename)}" - file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) - - attachment = Attachment( - filename=filename, - original_filename=file.filename, - file_type=file.filename.rsplit('.', 1)[1].lower(), - expense_id=expense.id - ) - db.session.add(attachment) - db.session.commit() + # Handle attachment uploads (one or more) + skipped = _save_attachments(expense, request.files.getlist('attachment')) + db.session.commit() + _flash_skipped_attachments(skipped) flash(_('Expense added successfully'), 'success') return redirect(url_for('vehicles.view', vehicle_id=vehicle_id)) @@ -184,22 +224,11 @@ def edit(expense_id): categories=EXPENSE_CATEGORIES, selected_vehicle_id=expense.vehicle_id) - # Handle attachment upload - if 'attachment' in request.files: - file = request.files['attachment'] - if file and file.filename and allowed_file(file.filename): - filename = f"{uuid.uuid4().hex}_{secure_filename(file.filename)}" - file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) - - attachment = Attachment( - filename=filename, - original_filename=file.filename, - file_type=file.filename.rsplit('.', 1)[1].lower(), - expense_id=expense.id - ) - db.session.add(attachment) + # Handle attachment uploads (one or more) + skipped = _save_attachments(expense, request.files.getlist('attachment')) db.session.commit() + _flash_skipped_attachments(skipped) flash(_('Expense updated successfully'), 'success') return redirect(url_for('vehicles.view', vehicle_id=expense.vehicle_id)) diff --git a/app/routes/fuel.py b/app/routes/fuel.py index 71a5654..88b44a7 100644 --- a/app/routes/fuel.py +++ b/app/routes/fuel.py @@ -7,7 +7,10 @@ from app import db from app.utils import parse_decimal from app.models import Vehicle, FuelLog, Attachment, FuelStation, FuelPriceHistory, FUEL_TYPES -from app.security import validate_file_upload, secure_filename_with_uuid, validate_positive_number +from app.security import ( + validate_file_upload, secure_filename_with_uuid, validate_positive_number, + get_safe_redirect_url +) from flask_babel import gettext as _ from app.services.tessie import TessieService @@ -337,7 +340,10 @@ def delete(log_id): db.session.delete(log) db.session.commit() flash(_('Fuel log deleted successfully'), 'success') - return redirect(url_for('vehicles.view', vehicle_id=vehicle_id)) + # Return to wherever the delete came from (#298): deleting from the fuel + # log should stay there rather than bouncing to the vehicle page. + next_url = get_safe_redirect_url(request.form.get('next'), default=None) + return redirect(next_url or url_for('vehicles.view', vehicle_id=vehicle_id)) @bp.route('//attachments//delete', methods=['POST']) diff --git a/app/routes/vehicles.py b/app/routes/vehicles.py index d911998..6c959a6 100644 --- a/app/routes/vehicles.py +++ b/app/routes/vehicles.py @@ -1,5 +1,6 @@ import os import uuid +from base64 import b64encode from io import BytesIO from datetime import datetime from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, Response @@ -8,13 +9,20 @@ from werkzeug.utils import secure_filename from app import db from app.utils import parse_decimal -from app.models import Vehicle, VehicleSpec, VehiclePart, FuelLog, Expense, User, Reminder, MaintenanceSchedule, VEHICLE_TYPES, FUEL_TYPES, VEHICLE_SPEC_TYPES, REMINDER_TYPES, PART_TYPES, TRACKING_UNITS, ODOMETER_UNITS, TRIP_PURPOSES, AppSettings +from app.models import Vehicle, VehicleSpec, VehiclePart, FuelLog, Expense, User, Reminder, MaintenanceSchedule, Attachment, VEHICLE_TYPES, FUEL_TYPES, VEHICLE_SPEC_TYPES, REMINDER_TYPES, PART_TYPES, TRACKING_UNITS, ODOMETER_UNITS, TRIP_PURPOSES, AppSettings from app.services.tessie import TessieService bp = Blueprint('vehicles', __name__, url_prefix='/vehicles') ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +# Attachment types that can be inlined into the PDF report as pictures. +RECEIPT_IMAGE_TYPES = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + +# Receipts are base64-encoded into the document, so cap the total to keep +# both the PDF and the memory used to build it within reason. +MAX_RECEIPT_BYTES = 20 * 1024 * 1024 + def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @@ -377,6 +385,80 @@ def unarchive(vehicle_id): return redirect(url_for('vehicles.index')) +def collect_receipts(fuel_logs, expenses, upload_folder): + """Collect receipt attachments for the PDF report (#219). + + Returns a (receipts, omitted) pair. Receipts are image attachments read + off disk and inlined as data URIs: WeasyPrint fetches remote URLs without + the user's session, so a link to the uploads route would land it on the + login page instead of the picture. Anything we cannot inline — a PDF scan, + a missing file, or one big enough to bloat the document — is listed in + omitted so the report can say so rather than silently dropping it. + """ + receipts = [] + omitted = [] + budget_left = MAX_RECEIPT_BYTES + + def add(record, kind, title, subtitle, cost): + nonlocal budget_left + for attachment in record.attachments.order_by(Attachment.id).all(): + extension = (attachment.file_type or '').lower().lstrip('.') + if not extension and '.' in attachment.filename: + extension = attachment.filename.rsplit('.', 1)[1].lower() + + entry = { + 'kind': kind, + 'date': record.date, + 'title': title, + 'subtitle': subtitle, + 'cost': cost, + 'filename': attachment.original_filename or attachment.filename, + } + + if extension not in RECEIPT_IMAGE_TYPES: + omitted.append(dict(entry, reason='not an image')) + continue + + path = os.path.join(upload_folder, attachment.filename) + try: + size = os.path.getsize(path) + except OSError: + omitted.append(dict(entry, reason='file missing')) + continue + + if size > budget_left: + omitted.append(dict(entry, reason='too large to embed')) + continue + + try: + with open(path, 'rb') as handle: + data = handle.read() + except OSError: + omitted.append(dict(entry, reason='file could not be read')) + continue + + budget_left -= len(data) + mime = 'image/jpeg' if extension in ('jpg', 'jpeg') else f'image/{extension}' + entry['data_uri'] = 'data:%s;base64,%s' % (mime, b64encode(data).decode('ascii')) + receipts.append(entry) + + for log in fuel_logs: + add(log, 'Fuel', + log.station or 'Fuel fill-up', + log.notes or '', + log.total_cost) + + for expense in expenses: + add(expense, 'Expense', + expense.description, + expense.vendor or '', + expense.cost) + + receipts.sort(key=lambda entry: entry['date'], reverse=True) + omitted.sort(key=lambda entry: entry['date'], reverse=True) + return receipts, omitted + + @bp.route('//report') @login_required def report(vehicle_id): @@ -413,6 +495,15 @@ def report(vehicle_id): # Get branding branding = AppSettings.get_all_branding() + # Receipts are opt-in (#219): they are what an accountant or employer + # asks for, but they also make the file much bigger, so only attach + # them when asked. + include_receipts = request.args.get('receipts') == '1' + receipts, receipts_omitted = [], [] + if include_receipts: + receipts, receipts_omitted = collect_receipts( + fuel_logs, expenses, current_app.config['UPLOAD_FOLDER']) + # Render HTML template html_content = render_template( 'vehicles/report_pdf.html', @@ -423,6 +514,9 @@ def report(vehicle_id): stats=stats, user=current_user, branding=branding, + include_receipts=include_receipts, + receipts=receipts, + receipts_omitted=receipts_omitted, generated_at=datetime.utcnow() ) diff --git a/app/templates/api/docs.html b/app/templates/api/docs.html index 1a99ec2..761b039 100644 --- a/app/templates/api/docs.html +++ b/app/templates/api/docs.html @@ -16,6 +16,8 @@

Quick Navigat Vehicles Fuel Logs Expenses + Trips + Charging Error Handling @@ -426,6 +428,211 @@

Expense Categori + +
+
+
+

Trips

+

Log journeys for mileage and tax purposes

+
+ + +
+
+
+ GET + /vehicles/{id}/trips +
+

List trips for a vehicle

+ +

Query Parameters

+
+ + + + + + + + + + + + + + +
ParameterDefaultDescription
limit100Maximum results (max: 500)
offset0Results to skip for pagination
purpose-Filter by purpose
sortdescSort by date: asc or desc
+
+
+
+ + +
+
+
+ POST + /vehicles/{id}/trips +
+

Create a trip

+ +

Request Body

+
+ + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
datestringYesDate in YYYY-MM-DD format
start_odometernumberYesOdometer at the start of the trip
purposestringYesSee purposes below
end_odometernumberNoOdometer at the end of the trip
descriptionstringNoDescription of the trip
start_locationstringNoWhere the trip started
end_locationstringNoWhere the trip ended
notesstringNoAdditional notes
+
+ +

Trip Purposes

+

Also available from GET /v1/trip-purposes

+
+ business + personal + commute + medical + charity + other +
+
+
+ + +
+
+
+ GET + PATCH + DELETE + /trips/{id} +
+

Get, update, or delete a specific trip

+
+
+
+
+ + +
+
+
+

Charging

+

Record EV charging sessions with energy, state of charge, and cost

+
+ + +
+
+
+ GET + /vehicles/{id}/charging +
+

List charging sessions for a vehicle

+ +

Query Parameters

+
+ + + + + + + + + + + + + + +
ParameterDefaultDescription
limit100Maximum results (max: 500)
offset0Results to skip for pagination
charger_type-Filter by charger type
sortdescSort by date: asc or desc
+
+ +

Sessions are returned under a charging_sessions key.

+
+
+ + +
+
+
+ POST + /vehicles/{id}/charging +
+

Create a charging session

+ +

Request Body

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
datestringYesDate in YYYY-MM-DD format
start_timestringNoStart time in HH:MM format
end_timestringNoEnd time in HH:MM format
odometernumberNoOdometer at time of charge
kwh_addednumberNoEnergy added in kWh
start_socintegerNoState of charge at start (%)
end_socintegerNoState of charge at end (%)
cost_per_kwhnumberNoPrice per kWh
total_costnumberNoCalculated from kWh and price if omitted
charger_typestringNoSee charger types below
locationstringNoStation name or "Home"
networkstringNoCharging network name
notesstringNoAdditional notes
+
+ +

Charger Types

+

Also available from GET /v1/charger-types

+
+ home + level1 + level2 + dcfc + tesla + other +
+
+
+ + +
+
+
+ GET + PATCH + DELETE + /charging/{id} +
+

Get, update, or delete a specific charging session

+
+
+
+
+
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 852a450..6cbc3d3 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -331,6 +331,7 @@

{{ _('Recent Expen