From 8a5b916a395eb284053e82240638adf8e3f550cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:39:52 +0000 Subject: [PATCH] Add attachment support for fuel and recurring expenses with camera capture Co-authored-by: aiulian25 <17886483+aiulian25@users.noreply.github.com> --- ATTACHMENT_API.md | 394 ++++++++++++++++++++++++ backend/app.py | 13 +- backend/migrate_attachments.py | 75 +++++ backend/models.py | 2 + frontend/static/js/app.js | 2 +- frontend/templates/fuel.html | 2 +- frontend/templates/service_records.html | 2 +- frontend/templates/taxes.html | 6 +- 8 files changed, 487 insertions(+), 9 deletions(-) create mode 100644 ATTACHMENT_API.md create mode 100644 backend/migrate_attachments.py diff --git a/ATTACHMENT_API.md b/ATTACHMENT_API.md new file mode 100644 index 0000000..878e3bb --- /dev/null +++ b/ATTACHMENT_API.md @@ -0,0 +1,394 @@ +# Attachment API Documentation + +## Overview +The Masina-Dock application supports file attachments for fuel records, service records, and recurring expenses (taxes). This document describes the API endpoints, data models, and usage patterns for working with attachments. + +## Security +- All attachment operations require authentication via `@login_required` decorator +- Users can only access their own attachments through vehicle ownership validation +- File paths are validated to prevent directory traversal attacks +- Uploaded files are stored in `/app/uploads/attachments/` with secure filenames + +## API Endpoints + +### Upload Attachment +Upload a file attachment. + +**Endpoint:** `POST /api/upload/attachment` + +**Authentication:** Required + +**Request:** +- Content-Type: `multipart/form-data` +- Body parameter: `attachment` (file) + +**Allowed File Types:** +- PDF: `.pdf` +- Images: `.png`, `.jpg`, `.jpeg` +- Documents: `.txt`, `.doc`, `.docx` +- Spreadsheets: `.xls`, `.xlsx`, `.csv` +- OpenDocument: `.odt`, `.ods` + +**Response:** +```json +{ + "file_path": "attachments/20231112153045_a1b2c3d4_receipt.pdf" +} +``` + +**Error Responses:** +- 400: No attachment provided / No file selected / Invalid file type +- 500: Server error during upload + +**Example:** +```javascript +const formData = new FormData(); +formData.append('attachment', fileInput.files[0]); + +const response = await fetch('/api/upload/attachment', { + method: 'POST', + credentials: 'include', + body: formData +}); + +const data = await response.json(); +const filePath = data.file_path; +``` + +--- + +### Download Attachment +Download or view an attachment. + +**Endpoint:** `GET /api/attachments/download` + +**Authentication:** Required + +**Query Parameters:** +- `path` (required): Relative path to the attachment (e.g., `attachments/filename.pdf`) + +**Response:** +- File content with appropriate Content-Type header +- Downloads as attachment + +**Error Responses:** +- 400: No file path provided +- 403: Invalid file path (security violation) +- 404: File not found + +**Example:** +```html + + Download Receipt + +``` + +--- + +## Data Models + +### FuelRecord +```python +class FuelRecord(db.Model): + id = db.Column(db.Integer, primary_key=True) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + odometer = db.Column(db.Integer, nullable=False) + fuel_amount = db.Column(db.Float, nullable=False) + cost = db.Column(db.Float, nullable=False) + unit_cost = db.Column(db.Float) + distance = db.Column(db.Integer) + fuel_economy = db.Column(db.Float) + unit = db.Column(db.String(20), default='MPG') + notes = db.Column(db.Text) + document_path = db.Column(db.String(255)) # NEW: Stores attachment path + created_at = db.Column(db.DateTime, default=datetime.utcnow) +``` + +### RecurringExpense +```python +class RecurringExpense(db.Model): + id = db.Column(db.Integer, primary_key=True) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False) + expense_type = db.Column(db.String(50), nullable=False) + description = db.Column(db.String(200), nullable=False) + amount = db.Column(db.Float, nullable=False) + frequency = db.Column(db.String(20), nullable=False) + start_date = db.Column(db.Date, nullable=False) + next_due_date = db.Column(db.Date, nullable=False) + is_active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + notes = db.Column(db.Text) + document_path = db.Column(db.String(255)) # NEW: Stores attachment path +``` + +### ServiceRecord +```python +class ServiceRecord(db.Model): + id = db.Column(db.Integer, primary_key=True) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + odometer = db.Column(db.Integer, nullable=False) + description = db.Column(db.String(200), nullable=False) + cost = db.Column(db.Float, default=0.0) + notes = db.Column(db.Text) + category = db.Column(db.String(50)) + document_path = db.Column(db.String(255)) # EXISTING: Already supported + created_at = db.Column(db.DateTime, default=datetime.utcnow) +``` + +--- + +## Using Attachments with Records + +### Fuel Records + +**Create with Attachment:** +```javascript +// 1. Upload attachment first +const formData = new FormData(); +formData.append('attachment', fileInput.files[0]); +const uploadResponse = await fetch('/api/upload/attachment', { + method: 'POST', + credentials: 'include', + body: formData +}); +const { file_path } = await uploadResponse.json(); + +// 2. Create fuel record with attachment path +await fetch(`/api/vehicles/${vehicleId}/fuel-records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + date: '2023-11-12', + odometer: 45000, + fuel_amount: 12.5, + cost: 45.00, + unit: 'MPG', + notes: 'Regular fill-up', + document_path: file_path // Attach the uploaded file + }) +}); +``` + +**Retrieve with Attachment:** +```javascript +const records = await fetch(`/api/vehicles/${vehicleId}/fuel-records`, { + credentials: 'include' +}).then(r => r.json()); + +// Each record includes document_path +records.forEach(record => { + if (record.document_path) { + console.log(`Attachment: ${record.document_path}`); + } +}); +``` + +### Recurring Expenses (Taxes) + +**Create with Attachment:** +```javascript +// 1. Upload attachment +const formData = new FormData(); +formData.append('attachment', fileInput.files[0]); +const uploadResponse = await fetch('/api/upload/attachment', { + method: 'POST', + credentials: 'include', + body: formData +}); +const { file_path } = await uploadResponse.json(); + +// 2. Create recurring expense with attachment +await fetch(`/api/vehicles/${vehicleId}/recurring-expenses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + expense_type: 'Insurance', + description: 'Car Insurance', + amount: 1200.00, + frequency: 'yearly', + start_date: '2023-11-12', + notes: 'Annual premium', + document_path: file_path // Attach the policy document + }) +}); +``` + +### Service Records + +**Create with Attachment:** +```javascript +// Same pattern as fuel records +const formData = new FormData(); +formData.append('attachment', fileInput.files[0]); +const uploadResponse = await fetch('/api/upload/attachment', { + method: 'POST', + credentials: 'include', + body: formData +}); +const { file_path } = await uploadResponse.json(); + +await fetch(`/api/vehicles/${vehicleId}/service-records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + date: '2023-11-12', + odometer: 45000, + description: 'Oil Change', + category: 'Maintenance', + cost: 45.00, + notes: 'Synthetic oil', + document_path: file_path // Attach the invoice + }) +}); +``` + +--- + +## Mobile Camera Support + +All attachment input fields support camera capture on mobile devices via the `capture="environment"` attribute: + +```html + +``` + +On mobile devices, this will prompt the user to: +1. Take a photo with the camera, OR +2. Select a file from the device + +The `accept="image/*"` attribute allows any image format, while specific formats like `.pdf` and `.txt` are also accepted. + +--- + +## Migration + +To add attachment support to an existing database, run the migration script: + +```bash +# Inside Docker container +python /app/backend/migrate_attachments.py + +# Or locally +cd backend +python migrate_attachments.py +``` + +This will add the `document_path` column to: +- `fuel_record` table +- `recurring_expense` table + +--- + +## File Storage + +**Location:** `/app/uploads/attachments/` + +**Filename Format:** `{timestamp}_{random}_{original_filename}` +- Example: `20231112153045_a1b2c3d4_receipt.pdf` + +**Permissions:** Files are saved with `0o644` permissions (read for all, write for owner) + +**Directory Creation:** The attachments directory is created automatically if it doesn't exist + +--- + +## Best Practices + +1. **Always upload the file first**, then reference it in the record +2. **Handle upload failures gracefully** - allow users to save records without attachments +3. **Validate file types** on both client and server side +4. **Use relative paths** - store only `attachments/filename.ext`, not absolute paths +5. **Check file existence** before displaying download links +6. **Implement cleanup** - consider removing orphaned attachments when records are deleted + +--- + +## Example: Complete Flow + +```javascript +async function addFuelRecordWithAttachment(vehicleId, recordData, file) { + let documentPath = null; + + // Step 1: Upload attachment if present + if (file) { + try { + const formData = new FormData(); + formData.append('attachment', file); + + const uploadResponse = await fetch('/api/upload/attachment', { + method: 'POST', + credentials: 'include', + body: formData + }); + + if (!uploadResponse.ok) { + console.error('Attachment upload failed'); + // Continue without attachment + } else { + const { file_path } = await uploadResponse.json(); + documentPath = file_path; + } + } catch (error) { + console.error('Attachment upload error:', error); + // Continue without attachment + } + } + + // Step 2: Create fuel record + const response = await fetch(`/api/vehicles/${vehicleId}/fuel-records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + ...recordData, + document_path: documentPath + }) + }); + + if (!response.ok) { + throw new Error('Failed to create fuel record'); + } + + return await response.json(); +} +``` + +--- + +## Error Handling + +```javascript +try { + const formData = new FormData(); + formData.append('attachment', file); + + const response = await fetch('/api/upload/attachment', { + method: 'POST', + credentials: 'include', + body: formData + }); + + if (!response.ok) { + const error = await response.json(); + console.error('Upload failed:', error.error); + // Handle specific error cases + if (response.status === 400) { + alert('Invalid file type or no file selected'); + } else if (response.status === 500) { + alert('Server error during upload'); + } + } +} catch (error) { + console.error('Network error:', error); + alert('Failed to connect to server'); +} +``` diff --git a/backend/app.py b/backend/app.py index c7ce94c..b5b8f3c 100644 --- a/backend/app.py +++ b/backend/app.py @@ -505,7 +505,8 @@ def fuel_records(vehicle_id): 'distance': r.distance, 'fuel_economy': r.fuel_economy, 'unit': r.unit, - 'notes': r.notes + 'notes': r.notes, + 'document_path': r.document_path } for r in records]), 200 elif request.method == 'POST': @@ -625,7 +626,8 @@ def recurring_expenses(vehicle_id): 'start_date': e.start_date.isoformat(), 'next_due_date': e.next_due_date.isoformat(), 'is_active': e.is_active, - 'notes': e.notes + 'notes': e.notes, + 'document_path': e.document_path } for e in expenses]), 200 elif request.method == 'POST': @@ -651,7 +653,8 @@ def recurring_expenses(vehicle_id): frequency=frequency, start_date=start_date, next_due_date=next_due, - notes=data.get('notes') + notes=data.get('notes'), + document_path=data.get('document_path') ) db.session.add(expense) @@ -847,7 +850,8 @@ def fuel_record_operations(vehicle_id, record_id): 'odometer': record.odometer, 'fuel_amount': record.fuel_amount, 'cost': record.cost, - 'notes': record.notes or '' + 'notes': record.notes or '', + 'document_path': record.document_path or '' }) elif request.method == 'PUT': @@ -857,6 +861,7 @@ def fuel_record_operations(vehicle_id, record_id): record.fuel_amount = data.get('fuel_amount', record.fuel_amount) record.cost = data.get('cost', record.cost) record.notes = data.get('notes', record.notes) + record.document_path = data.get('document_path', record.document_path) db.session.commit() return jsonify({'message': 'Fuel record updated successfully'}) diff --git a/backend/migrate_attachments.py b/backend/migrate_attachments.py new file mode 100644 index 0000000..a7fa0ef --- /dev/null +++ b/backend/migrate_attachments.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +Database migration script to add document_path columns to FuelRecord and RecurringExpense tables. +This migration adds support for file attachments to fuel and tax (recurring expense) entries. +""" +import sqlite3 +import os +import sys + +def migrate_database(): + """Add document_path columns to FuelRecord and RecurringExpense tables.""" + # Support both Docker and local paths + db_paths = [ + '/app/data/masina_dock.db', + './data/masina_dock.db', + '../data/masina_dock.db' + ] + + db_path = None + for path in db_paths: + if os.path.exists(path): + db_path = path + break + + if not db_path: + print("Database does not exist yet. Migration will be applied on first run.") + return True + + print(f"Migrating database at: {db_path}") + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check FuelRecord table + cursor.execute("PRAGMA table_info(fuel_record)") + fuel_columns = [column[1] for column in cursor.fetchall()] + + if 'document_path' not in fuel_columns: + print("Adding document_path column to fuel_record table...") + cursor.execute("ALTER TABLE fuel_record ADD COLUMN document_path VARCHAR(255)") + print("✓ Added document_path to fuel_record") + else: + print("✓ fuel_record.document_path already exists") + + # Check RecurringExpense table + cursor.execute("PRAGMA table_info(recurring_expense)") + expense_columns = [column[1] for column in cursor.fetchall()] + + if 'document_path' not in expense_columns: + print("Adding document_path column to recurring_expense table...") + cursor.execute("ALTER TABLE recurring_expense ADD COLUMN document_path VARCHAR(255)") + print("✓ Added document_path to recurring_expense") + else: + print("✓ recurring_expense.document_path already exists") + + conn.commit() + print("\n✓ Database migration completed successfully!") + return True + + except sqlite3.Error as e: + print(f"✗ Migration error: {e}", file=sys.stderr) + if conn: + conn.rollback() + return False + except Exception as e: + print(f"✗ Unexpected error: {e}", file=sys.stderr) + return False + finally: + if conn: + conn.close() + +if __name__ == '__main__': + success = migrate_database() + sys.exit(0 if success else 1) diff --git a/backend/models.py b/backend/models.py index a9967e0..c65932b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -111,6 +111,7 @@ class FuelRecord(db.Model): fuel_economy = db.Column(db.Float) unit = db.Column(db.String(20), default='MPG') notes = db.Column(db.Text) + document_path = db.Column(db.String(255)) created_at = db.Column(db.DateTime, default=datetime.utcnow) class Reminder(db.Model): @@ -151,3 +152,4 @@ class RecurringExpense(db.Model): is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) notes = db.Column(db.Text) + document_path = db.Column(db.String(255)) diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js index d7afa1c..d59c1dc 100644 --- a/frontend/static/js/app.js +++ b/frontend/static/js/app.js @@ -254,7 +254,7 @@ function displayFuelRecords(records) {
Frequency: ${e.frequency}
Next Due: ${formatDate(e.next_due_date)}
${e.notes || ''}
+ ${e.document_path ? `Attachment: Download
` : ''} @@ -291,7 +292,8 @@ amount: parseFloat(document.getElementById('tax-amount').value), frequency: document.getElementById('tax-frequency').value, start_date: document.getElementById('tax-date').value, - notes: document.getElementById('tax-notes').value || null + notes: document.getElementById('tax-notes').value || null, + document_path: attachmentPath }; try {