Initial commit: Masina-Dock Vehicle Management System

This commit is contained in:
Iulian 2025-10-19 11:10:11 +01:00
commit ae923e2c41
4999 changed files with 1607266 additions and 0 deletions

0
backend/__init__.py Normal file
View file

899
backend/app.py Normal file
View file

@ -0,0 +1,899 @@
from flask import Flask, jsonify, request, send_from_directory, send_file, session, redirect
from flask_cors import CORS
from flask_login import login_required, current_user
from flask_mail import Mail
from models import db, Vehicle, ServiceRecord, FuelRecord, Reminder, Todo, User, RecurringExpense
from auth import auth_bp, login_manager
from config import Config
import os
from datetime import datetime, timedelta
import pandas as pd
from werkzeug.utils import secure_filename
import io
import shutil
import zipfile
import tempfile
import secrets
import re
app = Flask(__name__, static_folder='../frontend/static', template_folder='../frontend/templates')
app.config.from_object(Config)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
app.config['SESSION_COOKIE_SECURE'] = False
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
CORS(app, supports_credentials=True, resources={r"/api/*": {"origins": "*"}})
db.init_app(app)
login_manager.init_app(app)
mail = Mail(app)
app.register_blueprint(auth_bp)
ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'csv'}
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
ALLOWED_ATTACHMENT_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'txt', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'odt', 'ods'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def allowed_image(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
def allowed_attachment(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_ATTACHMENT_EXTENSIONS
def validate_email(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
@app.before_request
def check_session():
session.permanent = True
if 'csrf_token' not in session:
session['csrf_token'] = secrets.token_hex(16)
@app.route('/')
def index():
return send_from_directory(app.template_folder, 'index.html')
@app.route('/login')
def login_page():
return send_from_directory(app.template_folder, 'login.html')
@app.route('/register')
def register_page():
return send_from_directory(app.template_folder, 'register.html')
@app.route('/first-login')
@login_required
def first_login_page():
if not current_user.must_change_credentials:
return redirect('/dashboard')
return send_from_directory(app.template_folder, 'first_login.html')
@app.route('/dashboard')
@login_required
def dashboard():
return send_from_directory(app.template_folder, 'dashboard.html')
@app.route('/settings')
@login_required
def settings_page():
return send_from_directory(app.template_folder, 'settings.html')
@app.route('/vehicles')
@login_required
def vehicles_page():
return send_from_directory(app.template_folder, 'vehicles.html')
@app.route('/vehicle-detail')
@login_required
def vehicle_detail_page():
return send_from_directory(app.template_folder, 'vehicle_detail.html')
@app.route('/service-records')
@login_required
def service_records_page():
return send_from_directory(app.template_folder, 'service_records.html')
@app.route('/repairs')
@login_required
def repairs_page():
return send_from_directory(app.template_folder, 'repairs.html')
@app.route('/fuel')
@login_required
def fuel_page():
return send_from_directory(app.template_folder, 'fuel.html')
@app.route('/taxes')
@login_required
def taxes_page():
return send_from_directory(app.template_folder, 'taxes.html')
@app.route('/notes')
@login_required
def notes_page():
return send_from_directory(app.template_folder, 'notes.html')
@app.route('/reminders')
@login_required
def reminders_page():
return send_from_directory(app.template_folder, 'reminders.html')
@app.route('/planner')
@login_required
def planner_page():
return send_from_directory(app.template_folder, 'planner.html')
@app.route('/uploads/<path:filename>')
def serve_upload(filename):
return send_from_directory('/app/uploads', filename)
@app.route('/api/settings', methods=['GET'])
@login_required
def get_settings():
return jsonify({
'username': current_user.username,
'email': current_user.email,
'language': current_user.language,
'unit_system': current_user.unit_system,
'currency': current_user.currency,
'theme': current_user.theme,
'photo': current_user.photo
}), 200
@app.route('/api/settings/language', methods=['POST'])
@login_required
def update_language():
data = request.get_json()
current_user.language = data.get('language', 'en')
db.session.commit()
return jsonify({'message': 'Language updated successfully'}), 200
@app.route('/api/settings/units', methods=['POST'])
@login_required
def update_units():
data = request.get_json()
current_user.unit_system = data.get('unit_system', 'metric')
current_user.currency = data.get('currency', 'USD')
db.session.commit()
return jsonify({'message': 'Units and currency updated successfully'}), 200
@app.route('/api/settings/theme', methods=['POST'])
@login_required
def update_theme():
data = request.get_json()
current_user.theme = data['theme']
db.session.commit()
return jsonify({'message': 'Theme updated successfully'}), 200
@app.route('/api/user/update-profile', methods=['POST'])
@login_required
def update_profile():
data = request.get_json()
if 'username' in data and data['username'] != current_user.username:
existing = User.query.filter_by(username=data['username']).first()
if existing:
return jsonify({'error': 'Username already taken'}), 400
current_user.username = data['username']
if 'email' in data and data['email'] != current_user.email:
if not validate_email(data['email']):
return jsonify({'error': 'Invalid email format'}), 400
existing = User.query.filter_by(email=data['email']).first()
if existing:
return jsonify({'error': 'Email already registered'}), 400
current_user.email = data['email']
if 'photo' in data:
current_user.photo = data['photo']
db.session.commit()
return jsonify({'message': 'Profile updated successfully'}), 200
@app.route('/api/backup/create', methods=['GET'])
@login_required
def create_backup():
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_filename = f'masina_dock_backup_{timestamp}.zip'
temp_dir = tempfile.mkdtemp()
backup_path = os.path.join(temp_dir, backup_filename)
try:
with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
db_path = '/app/data/masina_dock.db'
if os.path.exists(db_path):
zipf.write(db_path, 'masina_dock.db')
uploads_dir = '/app/uploads'
if os.path.exists(uploads_dir):
for root, dirs, files in os.walk(uploads_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, '/app')
zipf.write(file_path, arcname)
return send_file(
backup_path,
mimetype='application/zip',
as_attachment=True,
download_name=backup_filename
)
except Exception as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': str(e)}), 500
@app.route('/api/backup/restore', methods=['POST'])
@login_required
def restore_backup():
if 'backup' not in request.files:
return jsonify({'error': 'No backup file provided'}), 400
file = request.files['backup']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not file.filename.endswith('.zip'):
return jsonify({'error': 'Invalid file type. Please upload a .zip file.'}), 400
temp_dir = tempfile.mkdtemp()
backup_path = os.path.join(temp_dir, secure_filename(file.filename))
try:
file.save(backup_path)
with zipfile.ZipFile(backup_path, 'r') as zipf:
zipf.extractall(temp_dir)
extracted_db = os.path.join(temp_dir, 'masina_dock.db')
if os.path.exists(extracted_db):
target_db = '/app/data/masina_dock.db'
backup_db = '/app/data/masina_dock.db.backup'
if os.path.exists(target_db):
shutil.copy2(target_db, backup_db)
os.remove(target_db)
shutil.copy2(extracted_db, target_db)
else:
raise Exception('Database file not found in backup')
extracted_uploads = os.path.join(temp_dir, 'uploads')
if os.path.exists(extracted_uploads):
target_uploads = '/app/uploads'
for item in os.listdir(target_uploads):
item_path = os.path.join(target_uploads, item)
try:
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
except Exception as e:
print(f"Error removing {item_path}: {e}")
for root, dirs, files in os.walk(extracted_uploads):
rel_path = os.path.relpath(root, extracted_uploads)
target_path = os.path.join(target_uploads, rel_path) if rel_path != '.' else target_uploads
os.makedirs(target_path, exist_ok=True)
for file in files:
src_file = os.path.join(root, file)
dst_file = os.path.join(target_path, file)
shutil.copy2(src_file, dst_file)
os.chmod(dst_file, 0o644)
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'message': 'Backup restored successfully'}), 200
except zipfile.BadZipFile:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': 'Invalid or corrupted backup file'}), 400
except Exception as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': f'Restore failed: {str(e)}'}), 500
@app.route('/api/vehicles', methods=['GET', 'POST'])
@login_required
def vehicles():
if request.method == 'GET':
vehicles = Vehicle.query.filter_by(user_id=current_user.id).all()
return jsonify([{
'id': v.id,
'year': v.year,
'make': v.make,
'model': v.model,
'vin': v.vin,
'license_plate': v.license_plate,
'odometer': v.odometer,
'photo': v.photo,
'status': v.status
} for v in vehicles]), 200
elif request.method == 'POST':
data = request.get_json()
vehicle = Vehicle(
user_id=current_user.id,
year=data['year'],
make=data['make'],
model=data['model'],
vin=data.get('vin'),
license_plate=data.get('license_plate'),
odometer=data.get('odometer', 0),
photo=data.get('photo')
)
db.session.add(vehicle)
db.session.commit()
return jsonify({'id': vehicle.id, 'message': 'Vehicle added successfully'}), 201
@app.route('/api/upload/photo', methods=['POST'])
@login_required
def upload_photo():
if 'photo' not in request.files:
return jsonify({'error': 'No photo provided'}), 400
file = request.files['photo']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file and allowed_image(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_suffix = secrets.token_hex(4)
filename = f"{timestamp}_{random_suffix}_{filename}"
uploads_dir = '/app/uploads'
os.makedirs(uploads_dir, exist_ok=True)
filepath = os.path.join(uploads_dir, filename)
file.save(filepath)
os.chmod(filepath, 0o644)
return jsonify({'photo_url': f'/uploads/{filename}'}), 200
return jsonify({'error': 'Invalid file type. Only images are allowed.'}), 400
@app.route('/api/upload/attachment', methods=['POST'])
@login_required
def upload_attachment():
if 'attachment' not in request.files:
return jsonify({'error': 'No attachment provided'}), 400
file = request.files['attachment']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file and allowed_attachment(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_suffix = secrets.token_hex(4)
filename = f"{timestamp}_{random_suffix}_{filename}"
attachments_dir = '/app/uploads/attachments'
os.makedirs(attachments_dir, exist_ok=True)
filepath = os.path.join(attachments_dir, filename)
file.save(filepath)
os.chmod(filepath, 0o644)
relative_path = f'attachments/{filename}'
return jsonify({'file_path': relative_path}), 200
return jsonify({'error': 'Invalid file type'}), 400
@app.route('/api/attachments/download', methods=['GET'])
@login_required
def download_attachment():
file_path = request.args.get('path')
if not file_path:
return jsonify({'error': 'No file path provided'}), 400
full_path = os.path.join('/app/uploads', file_path)
if not os.path.exists(full_path):
return jsonify({'error': 'File not found'}), 404
if not full_path.startswith('/app/uploads'):
return jsonify({'error': 'Invalid file path'}), 403
directory = os.path.dirname(full_path)
filename = os.path.basename(full_path)
return send_from_directory(directory, filename, as_attachment=True)
@app.route('/api/vehicles/<int:vehicle_id>', methods=['GET', 'PUT', 'DELETE'])
@login_required
def vehicle_detail(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
return jsonify({
'id': vehicle.id,
'year': vehicle.year,
'make': vehicle.make,
'model': vehicle.model,
'vin': vehicle.vin,
'license_plate': vehicle.license_plate,
'odometer': vehicle.odometer,
'photo': vehicle.photo,
'status': vehicle.status
}), 200
elif request.method == 'PUT':
data = request.get_json()
vehicle.year = data.get('year', vehicle.year)
vehicle.make = data.get('make', vehicle.make)
vehicle.model = data.get('model', vehicle.model)
vehicle.vin = data.get('vin', vehicle.vin)
vehicle.license_plate = data.get('license_plate', vehicle.license_plate)
vehicle.odometer = data.get('odometer', vehicle.odometer)
vehicle.status = data.get('status', vehicle.status)
vehicle.photo = data.get('photo', vehicle.photo)
db.session.commit()
return jsonify({'message': 'Vehicle updated successfully'}), 200
elif request.method == 'DELETE':
db.session.delete(vehicle)
db.session.commit()
return jsonify({'message': 'Vehicle deleted successfully'}), 200
@app.route('/api/vehicles/<int:vehicle_id>/service-records', methods=['GET', 'POST'])
@login_required
def service_records(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).order_by(ServiceRecord.date.desc()).all()
return jsonify([{
'id': r.id,
'date': r.date.isoformat(),
'odometer': r.odometer,
'description': r.description,
'cost': r.cost,
'notes': r.notes,
'category': r.category,
'document_path': r.document_path
} for r in records]), 200
elif request.method == 'POST':
data = request.get_json()
record = ServiceRecord(
vehicle_id=vehicle_id,
date=datetime.fromisoformat(data['date']),
odometer=data['odometer'],
description=data['description'],
cost=data.get('cost', 0.0),
notes=data.get('notes'),
category=data.get('category'),
document_path=data.get('document_path')
)
db.session.add(record)
db.session.commit()
return jsonify({'id': record.id, 'message': 'Service record added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/fuel-records', methods=['GET', 'POST'])
@login_required
def fuel_records(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.date.desc()).all()
return jsonify([{
'id': r.id,
'date': r.date.isoformat(),
'odometer': r.odometer,
'fuel_amount': r.fuel_amount,
'cost': r.cost,
'unit_cost': r.unit_cost,
'distance': r.distance,
'fuel_economy': r.fuel_economy,
'unit': r.unit,
'notes': r.notes
} for r in records]), 200
elif request.method == 'POST':
data = request.get_json()
last_record = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.odometer.desc()).first()
distance = data['odometer'] - last_record.odometer if last_record else 0
fuel_economy = None
if distance > 0 and data['fuel_amount'] > 0:
if data.get('unit', 'MPG') == 'MPG':
fuel_economy = distance / data['fuel_amount']
elif data.get('unit') == 'L/100KM':
fuel_economy = (data['fuel_amount'] / distance) * 100
elif data.get('unit') == 'KM/L':
fuel_economy = distance / data['fuel_amount']
record = FuelRecord(
vehicle_id=vehicle_id,
date=datetime.fromisoformat(data['date']),
odometer=data['odometer'],
fuel_amount=data['fuel_amount'],
cost=data.get('cost', 0.0),
unit_cost=data.get('unit_cost'),
distance=distance,
fuel_economy=fuel_economy,
unit=data.get('unit', 'MPG'),
notes=data.get('notes')
)
db.session.add(record)
db.session.commit()
return jsonify({'id': record.id, 'message': 'Fuel record added successfully', 'fuel_economy': fuel_economy}), 201
@app.route('/api/vehicles/<int:vehicle_id>/reminders', methods=['GET', 'POST'])
@login_required
def reminders(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
reminders = Reminder.query.filter_by(vehicle_id=vehicle_id, completed=False).all()
return jsonify([{
'id': r.id,
'description': r.description,
'urgency': r.urgency,
'due_date': r.due_date.isoformat() if r.due_date else None,
'due_odometer': r.due_odometer,
'metric': r.metric,
'recurring': r.recurring,
'interval_type': r.interval_type,
'interval_value': r.interval_value,
'notes': r.notes
} for r in reminders]), 200
elif request.method == 'POST':
data = request.get_json()
reminder = Reminder(
vehicle_id=vehicle_id,
description=data['description'],
urgency=data.get('urgency', 'not_urgent'),
due_date=datetime.fromisoformat(data['due_date']) if data.get('due_date') else None,
due_odometer=data.get('due_odometer'),
metric=data.get('metric'),
recurring=data.get('recurring', False),
interval_type=data.get('interval_type'),
interval_value=data.get('interval_value'),
notes=data.get('notes')
)
db.session.add(reminder)
db.session.commit()
return jsonify({'id': reminder.id, 'message': 'Reminder added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/todos', methods=['GET', 'POST'])
@login_required
def todos(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
todos = Todo.query.filter_by(vehicle_id=vehicle_id).all()
return jsonify([{
'id': t.id,
'description': t.description,
'cost': t.cost,
'priority': t.priority,
'status': t.status,
'type': t.type,
'notes': t.notes
} for t in todos]), 200
elif request.method == 'POST':
data = request.get_json()
todo = Todo(
vehicle_id=vehicle_id,
description=data['description'],
cost=data.get('cost', 0.0),
priority=data.get('priority', 'medium'),
status=data.get('status', 'planned'),
type=data.get('type'),
notes=data.get('notes')
)
db.session.add(todo)
db.session.commit()
return jsonify({'id': todo.id, 'message': 'Todo added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/recurring-expenses', methods=['GET', 'POST'])
@login_required
def recurring_expenses(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
expenses = RecurringExpense.query.filter_by(vehicle_id=vehicle_id, is_active=True).all()
return jsonify([{
'id': e.id,
'expense_type': e.expense_type,
'description': e.description,
'amount': e.amount,
'frequency': e.frequency,
'start_date': e.start_date.isoformat(),
'next_due_date': e.next_due_date.isoformat(),
'is_active': e.is_active,
'notes': e.notes
} for e in expenses]), 200
elif request.method == 'POST':
data = request.get_json()
start_date = datetime.fromisoformat(data['start_date']).date()
frequency = data['frequency']
if frequency == 'monthly':
next_due = start_date + timedelta(days=30)
elif frequency == 'quarterly':
next_due = start_date + timedelta(days=90)
elif frequency == 'yearly':
next_due = start_date + timedelta(days=365)
else:
next_due = start_date
expense = RecurringExpense(
vehicle_id=vehicle_id,
expense_type=data['expense_type'],
description=data['description'],
amount=data['amount'],
frequency=frequency,
start_date=start_date,
next_due_date=next_due,
notes=data.get('notes')
)
db.session.add(expense)
db.session.commit()
return jsonify({
'id': expense.id,
'message': 'Recurring expense added successfully',
'next_due_date': next_due.isoformat()
}), 201
@app.route('/api/vehicles/<int:vehicle_id>/recurring-expenses/<int:expense_id>', methods=['DELETE'])
@login_required
def cancel_recurring_expense(vehicle_id, expense_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
expense = RecurringExpense.query.filter_by(id=expense_id, vehicle_id=vehicle_id).first_or_404()
expense.is_active = False
db.session.commit()
return jsonify({'message': 'Recurring expense cancelled successfully'}), 200
@app.route('/api/vehicles/<int:vehicle_id>/export-all', methods=['GET'])
@login_required
def export_all_vehicle_data(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
service_records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).order_by(ServiceRecord.date.desc()).all()
fuel_records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.date.desc()).all()
reminders = Reminder.query.filter_by(vehicle_id=vehicle_id).all()
todos = Todo.query.filter_by(vehicle_id=vehicle_id).all()
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
vehicle_data = pd.DataFrame([{
'Year': vehicle.year,
'Make': vehicle.make,
'Model': vehicle.model,
'VIN': vehicle.vin or '',
'License Plate': vehicle.license_plate or '',
'Current Odometer': vehicle.odometer,
'Status': vehicle.status,
'Photo URL': vehicle.photo or ''
}])
vehicle_data.to_excel(writer, sheet_name='Vehicle Info', index=False)
if service_records:
service_df = pd.DataFrame([{
'Date': r.date.strftime('%Y-%m-%d'),
'Odometer': r.odometer,
'Description': r.description,
'Category': r.category or '',
'Cost': r.cost,
'Notes': r.notes or '',
'Document': r.document_path or ''
} for r in service_records])
service_df.to_excel(writer, sheet_name='Service Records', index=False)
if fuel_records:
fuel_df = pd.DataFrame([{
'Date': r.date.strftime('%Y-%m-%d'),
'Odometer': r.odometer,
'Fuel Amount': r.fuel_amount,
'Unit': r.unit,
'Cost': r.cost,
'Unit Cost': r.unit_cost or 0,
'Distance': r.distance or 0,
'Fuel Economy': r.fuel_economy or 0,
'Notes': r.notes or ''
} for r in fuel_records])
fuel_df.to_excel(writer, sheet_name='Fuel Records', index=False)
if reminders:
reminders_df = pd.DataFrame([{
'Description': r.description,
'Urgency': r.urgency,
'Due Date': r.due_date.strftime('%Y-%m-%d') if r.due_date else '',
'Due Odometer': r.due_odometer or '',
'Recurring': r.recurring,
'Interval Type': r.interval_type or '',
'Interval Value': r.interval_value or '',
'Notes': r.notes or '',
'Completed': r.completed
} for r in reminders])
reminders_df.to_excel(writer, sheet_name='Reminders', index=False)
if todos:
todos_df = pd.DataFrame([{
'Description': t.description,
'Type': t.type or '',
'Priority': t.priority,
'Status': t.status,
'Cost': t.cost,
'Notes': t.notes or ''
} for t in todos])
todos_df.to_excel(writer, sheet_name='Todos', index=False)
output.seek(0)
filename = f"{vehicle.year}_{vehicle.make}_{vehicle.model}_Complete_Data_{datetime.now().strftime('%Y%m%d')}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
@app.route('/api/export/<data_type>', methods=['GET'])
@login_required
def export_data(data_type):
vehicle_id = request.args.get('vehicle_id')
if data_type == 'service_records':
records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).all()
df = pd.DataFrame([{
'Date': r.date,
'Odometer': r.odometer,
'Description': r.description,
'Cost': r.cost,
'Category': r.category,
'Notes': r.notes
} for r in records])
elif data_type == 'fuel_records':
records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).all()
df = pd.DataFrame([{
'Date': r.date,
'Odometer': r.odometer,
'Fuel Amount': r.fuel_amount,
'Cost': r.cost,
'Fuel Economy': r.fuel_economy,
'Unit': r.unit
} for r in records])
else:
return jsonify({'error': 'Invalid data type'}), 400
output_file = f'/tmp/{data_type}_{vehicle_id}.csv'
df.to_csv(output_file, index=False)
return send_from_directory('/tmp', f'{data_type}_{vehicle_id}.csv', as_attachment=True)
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(host='0.0.0.0', port=5000, debug=False)
# Edit/Delete operations for Service Records
@app.route('/api/vehicles/<int:vehicle_id>/service-records/<int:record_id>', methods=['GET', 'PUT', 'DELETE'])
@login_required
def service_record_operations(vehicle_id, record_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
record = ServiceRecord.query.filter_by(id=record_id, vehicle_id=vehicle_id).first_or_404()
if request.method == 'GET':
return jsonify({
'id': record.id,
'date': record.date.isoformat(),
'odometer': record.odometer,
'description': record.description,
'category': record.category,
'cost': record.cost,
'notes': record.notes or '',
'document_path': record.document_path or ''
})
elif request.method == 'PUT':
data = request.get_json()
record.date = datetime.fromisoformat(data['date']).date() if 'date' in data else record.date
record.odometer = data.get('odometer', record.odometer)
record.description = data.get('description', record.description)
record.category = data.get('category', record.category)
record.cost = data.get('cost', record.cost)
record.notes = data.get('notes', record.notes)
db.session.commit()
return jsonify({'message': 'Service record updated successfully'})
elif request.method == 'DELETE':
db.session.delete(record)
db.session.commit()
return jsonify({'message': 'Service record deleted successfully'})
# Edit/Delete operations for Fuel Records
@app.route('/api/vehicles/<int:vehicle_id>/fuel-records/<int:record_id>', methods=['GET', 'PUT', 'DELETE'])
@login_required
def fuel_record_operations(vehicle_id, record_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
record = FuelRecord.query.filter_by(id=record_id, vehicle_id=vehicle_id).first_or_404()
if request.method == 'GET':
return jsonify({
'id': record.id,
'date': record.date.isoformat(),
'odometer': record.odometer,
'fuel_amount': record.fuel_amount,
'cost': record.cost,
'notes': record.notes or ''
})
elif request.method == 'PUT':
data = request.get_json()
record.date = datetime.fromisoformat(data['date']).date() if 'date' in data else record.date
record.odometer = data.get('odometer', record.odometer)
record.fuel_amount = data.get('fuel_amount', record.fuel_amount)
record.cost = data.get('cost', record.cost)
record.notes = data.get('notes', record.notes)
db.session.commit()
return jsonify({'message': 'Fuel record updated successfully'})
elif request.method == 'DELETE':
db.session.delete(record)
db.session.commit()
return jsonify({'message': 'Fuel record deleted successfully'})
# Edit/Delete operations for Reminders
@app.route('/api/vehicles/<int:vehicle_id>/reminders/<int:reminder_id>', methods=['GET', 'PUT', 'DELETE'])
@login_required
def reminder_operations(vehicle_id, reminder_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
reminder = Reminder.query.filter_by(id=reminder_id, vehicle_id=vehicle_id).first_or_404()
if request.method == 'GET':
return jsonify({
'id': reminder.id,
'description': reminder.description,
'urgency': reminder.urgency,
'due_date': reminder.due_date.isoformat() if reminder.due_date else None,
'due_odometer': reminder.due_odometer,
'notes': reminder.notes or ''
})
elif request.method == 'PUT':
data = request.get_json()
reminder.description = data.get('description', reminder.description)
reminder.urgency = data.get('urgency', reminder.urgency)
if 'due_date' in data and data['due_date']:
reminder.due_date = datetime.fromisoformat(data['due_date']).date()
reminder.due_odometer = data.get('due_odometer', reminder.due_odometer)
reminder.notes = data.get('notes', reminder.notes)
db.session.commit()
return jsonify({'message': 'Reminder updated successfully'})
elif request.method == 'DELETE':
db.session.delete(reminder)
db.session.commit()
return jsonify({'message': 'Reminder deleted successfully'})

View file

@ -0,0 +1,799 @@
from flask import Flask, jsonify, request, send_from_directory, send_file, session, redirect
from flask_cors import CORS
from flask_login import login_required, current_user
from flask_mail import Mail
from models import db, Vehicle, ServiceRecord, FuelRecord, Reminder, Todo, User, RecurringExpense
from auth import auth_bp, login_manager
from config import Config
import os
from datetime import datetime, timedelta
import pandas as pd
from werkzeug.utils import secure_filename
import io
import shutil
import zipfile
import tempfile
import secrets
import re
app = Flask(__name__, static_folder='../frontend/static', template_folder='../frontend/templates')
app.config.from_object(Config)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
app.config['SESSION_COOKIE_SECURE'] = False
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
CORS(app, supports_credentials=True, resources={r"/api/*": {"origins": "*"}})
db.init_app(app)
login_manager.init_app(app)
mail = Mail(app)
app.register_blueprint(auth_bp)
ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'csv'}
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
ALLOWED_ATTACHMENT_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'txt', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'odt', 'ods'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def allowed_image(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
def allowed_attachment(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_ATTACHMENT_EXTENSIONS
def validate_email(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
@app.before_request
def check_session():
session.permanent = True
if 'csrf_token' not in session:
session['csrf_token'] = secrets.token_hex(16)
@app.route('/')
def index():
return send_from_directory(app.template_folder, 'index.html')
@app.route('/login')
def login_page():
return send_from_directory(app.template_folder, 'login.html')
@app.route('/register')
def register_page():
return send_from_directory(app.template_folder, 'register.html')
@app.route('/first-login')
@login_required
def first_login_page():
if not current_user.must_change_credentials:
return redirect('/dashboard')
return send_from_directory(app.template_folder, 'first_login.html')
@app.route('/dashboard')
@login_required
def dashboard():
return send_from_directory(app.template_folder, 'dashboard.html')
@app.route('/settings')
@login_required
def settings_page():
return send_from_directory(app.template_folder, 'settings.html')
@app.route('/vehicles')
@login_required
def vehicles_page():
return send_from_directory(app.template_folder, 'vehicles.html')
@app.route('/vehicle-detail')
@login_required
def vehicle_detail_page():
return send_from_directory(app.template_folder, 'vehicle_detail.html')
@app.route('/service-records')
@login_required
def service_records_page():
return send_from_directory(app.template_folder, 'service_records.html')
@app.route('/repairs')
@login_required
def repairs_page():
return send_from_directory(app.template_folder, 'repairs.html')
@app.route('/fuel')
@login_required
def fuel_page():
return send_from_directory(app.template_folder, 'fuel.html')
@app.route('/taxes')
@login_required
def taxes_page():
return send_from_directory(app.template_folder, 'taxes.html')
@app.route('/notes')
@login_required
def notes_page():
return send_from_directory(app.template_folder, 'notes.html')
@app.route('/reminders')
@login_required
def reminders_page():
return send_from_directory(app.template_folder, 'reminders.html')
@app.route('/planner')
@login_required
def planner_page():
return send_from_directory(app.template_folder, 'planner.html')
@app.route('/uploads/<path:filename>')
def serve_upload(filename):
return send_from_directory('/app/uploads', filename)
@app.route('/api/settings', methods=['GET'])
@login_required
def get_settings():
return jsonify({
'username': current_user.username,
'email': current_user.email,
'language': current_user.language,
'unit_system': current_user.unit_system,
'currency': current_user.currency,
'theme': current_user.theme,
'photo': current_user.photo
}), 200
@app.route('/api/settings/language', methods=['POST'])
@login_required
def update_language():
data = request.get_json()
current_user.language = data.get('language', 'en')
db.session.commit()
return jsonify({'message': 'Language updated successfully'}), 200
@app.route('/api/settings/units', methods=['POST'])
@login_required
def update_units():
data = request.get_json()
current_user.unit_system = data.get('unit_system', 'metric')
current_user.currency = data.get('currency', 'USD')
db.session.commit()
return jsonify({'message': 'Units and currency updated successfully'}), 200
@app.route('/api/settings/theme', methods=['POST'])
@login_required
def update_theme():
data = request.get_json()
current_user.theme = data['theme']
db.session.commit()
return jsonify({'message': 'Theme updated successfully'}), 200
@app.route('/api/user/update-profile', methods=['POST'])
@login_required
def update_profile():
data = request.get_json()
if 'username' in data and data['username'] != current_user.username:
existing = User.query.filter_by(username=data['username']).first()
if existing:
return jsonify({'error': 'Username already taken'}), 400
current_user.username = data['username']
if 'email' in data and data['email'] != current_user.email:
if not validate_email(data['email']):
return jsonify({'error': 'Invalid email format'}), 400
existing = User.query.filter_by(email=data['email']).first()
if existing:
return jsonify({'error': 'Email already registered'}), 400
current_user.email = data['email']
if 'photo' in data:
current_user.photo = data['photo']
db.session.commit()
return jsonify({'message': 'Profile updated successfully'}), 200
@app.route('/api/backup/create', methods=['GET'])
@login_required
def create_backup():
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_filename = f'masina_dock_backup_{timestamp}.zip'
temp_dir = tempfile.mkdtemp()
backup_path = os.path.join(temp_dir, backup_filename)
try:
with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
db_path = '/app/data/masina_dock.db'
if os.path.exists(db_path):
zipf.write(db_path, 'masina_dock.db')
uploads_dir = '/app/uploads'
if os.path.exists(uploads_dir):
for root, dirs, files in os.walk(uploads_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, '/app')
zipf.write(file_path, arcname)
return send_file(
backup_path,
mimetype='application/zip',
as_attachment=True,
download_name=backup_filename
)
except Exception as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': str(e)}), 500
@app.route('/api/backup/restore', methods=['POST'])
@login_required
def restore_backup():
if 'backup' not in request.files:
return jsonify({'error': 'No backup file provided'}), 400
file = request.files['backup']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not file.filename.endswith('.zip'):
return jsonify({'error': 'Invalid file type. Please upload a .zip file.'}), 400
temp_dir = tempfile.mkdtemp()
backup_path = os.path.join(temp_dir, secure_filename(file.filename))
try:
file.save(backup_path)
with zipfile.ZipFile(backup_path, 'r') as zipf:
zipf.extractall(temp_dir)
extracted_db = os.path.join(temp_dir, 'masina_dock.db')
if os.path.exists(extracted_db):
target_db = '/app/data/masina_dock.db'
backup_db = '/app/data/masina_dock.db.backup'
if os.path.exists(target_db):
shutil.copy2(target_db, backup_db)
os.remove(target_db)
shutil.copy2(extracted_db, target_db)
else:
raise Exception('Database file not found in backup')
extracted_uploads = os.path.join(temp_dir, 'uploads')
if os.path.exists(extracted_uploads):
target_uploads = '/app/uploads'
for item in os.listdir(target_uploads):
item_path = os.path.join(target_uploads, item)
try:
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
except Exception as e:
print(f"Error removing {item_path}: {e}")
for root, dirs, files in os.walk(extracted_uploads):
rel_path = os.path.relpath(root, extracted_uploads)
target_path = os.path.join(target_uploads, rel_path) if rel_path != '.' else target_uploads
os.makedirs(target_path, exist_ok=True)
for file in files:
src_file = os.path.join(root, file)
dst_file = os.path.join(target_path, file)
shutil.copy2(src_file, dst_file)
os.chmod(dst_file, 0o644)
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'message': 'Backup restored successfully'}), 200
except zipfile.BadZipFile:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': 'Invalid or corrupted backup file'}), 400
except Exception as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': f'Restore failed: {str(e)}'}), 500
@app.route('/api/vehicles', methods=['GET', 'POST'])
@login_required
def vehicles():
if request.method == 'GET':
vehicles = Vehicle.query.filter_by(user_id=current_user.id).all()
return jsonify([{
'id': v.id,
'year': v.year,
'make': v.make,
'model': v.model,
'vin': v.vin,
'license_plate': v.license_plate,
'odometer': v.odometer,
'photo': v.photo,
'status': v.status
} for v in vehicles]), 200
elif request.method == 'POST':
data = request.get_json()
vehicle = Vehicle(
user_id=current_user.id,
year=data['year'],
make=data['make'],
model=data['model'],
vin=data.get('vin'),
license_plate=data.get('license_plate'),
odometer=data.get('odometer', 0),
photo=data.get('photo')
)
db.session.add(vehicle)
db.session.commit()
return jsonify({'id': vehicle.id, 'message': 'Vehicle added successfully'}), 201
@app.route('/api/upload/photo', methods=['POST'])
@login_required
def upload_photo():
if 'photo' not in request.files:
return jsonify({'error': 'No photo provided'}), 400
file = request.files['photo']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file and allowed_image(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_suffix = secrets.token_hex(4)
filename = f"{timestamp}_{random_suffix}_{filename}"
uploads_dir = '/app/uploads'
os.makedirs(uploads_dir, exist_ok=True)
filepath = os.path.join(uploads_dir, filename)
file.save(filepath)
os.chmod(filepath, 0o644)
return jsonify({'photo_url': f'/uploads/{filename}'}), 200
return jsonify({'error': 'Invalid file type. Only images are allowed.'}), 400
@app.route('/api/upload/attachment', methods=['POST'])
@login_required
def upload_attachment():
if 'attachment' not in request.files:
return jsonify({'error': 'No attachment provided'}), 400
file = request.files['attachment']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file and allowed_attachment(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_suffix = secrets.token_hex(4)
filename = f"{timestamp}_{random_suffix}_{filename}"
attachments_dir = '/app/uploads/attachments'
os.makedirs(attachments_dir, exist_ok=True)
filepath = os.path.join(attachments_dir, filename)
file.save(filepath)
os.chmod(filepath, 0o644)
relative_path = f'attachments/{filename}'
return jsonify({'file_path': relative_path}), 200
return jsonify({'error': 'Invalid file type'}), 400
@app.route('/api/attachments/download', methods=['GET'])
@login_required
def download_attachment():
file_path = request.args.get('path')
if not file_path:
return jsonify({'error': 'No file path provided'}), 400
full_path = os.path.join('/app/uploads', file_path)
if not os.path.exists(full_path):
return jsonify({'error': 'File not found'}), 404
if not full_path.startswith('/app/uploads'):
return jsonify({'error': 'Invalid file path'}), 403
directory = os.path.dirname(full_path)
filename = os.path.basename(full_path)
return send_from_directory(directory, filename, as_attachment=True)
@app.route('/api/vehicles/<int:vehicle_id>', methods=['GET', 'PUT', 'DELETE'])
@login_required
def vehicle_detail(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
return jsonify({
'id': vehicle.id,
'year': vehicle.year,
'make': vehicle.make,
'model': vehicle.model,
'vin': vehicle.vin,
'license_plate': vehicle.license_plate,
'odometer': vehicle.odometer,
'photo': vehicle.photo,
'status': vehicle.status
}), 200
elif request.method == 'PUT':
data = request.get_json()
vehicle.year = data.get('year', vehicle.year)
vehicle.make = data.get('make', vehicle.make)
vehicle.model = data.get('model', vehicle.model)
vehicle.vin = data.get('vin', vehicle.vin)
vehicle.license_plate = data.get('license_plate', vehicle.license_plate)
vehicle.odometer = data.get('odometer', vehicle.odometer)
vehicle.status = data.get('status', vehicle.status)
vehicle.photo = data.get('photo', vehicle.photo)
db.session.commit()
return jsonify({'message': 'Vehicle updated successfully'}), 200
elif request.method == 'DELETE':
db.session.delete(vehicle)
db.session.commit()
return jsonify({'message': 'Vehicle deleted successfully'}), 200
@app.route('/api/vehicles/<int:vehicle_id>/service-records', methods=['GET', 'POST'])
@login_required
def service_records(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).order_by(ServiceRecord.date.desc()).all()
return jsonify([{
'id': r.id,
'date': r.date.isoformat(),
'odometer': r.odometer,
'description': r.description,
'cost': r.cost,
'notes': r.notes,
'category': r.category,
'document_path': r.document_path
} for r in records]), 200
elif request.method == 'POST':
data = request.get_json()
record = ServiceRecord(
vehicle_id=vehicle_id,
date=datetime.fromisoformat(data['date']),
odometer=data['odometer'],
description=data['description'],
cost=data.get('cost', 0.0),
notes=data.get('notes'),
category=data.get('category'),
document_path=data.get('document_path')
)
db.session.add(record)
db.session.commit()
return jsonify({'id': record.id, 'message': 'Service record added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/fuel-records', methods=['GET', 'POST'])
@login_required
def fuel_records(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.date.desc()).all()
return jsonify([{
'id': r.id,
'date': r.date.isoformat(),
'odometer': r.odometer,
'fuel_amount': r.fuel_amount,
'cost': r.cost,
'unit_cost': r.unit_cost,
'distance': r.distance,
'fuel_economy': r.fuel_economy,
'unit': r.unit,
'notes': r.notes
} for r in records]), 200
elif request.method == 'POST':
data = request.get_json()
last_record = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.odometer.desc()).first()
distance = data['odometer'] - last_record.odometer if last_record else 0
fuel_economy = None
if distance > 0 and data['fuel_amount'] > 0:
if data.get('unit', 'MPG') == 'MPG':
fuel_economy = distance / data['fuel_amount']
elif data.get('unit') == 'L/100KM':
fuel_economy = (data['fuel_amount'] / distance) * 100
elif data.get('unit') == 'KM/L':
fuel_economy = distance / data['fuel_amount']
record = FuelRecord(
vehicle_id=vehicle_id,
date=datetime.fromisoformat(data['date']),
odometer=data['odometer'],
fuel_amount=data['fuel_amount'],
cost=data.get('cost', 0.0),
unit_cost=data.get('unit_cost'),
distance=distance,
fuel_economy=fuel_economy,
unit=data.get('unit', 'MPG'),
notes=data.get('notes')
)
db.session.add(record)
db.session.commit()
return jsonify({'id': record.id, 'message': 'Fuel record added successfully', 'fuel_economy': fuel_economy}), 201
@app.route('/api/vehicles/<int:vehicle_id>/reminders', methods=['GET', 'POST'])
@login_required
def reminders(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
reminders = Reminder.query.filter_by(vehicle_id=vehicle_id, completed=False).all()
return jsonify([{
'id': r.id,
'description': r.description,
'urgency': r.urgency,
'due_date': r.due_date.isoformat() if r.due_date else None,
'due_odometer': r.due_odometer,
'metric': r.metric,
'recurring': r.recurring,
'interval_type': r.interval_type,
'interval_value': r.interval_value,
'notes': r.notes
} for r in reminders]), 200
elif request.method == 'POST':
data = request.get_json()
reminder = Reminder(
vehicle_id=vehicle_id,
description=data['description'],
urgency=data.get('urgency', 'not_urgent'),
due_date=datetime.fromisoformat(data['due_date']) if data.get('due_date') else None,
due_odometer=data.get('due_odometer'),
metric=data.get('metric'),
recurring=data.get('recurring', False),
interval_type=data.get('interval_type'),
interval_value=data.get('interval_value'),
notes=data.get('notes')
)
db.session.add(reminder)
db.session.commit()
return jsonify({'id': reminder.id, 'message': 'Reminder added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/todos', methods=['GET', 'POST'])
@login_required
def todos(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
todos = Todo.query.filter_by(vehicle_id=vehicle_id).all()
return jsonify([{
'id': t.id,
'description': t.description,
'cost': t.cost,
'priority': t.priority,
'status': t.status,
'type': t.type,
'notes': t.notes
} for t in todos]), 200
elif request.method == 'POST':
data = request.get_json()
todo = Todo(
vehicle_id=vehicle_id,
description=data['description'],
cost=data.get('cost', 0.0),
priority=data.get('priority', 'medium'),
status=data.get('status', 'planned'),
type=data.get('type'),
notes=data.get('notes')
)
db.session.add(todo)
db.session.commit()
return jsonify({'id': todo.id, 'message': 'Todo added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/recurring-expenses', methods=['GET', 'POST'])
@login_required
def recurring_expenses(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
expenses = RecurringExpense.query.filter_by(vehicle_id=vehicle_id, is_active=True).all()
return jsonify([{
'id': e.id,
'expense_type': e.expense_type,
'description': e.description,
'amount': e.amount,
'frequency': e.frequency,
'start_date': e.start_date.isoformat(),
'next_due_date': e.next_due_date.isoformat(),
'is_active': e.is_active,
'notes': e.notes
} for e in expenses]), 200
elif request.method == 'POST':
data = request.get_json()
start_date = datetime.fromisoformat(data['start_date']).date()
frequency = data['frequency']
if frequency == 'monthly':
next_due = start_date + timedelta(days=30)
elif frequency == 'quarterly':
next_due = start_date + timedelta(days=90)
elif frequency == 'yearly':
next_due = start_date + timedelta(days=365)
else:
next_due = start_date
expense = RecurringExpense(
vehicle_id=vehicle_id,
expense_type=data['expense_type'],
description=data['description'],
amount=data['amount'],
frequency=frequency,
start_date=start_date,
next_due_date=next_due,
notes=data.get('notes')
)
db.session.add(expense)
db.session.commit()
return jsonify({
'id': expense.id,
'message': 'Recurring expense added successfully',
'next_due_date': next_due.isoformat()
}), 201
@app.route('/api/vehicles/<int:vehicle_id>/recurring-expenses/<int:expense_id>', methods=['DELETE'])
@login_required
def cancel_recurring_expense(vehicle_id, expense_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
expense = RecurringExpense.query.filter_by(id=expense_id, vehicle_id=vehicle_id).first_or_404()
expense.is_active = False
db.session.commit()
return jsonify({'message': 'Recurring expense cancelled successfully'}), 200
@app.route('/api/vehicles/<int:vehicle_id>/export-all', methods=['GET'])
@login_required
def export_all_vehicle_data(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
service_records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).order_by(ServiceRecord.date.desc()).all()
fuel_records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.date.desc()).all()
reminders = Reminder.query.filter_by(vehicle_id=vehicle_id).all()
todos = Todo.query.filter_by(vehicle_id=vehicle_id).all()
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
vehicle_data = pd.DataFrame([{
'Year': vehicle.year,
'Make': vehicle.make,
'Model': vehicle.model,
'VIN': vehicle.vin or '',
'License Plate': vehicle.license_plate or '',
'Current Odometer': vehicle.odometer,
'Status': vehicle.status,
'Photo URL': vehicle.photo or ''
}])
vehicle_data.to_excel(writer, sheet_name='Vehicle Info', index=False)
if service_records:
service_df = pd.DataFrame([{
'Date': r.date.strftime('%Y-%m-%d'),
'Odometer': r.odometer,
'Description': r.description,
'Category': r.category or '',
'Cost': r.cost,
'Notes': r.notes or '',
'Document': r.document_path or ''
} for r in service_records])
service_df.to_excel(writer, sheet_name='Service Records', index=False)
if fuel_records:
fuel_df = pd.DataFrame([{
'Date': r.date.strftime('%Y-%m-%d'),
'Odometer': r.odometer,
'Fuel Amount': r.fuel_amount,
'Unit': r.unit,
'Cost': r.cost,
'Unit Cost': r.unit_cost or 0,
'Distance': r.distance or 0,
'Fuel Economy': r.fuel_economy or 0,
'Notes': r.notes or ''
} for r in fuel_records])
fuel_df.to_excel(writer, sheet_name='Fuel Records', index=False)
if reminders:
reminders_df = pd.DataFrame([{
'Description': r.description,
'Urgency': r.urgency,
'Due Date': r.due_date.strftime('%Y-%m-%d') if r.due_date else '',
'Due Odometer': r.due_odometer or '',
'Recurring': r.recurring,
'Interval Type': r.interval_type or '',
'Interval Value': r.interval_value or '',
'Notes': r.notes or '',
'Completed': r.completed
} for r in reminders])
reminders_df.to_excel(writer, sheet_name='Reminders', index=False)
if todos:
todos_df = pd.DataFrame([{
'Description': t.description,
'Type': t.type or '',
'Priority': t.priority,
'Status': t.status,
'Cost': t.cost,
'Notes': t.notes or ''
} for t in todos])
todos_df.to_excel(writer, sheet_name='Todos', index=False)
output.seek(0)
filename = f"{vehicle.year}_{vehicle.make}_{vehicle.model}_Complete_Data_{datetime.now().strftime('%Y%m%d')}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
@app.route('/api/export/<data_type>', methods=['GET'])
@login_required
def export_data(data_type):
vehicle_id = request.args.get('vehicle_id')
if data_type == 'service_records':
records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).all()
df = pd.DataFrame([{
'Date': r.date,
'Odometer': r.odometer,
'Description': r.description,
'Cost': r.cost,
'Category': r.category,
'Notes': r.notes
} for r in records])
elif data_type == 'fuel_records':
records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).all()
df = pd.DataFrame([{
'Date': r.date,
'Odometer': r.odometer,
'Fuel Amount': r.fuel_amount,
'Cost': r.cost,
'Fuel Economy': r.fuel_economy,
'Unit': r.unit
} for r in records])
else:
return jsonify({'error': 'Invalid data type'}), 400
output_file = f'/tmp/{data_type}_{vehicle_id}.csv'
df.to_csv(output_file, index=False)
return send_from_directory('/tmp', f'{data_type}_{vehicle_id}.csv', as_attachment=True)
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(host='0.0.0.0', port=5000, debug=False)

799
backend/app.py.backup-clean Normal file
View file

@ -0,0 +1,799 @@
from flask import Flask, jsonify, request, send_from_directory, send_file, session, redirect
from flask_cors import CORS
from flask_login import login_required, current_user
from flask_mail import Mail
from models import db, Vehicle, ServiceRecord, FuelRecord, Reminder, Todo, User, RecurringExpense
from auth import auth_bp, login_manager
from config import Config
import os
from datetime import datetime, timedelta
import pandas as pd
from werkzeug.utils import secure_filename
import io
import shutil
import zipfile
import tempfile
import secrets
import re
app = Flask(__name__, static_folder='../frontend/static', template_folder='../frontend/templates')
app.config.from_object(Config)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
app.config['SESSION_COOKIE_SECURE'] = False
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
CORS(app, supports_credentials=True, resources={r"/api/*": {"origins": "*"}})
db.init_app(app)
login_manager.init_app(app)
mail = Mail(app)
app.register_blueprint(auth_bp)
ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'csv'}
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
ALLOWED_ATTACHMENT_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'txt', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'odt', 'ods'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def allowed_image(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
def allowed_attachment(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_ATTACHMENT_EXTENSIONS
def validate_email(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
@app.before_request
def check_session():
session.permanent = True
if 'csrf_token' not in session:
session['csrf_token'] = secrets.token_hex(16)
@app.route('/')
def index():
return send_from_directory(app.template_folder, 'index.html')
@app.route('/login')
def login_page():
return send_from_directory(app.template_folder, 'login.html')
@app.route('/register')
def register_page():
return send_from_directory(app.template_folder, 'register.html')
@app.route('/first-login')
@login_required
def first_login_page():
if not current_user.must_change_credentials:
return redirect('/dashboard')
return send_from_directory(app.template_folder, 'first_login.html')
@app.route('/dashboard')
@login_required
def dashboard():
return send_from_directory(app.template_folder, 'dashboard.html')
@app.route('/settings')
@login_required
def settings_page():
return send_from_directory(app.template_folder, 'settings.html')
@app.route('/vehicles')
@login_required
def vehicles_page():
return send_from_directory(app.template_folder, 'vehicles.html')
@app.route('/vehicle-detail')
@login_required
def vehicle_detail_page():
return send_from_directory(app.template_folder, 'vehicle_detail.html')
@app.route('/service-records')
@login_required
def service_records_page():
return send_from_directory(app.template_folder, 'service_records.html')
@app.route('/repairs')
@login_required
def repairs_page():
return send_from_directory(app.template_folder, 'repairs.html')
@app.route('/fuel')
@login_required
def fuel_page():
return send_from_directory(app.template_folder, 'fuel.html')
@app.route('/taxes')
@login_required
def taxes_page():
return send_from_directory(app.template_folder, 'taxes.html')
@app.route('/notes')
@login_required
def notes_page():
return send_from_directory(app.template_folder, 'notes.html')
@app.route('/reminders')
@login_required
def reminders_page():
return send_from_directory(app.template_folder, 'reminders.html')
@app.route('/planner')
@login_required
def planner_page():
return send_from_directory(app.template_folder, 'planner.html')
@app.route('/uploads/<path:filename>')
def serve_upload(filename):
return send_from_directory('/app/uploads', filename)
@app.route('/api/settings', methods=['GET'])
@login_required
def get_settings():
return jsonify({
'username': current_user.username,
'email': current_user.email,
'language': current_user.language,
'unit_system': current_user.unit_system,
'currency': current_user.currency,
'theme': current_user.theme,
'photo': current_user.photo
}), 200
@app.route('/api/settings/language', methods=['POST'])
@login_required
def update_language():
data = request.get_json()
current_user.language = data.get('language', 'en')
db.session.commit()
return jsonify({'message': 'Language updated successfully'}), 200
@app.route('/api/settings/units', methods=['POST'])
@login_required
def update_units():
data = request.get_json()
current_user.unit_system = data.get('unit_system', 'metric')
current_user.currency = data.get('currency', 'USD')
db.session.commit()
return jsonify({'message': 'Units and currency updated successfully'}), 200
@app.route('/api/settings/theme', methods=['POST'])
@login_required
def update_theme():
data = request.get_json()
current_user.theme = data['theme']
db.session.commit()
return jsonify({'message': 'Theme updated successfully'}), 200
@app.route('/api/user/update-profile', methods=['POST'])
@login_required
def update_profile():
data = request.get_json()
if 'username' in data and data['username'] != current_user.username:
existing = User.query.filter_by(username=data['username']).first()
if existing:
return jsonify({'error': 'Username already taken'}), 400
current_user.username = data['username']
if 'email' in data and data['email'] != current_user.email:
if not validate_email(data['email']):
return jsonify({'error': 'Invalid email format'}), 400
existing = User.query.filter_by(email=data['email']).first()
if existing:
return jsonify({'error': 'Email already registered'}), 400
current_user.email = data['email']
if 'photo' in data:
current_user.photo = data['photo']
db.session.commit()
return jsonify({'message': 'Profile updated successfully'}), 200
@app.route('/api/backup/create', methods=['GET'])
@login_required
def create_backup():
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_filename = f'masina_dock_backup_{timestamp}.zip'
temp_dir = tempfile.mkdtemp()
backup_path = os.path.join(temp_dir, backup_filename)
try:
with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
db_path = '/app/data/masina_dock.db'
if os.path.exists(db_path):
zipf.write(db_path, 'masina_dock.db')
uploads_dir = '/app/uploads'
if os.path.exists(uploads_dir):
for root, dirs, files in os.walk(uploads_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, '/app')
zipf.write(file_path, arcname)
return send_file(
backup_path,
mimetype='application/zip',
as_attachment=True,
download_name=backup_filename
)
except Exception as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': str(e)}), 500
@app.route('/api/backup/restore', methods=['POST'])
@login_required
def restore_backup():
if 'backup' not in request.files:
return jsonify({'error': 'No backup file provided'}), 400
file = request.files['backup']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not file.filename.endswith('.zip'):
return jsonify({'error': 'Invalid file type. Please upload a .zip file.'}), 400
temp_dir = tempfile.mkdtemp()
backup_path = os.path.join(temp_dir, secure_filename(file.filename))
try:
file.save(backup_path)
with zipfile.ZipFile(backup_path, 'r') as zipf:
zipf.extractall(temp_dir)
extracted_db = os.path.join(temp_dir, 'masina_dock.db')
if os.path.exists(extracted_db):
target_db = '/app/data/masina_dock.db'
backup_db = '/app/data/masina_dock.db.backup'
if os.path.exists(target_db):
shutil.copy2(target_db, backup_db)
os.remove(target_db)
shutil.copy2(extracted_db, target_db)
else:
raise Exception('Database file not found in backup')
extracted_uploads = os.path.join(temp_dir, 'uploads')
if os.path.exists(extracted_uploads):
target_uploads = '/app/uploads'
for item in os.listdir(target_uploads):
item_path = os.path.join(target_uploads, item)
try:
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
except Exception as e:
print(f"Error removing {item_path}: {e}")
for root, dirs, files in os.walk(extracted_uploads):
rel_path = os.path.relpath(root, extracted_uploads)
target_path = os.path.join(target_uploads, rel_path) if rel_path != '.' else target_uploads
os.makedirs(target_path, exist_ok=True)
for file in files:
src_file = os.path.join(root, file)
dst_file = os.path.join(target_path, file)
shutil.copy2(src_file, dst_file)
os.chmod(dst_file, 0o644)
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'message': 'Backup restored successfully'}), 200
except zipfile.BadZipFile:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': 'Invalid or corrupted backup file'}), 400
except Exception as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return jsonify({'error': f'Restore failed: {str(e)}'}), 500
@app.route('/api/vehicles', methods=['GET', 'POST'])
@login_required
def vehicles():
if request.method == 'GET':
vehicles = Vehicle.query.filter_by(user_id=current_user.id).all()
return jsonify([{
'id': v.id,
'year': v.year,
'make': v.make,
'model': v.model,
'vin': v.vin,
'license_plate': v.license_plate,
'odometer': v.odometer,
'photo': v.photo,
'status': v.status
} for v in vehicles]), 200
elif request.method == 'POST':
data = request.get_json()
vehicle = Vehicle(
user_id=current_user.id,
year=data['year'],
make=data['make'],
model=data['model'],
vin=data.get('vin'),
license_plate=data.get('license_plate'),
odometer=data.get('odometer', 0),
photo=data.get('photo')
)
db.session.add(vehicle)
db.session.commit()
return jsonify({'id': vehicle.id, 'message': 'Vehicle added successfully'}), 201
@app.route('/api/upload/photo', methods=['POST'])
@login_required
def upload_photo():
if 'photo' not in request.files:
return jsonify({'error': 'No photo provided'}), 400
file = request.files['photo']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file and allowed_image(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_suffix = secrets.token_hex(4)
filename = f"{timestamp}_{random_suffix}_{filename}"
uploads_dir = '/app/uploads'
os.makedirs(uploads_dir, exist_ok=True)
filepath = os.path.join(uploads_dir, filename)
file.save(filepath)
os.chmod(filepath, 0o644)
return jsonify({'photo_url': f'/uploads/{filename}'}), 200
return jsonify({'error': 'Invalid file type. Only images are allowed.'}), 400
@app.route('/api/upload/attachment', methods=['POST'])
@login_required
def upload_attachment():
if 'attachment' not in request.files:
return jsonify({'error': 'No attachment provided'}), 400
file = request.files['attachment']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file and allowed_attachment(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_suffix = secrets.token_hex(4)
filename = f"{timestamp}_{random_suffix}_{filename}"
attachments_dir = '/app/uploads/attachments'
os.makedirs(attachments_dir, exist_ok=True)
filepath = os.path.join(attachments_dir, filename)
file.save(filepath)
os.chmod(filepath, 0o644)
relative_path = f'attachments/{filename}'
return jsonify({'file_path': relative_path}), 200
return jsonify({'error': 'Invalid file type'}), 400
@app.route('/api/attachments/download', methods=['GET'])
@login_required
def download_attachment():
file_path = request.args.get('path')
if not file_path:
return jsonify({'error': 'No file path provided'}), 400
full_path = os.path.join('/app/uploads', file_path)
if not os.path.exists(full_path):
return jsonify({'error': 'File not found'}), 404
if not full_path.startswith('/app/uploads'):
return jsonify({'error': 'Invalid file path'}), 403
directory = os.path.dirname(full_path)
filename = os.path.basename(full_path)
return send_from_directory(directory, filename, as_attachment=True)
@app.route('/api/vehicles/<int:vehicle_id>', methods=['GET', 'PUT', 'DELETE'])
@login_required
def vehicle_detail(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
return jsonify({
'id': vehicle.id,
'year': vehicle.year,
'make': vehicle.make,
'model': vehicle.model,
'vin': vehicle.vin,
'license_plate': vehicle.license_plate,
'odometer': vehicle.odometer,
'photo': vehicle.photo,
'status': vehicle.status
}), 200
elif request.method == 'PUT':
data = request.get_json()
vehicle.year = data.get('year', vehicle.year)
vehicle.make = data.get('make', vehicle.make)
vehicle.model = data.get('model', vehicle.model)
vehicle.vin = data.get('vin', vehicle.vin)
vehicle.license_plate = data.get('license_plate', vehicle.license_plate)
vehicle.odometer = data.get('odometer', vehicle.odometer)
vehicle.status = data.get('status', vehicle.status)
vehicle.photo = data.get('photo', vehicle.photo)
db.session.commit()
return jsonify({'message': 'Vehicle updated successfully'}), 200
elif request.method == 'DELETE':
db.session.delete(vehicle)
db.session.commit()
return jsonify({'message': 'Vehicle deleted successfully'}), 200
@app.route('/api/vehicles/<int:vehicle_id>/service-records', methods=['GET', 'POST'])
@login_required
def service_records(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).order_by(ServiceRecord.date.desc()).all()
return jsonify([{
'id': r.id,
'date': r.date.isoformat(),
'odometer': r.odometer,
'description': r.description,
'cost': r.cost,
'notes': r.notes,
'category': r.category,
'document_path': r.document_path
} for r in records]), 200
elif request.method == 'POST':
data = request.get_json()
record = ServiceRecord(
vehicle_id=vehicle_id,
date=datetime.fromisoformat(data['date']),
odometer=data['odometer'],
description=data['description'],
cost=data.get('cost', 0.0),
notes=data.get('notes'),
category=data.get('category'),
document_path=data.get('document_path')
)
db.session.add(record)
db.session.commit()
return jsonify({'id': record.id, 'message': 'Service record added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/fuel-records', methods=['GET', 'POST'])
@login_required
def fuel_records(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.date.desc()).all()
return jsonify([{
'id': r.id,
'date': r.date.isoformat(),
'odometer': r.odometer,
'fuel_amount': r.fuel_amount,
'cost': r.cost,
'unit_cost': r.unit_cost,
'distance': r.distance,
'fuel_economy': r.fuel_economy,
'unit': r.unit,
'notes': r.notes
} for r in records]), 200
elif request.method == 'POST':
data = request.get_json()
last_record = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.odometer.desc()).first()
distance = data['odometer'] - last_record.odometer if last_record else 0
fuel_economy = None
if distance > 0 and data['fuel_amount'] > 0:
if data.get('unit', 'MPG') == 'MPG':
fuel_economy = distance / data['fuel_amount']
elif data.get('unit') == 'L/100KM':
fuel_economy = (data['fuel_amount'] / distance) * 100
elif data.get('unit') == 'KM/L':
fuel_economy = distance / data['fuel_amount']
record = FuelRecord(
vehicle_id=vehicle_id,
date=datetime.fromisoformat(data['date']),
odometer=data['odometer'],
fuel_amount=data['fuel_amount'],
cost=data.get('cost', 0.0),
unit_cost=data.get('unit_cost'),
distance=distance,
fuel_economy=fuel_economy,
unit=data.get('unit', 'MPG'),
notes=data.get('notes')
)
db.session.add(record)
db.session.commit()
return jsonify({'id': record.id, 'message': 'Fuel record added successfully', 'fuel_economy': fuel_economy}), 201
@app.route('/api/vehicles/<int:vehicle_id>/reminders', methods=['GET', 'POST'])
@login_required
def reminders(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
reminders = Reminder.query.filter_by(vehicle_id=vehicle_id, completed=False).all()
return jsonify([{
'id': r.id,
'description': r.description,
'urgency': r.urgency,
'due_date': r.due_date.isoformat() if r.due_date else None,
'due_odometer': r.due_odometer,
'metric': r.metric,
'recurring': r.recurring,
'interval_type': r.interval_type,
'interval_value': r.interval_value,
'notes': r.notes
} for r in reminders]), 200
elif request.method == 'POST':
data = request.get_json()
reminder = Reminder(
vehicle_id=vehicle_id,
description=data['description'],
urgency=data.get('urgency', 'not_urgent'),
due_date=datetime.fromisoformat(data['due_date']) if data.get('due_date') else None,
due_odometer=data.get('due_odometer'),
metric=data.get('metric'),
recurring=data.get('recurring', False),
interval_type=data.get('interval_type'),
interval_value=data.get('interval_value'),
notes=data.get('notes')
)
db.session.add(reminder)
db.session.commit()
return jsonify({'id': reminder.id, 'message': 'Reminder added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/todos', methods=['GET', 'POST'])
@login_required
def todos(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
todos = Todo.query.filter_by(vehicle_id=vehicle_id).all()
return jsonify([{
'id': t.id,
'description': t.description,
'cost': t.cost,
'priority': t.priority,
'status': t.status,
'type': t.type,
'notes': t.notes
} for t in todos]), 200
elif request.method == 'POST':
data = request.get_json()
todo = Todo(
vehicle_id=vehicle_id,
description=data['description'],
cost=data.get('cost', 0.0),
priority=data.get('priority', 'medium'),
status=data.get('status', 'planned'),
type=data.get('type'),
notes=data.get('notes')
)
db.session.add(todo)
db.session.commit()
return jsonify({'id': todo.id, 'message': 'Todo added successfully'}), 201
@app.route('/api/vehicles/<int:vehicle_id>/recurring-expenses', methods=['GET', 'POST'])
@login_required
def recurring_expenses(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
if request.method == 'GET':
expenses = RecurringExpense.query.filter_by(vehicle_id=vehicle_id, is_active=True).all()
return jsonify([{
'id': e.id,
'expense_type': e.expense_type,
'description': e.description,
'amount': e.amount,
'frequency': e.frequency,
'start_date': e.start_date.isoformat(),
'next_due_date': e.next_due_date.isoformat(),
'is_active': e.is_active,
'notes': e.notes
} for e in expenses]), 200
elif request.method == 'POST':
data = request.get_json()
start_date = datetime.fromisoformat(data['start_date']).date()
frequency = data['frequency']
if frequency == 'monthly':
next_due = start_date + timedelta(days=30)
elif frequency == 'quarterly':
next_due = start_date + timedelta(days=90)
elif frequency == 'yearly':
next_due = start_date + timedelta(days=365)
else:
next_due = start_date
expense = RecurringExpense(
vehicle_id=vehicle_id,
expense_type=data['expense_type'],
description=data['description'],
amount=data['amount'],
frequency=frequency,
start_date=start_date,
next_due_date=next_due,
notes=data.get('notes')
)
db.session.add(expense)
db.session.commit()
return jsonify({
'id': expense.id,
'message': 'Recurring expense added successfully',
'next_due_date': next_due.isoformat()
}), 201
@app.route('/api/vehicles/<int:vehicle_id>/recurring-expenses/<int:expense_id>', methods=['DELETE'])
@login_required
def cancel_recurring_expense(vehicle_id, expense_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
expense = RecurringExpense.query.filter_by(id=expense_id, vehicle_id=vehicle_id).first_or_404()
expense.is_active = False
db.session.commit()
return jsonify({'message': 'Recurring expense cancelled successfully'}), 200
@app.route('/api/vehicles/<int:vehicle_id>/export-all', methods=['GET'])
@login_required
def export_all_vehicle_data(vehicle_id):
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
service_records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).order_by(ServiceRecord.date.desc()).all()
fuel_records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.date.desc()).all()
reminders = Reminder.query.filter_by(vehicle_id=vehicle_id).all()
todos = Todo.query.filter_by(vehicle_id=vehicle_id).all()
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
vehicle_data = pd.DataFrame([{
'Year': vehicle.year,
'Make': vehicle.make,
'Model': vehicle.model,
'VIN': vehicle.vin or '',
'License Plate': vehicle.license_plate or '',
'Current Odometer': vehicle.odometer,
'Status': vehicle.status,
'Photo URL': vehicle.photo or ''
}])
vehicle_data.to_excel(writer, sheet_name='Vehicle Info', index=False)
if service_records:
service_df = pd.DataFrame([{
'Date': r.date.strftime('%Y-%m-%d'),
'Odometer': r.odometer,
'Description': r.description,
'Category': r.category or '',
'Cost': r.cost,
'Notes': r.notes or '',
'Document': r.document_path or ''
} for r in service_records])
service_df.to_excel(writer, sheet_name='Service Records', index=False)
if fuel_records:
fuel_df = pd.DataFrame([{
'Date': r.date.strftime('%Y-%m-%d'),
'Odometer': r.odometer,
'Fuel Amount': r.fuel_amount,
'Unit': r.unit,
'Cost': r.cost,
'Unit Cost': r.unit_cost or 0,
'Distance': r.distance or 0,
'Fuel Economy': r.fuel_economy or 0,
'Notes': r.notes or ''
} for r in fuel_records])
fuel_df.to_excel(writer, sheet_name='Fuel Records', index=False)
if reminders:
reminders_df = pd.DataFrame([{
'Description': r.description,
'Urgency': r.urgency,
'Due Date': r.due_date.strftime('%Y-%m-%d') if r.due_date else '',
'Due Odometer': r.due_odometer or '',
'Recurring': r.recurring,
'Interval Type': r.interval_type or '',
'Interval Value': r.interval_value or '',
'Notes': r.notes or '',
'Completed': r.completed
} for r in reminders])
reminders_df.to_excel(writer, sheet_name='Reminders', index=False)
if todos:
todos_df = pd.DataFrame([{
'Description': t.description,
'Type': t.type or '',
'Priority': t.priority,
'Status': t.status,
'Cost': t.cost,
'Notes': t.notes or ''
} for t in todos])
todos_df.to_excel(writer, sheet_name='Todos', index=False)
output.seek(0)
filename = f"{vehicle.year}_{vehicle.make}_{vehicle.model}_Complete_Data_{datetime.now().strftime('%Y%m%d')}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
@app.route('/api/export/<data_type>', methods=['GET'])
@login_required
def export_data(data_type):
vehicle_id = request.args.get('vehicle_id')
if data_type == 'service_records':
records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).all()
df = pd.DataFrame([{
'Date': r.date,
'Odometer': r.odometer,
'Description': r.description,
'Cost': r.cost,
'Category': r.category,
'Notes': r.notes
} for r in records])
elif data_type == 'fuel_records':
records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).all()
df = pd.DataFrame([{
'Date': r.date,
'Odometer': r.odometer,
'Fuel Amount': r.fuel_amount,
'Cost': r.cost,
'Fuel Economy': r.fuel_economy,
'Unit': r.unit
} for r in records])
else:
return jsonify({'error': 'Invalid data type'}), 400
output_file = f'/tmp/{data_type}_{vehicle_id}.csv'
df.to_csv(output_file, index=False)
return send_from_directory('/tmp', f'{data_type}_{vehicle_id}.csv', as_attachment=True)
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(host='0.0.0.0', port=5000, debug=False)

389
backend/auth.py Normal file
View file

@ -0,0 +1,389 @@
from flask import Blueprint, request, jsonify, current_app
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from models import db, User
from werkzeug.security import check_password_hash
import pyotp
import qrcode
import io
import base64
import re
from datetime import datetime
auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')
login_manager = LoginManager()
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
@login_manager.unauthorized_handler
def unauthorized():
return jsonify({'error': 'Unauthorized access'}), 401
def validate_email(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def validate_password(password):
if len(password) < 8:
return False, "Password must be at least 8 characters long"
if not re.search(r'[A-Z]', password):
return False, "Password must contain at least one uppercase letter"
if not re.search(r'[a-z]', password):
return False, "Password must contain at least one lowercase letter"
if not re.search(r'[0-9]', password):
return False, "Password must contain at least one number"
return True, "Valid"
def send_verification_email(user, token):
if not current_app.config.get('ENABLE_EMAIL_VERIFICATION'):
return
if not current_app.config.get('MAIL_USERNAME'):
return
try:
from flask_mail import Message, Mail
mail = Mail(current_app)
msg = Message(
'Verify your email for Masina-Dock',
recipients=[user.email]
)
msg.body = f'''Hello {user.username},
Please verify your email address by clicking the link below:
http://localhost:5000/verify-email?token={token}
If you did not create an account, please ignore this email.
Best regards,
Masina-Dock Team
'''
mail.send(msg)
except Exception as e:
print(f"Failed to send email: {e}")
@auth_bp.route('/register', methods=['POST'])
def register():
try:
if current_app.config.get('DISABLE_SIGNUPS'):
return jsonify({'error': 'Registrations are currently disabled'}), 403
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
if not data.get('username') or not data.get('email') or not data.get('password'):
return jsonify({'error': 'Missing required fields'}), 400
if User.query.filter_by(username=data['username']).first():
return jsonify({'error': 'Username already exists'}), 400
if User.query.filter_by(email=data['email']).first():
return jsonify({'error': 'Email already registered'}), 400
if not validate_email(data['email']):
return jsonify({'error': 'Invalid email format'}), 400
is_valid, message = validate_password(data['password'])
if not is_valid:
return jsonify({'error': message}), 400
is_first_user = User.query.count() == 0
user = User(
username=data['username'],
email=data['email'],
is_admin=is_first_user,
language='en',
unit_system='metric',
currency='USD',
email_verified=not current_app.config.get('ENABLE_EMAIL_VERIFICATION', False)
)
user.set_password(data['password'])
if current_app.config.get('ENABLE_EMAIL_VERIFICATION', False):
token = user.generate_email_verification_token()
send_verification_email(user, token)
db.session.add(user)
db.session.commit()
message = 'User registered successfully'
if current_app.config.get('ENABLE_EMAIL_VERIFICATION', False):
message += '. Please check your email to verify your account.'
return jsonify({'message': message}), 201
except Exception as e:
print(f"Registration error: {e}")
db.session.rollback()
return jsonify({'error': f'Registration failed: {str(e)}'}), 500
@auth_bp.route('/verify-email', methods=['POST'])
def verify_email():
data = request.get_json()
token = data.get('token')
if not token:
return jsonify({'error': 'Verification token required'}), 400
user = User.query.filter_by(email_verification_token=token).first()
if not user:
return jsonify({'error': 'Invalid or expired verification token'}), 400
if user.verify_email(token):
db.session.commit()
return jsonify({'message': 'Email verified successfully'}), 200
return jsonify({'error': 'Verification failed'}), 400
@auth_bp.route('/login', methods=['POST'])
def login():
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
if not data.get('username') or not data.get('password'):
return jsonify({'error': 'Missing username or password'}), 400
user = User.query.filter_by(username=data['username']).first()
if not user or not user.check_password(data['password']):
return jsonify({'error': 'Invalid username or password'}), 401
if current_app.config.get('ENABLE_EMAIL_VERIFICATION', False) and not user.email_verified:
return jsonify({'error': 'Please verify your email before logging in'}), 403
if user.two_factor_enabled:
return jsonify({
'requires_2fa': True,
'user_id': user.id
}), 200
login_user(user, remember=True)
user.last_login = datetime.utcnow()
db.session.commit()
return jsonify({
'message': 'Login successful',
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'theme': user.theme,
'first_login': user.first_login,
'must_change_credentials': user.must_change_credentials,
'language': user.language,
'unit_system': user.unit_system,
'currency': user.currency,
'photo': user.photo
}
}), 200
except Exception as e:
print(f"Login error: {e}")
return jsonify({'error': f'Login failed: {str(e)}'}), 500
@auth_bp.route('/verify-2fa', methods=['POST'])
def verify_2fa():
data = request.get_json()
user_id = data.get('user_id')
code = data.get('code')
if not user_id or not code:
return jsonify({'error': 'Missing required fields'}), 400
user = User.query.get(user_id)
if not user or not user.two_factor_enabled:
return jsonify({'error': 'Invalid request'}), 400
totp = pyotp.TOTP(user.two_factor_secret)
if totp.verify(code) or user.verify_backup_code(code):
login_user(user, remember=True)
user.last_login = datetime.utcnow()
db.session.commit()
return jsonify({
'message': 'Login successful',
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'theme': user.theme,
'first_login': user.first_login,
'must_change_credentials': user.must_change_credentials,
'language': user.language,
'unit_system': user.unit_system,
'currency': user.currency,
'photo': user.photo
}
}), 200
return jsonify({'error': 'Invalid 2FA code'}), 401
@auth_bp.route('/setup-2fa', methods=['POST'])
@login_required
def setup_2fa():
if current_user.two_factor_enabled:
return jsonify({'error': '2FA is already enabled'}), 400
secret = pyotp.random_base32()
current_user.two_factor_secret = secret
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(
name=current_user.email,
issuer_name='Masina-Dock'
)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(provisioning_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format='PNG')
qr_code = base64.b64encode(buffer.getvalue()).decode()
backup_codes = current_user.generate_backup_codes()
db.session.commit()
return jsonify({
'secret': secret,
'qr_code': f'data:image/png;base64,{qr_code}',
'backup_codes': backup_codes
}), 200
@auth_bp.route('/enable-2fa', methods=['POST'])
@login_required
def enable_2fa():
data = request.get_json()
code = data.get('code')
if not code:
return jsonify({'error': 'Verification code required'}), 400
if not current_user.two_factor_secret:
return jsonify({'error': 'Please setup 2FA first'}), 400
totp = pyotp.TOTP(current_user.two_factor_secret)
if totp.verify(code):
current_user.two_factor_enabled = True
db.session.commit()
return jsonify({'message': '2FA enabled successfully'}), 200
return jsonify({'error': 'Invalid verification code'}), 400
@auth_bp.route('/disable-2fa', methods=['POST'])
@login_required
def disable_2fa():
data = request.get_json()
password = data.get('password')
if not password:
return jsonify({'error': 'Password required'}), 400
if not current_user.check_password(password):
return jsonify({'error': 'Invalid password'}), 401
current_user.two_factor_enabled = False
current_user.two_factor_secret = None
current_user.backup_codes = None
db.session.commit()
return jsonify({'message': '2FA disabled successfully'}), 200
@auth_bp.route('/logout', methods=['POST'])
@login_required
def logout():
logout_user()
return jsonify({'message': 'Logout successful'}), 200
@auth_bp.route('/me', methods=['GET'])
@login_required
def get_current_user():
return jsonify({
'id': current_user.id,
'username': current_user.username,
'email': current_user.email,
'theme': current_user.theme,
'language': current_user.language,
'unit_system': current_user.unit_system,
'currency': current_user.currency,
'photo': current_user.photo,
'must_change_credentials': current_user.must_change_credentials,
'email_verified': current_user.email_verified,
'two_factor_enabled': current_user.two_factor_enabled
}), 200
@auth_bp.route('/update-credentials', methods=['POST'])
@login_required
def update_credentials():
data = request.get_json()
new_username = data.get('username')
new_email = data.get('email')
new_password = data.get('password')
if not new_username or not new_email or not new_password:
return jsonify({'error': 'Missing required fields'}), 400
if new_username != current_user.username:
existing_user = User.query.filter_by(username=new_username).first()
if existing_user:
return jsonify({'error': 'Username already taken'}), 400
current_user.username = new_username
if new_email != current_user.email:
if not validate_email(new_email):
return jsonify({'error': 'Invalid email format'}), 400
existing_email = User.query.filter_by(email=new_email).first()
if existing_email:
return jsonify({'error': 'Email already registered'}), 400
current_user.email = new_email
if current_app.config.get('ENABLE_EMAIL_VERIFICATION', False):
current_user.email_verified = False
token = current_user.generate_email_verification_token()
send_verification_email(current_user, token)
is_valid, message = validate_password(new_password)
if not is_valid:
return jsonify({'error': message}), 400
current_user.set_password(new_password)
current_user.must_change_credentials = False
current_user.first_login = False
db.session.commit()
return jsonify({'message': 'Credentials updated successfully'}), 200
@auth_bp.route('/change-password', methods=['POST'])
@login_required
def change_password():
data = request.get_json()
if not current_user.check_password(data['current_password']):
return jsonify({'error': 'Current password is incorrect'}), 400
is_valid, message = validate_password(data['new_password'])
if not is_valid:
return jsonify({'error': message}), 400
current_user.set_password(data['new_password'])
current_user.first_login = False
db.session.commit()
return jsonify({'message': 'Password changed successfully'}), 200

24
backend/config.py Normal file
View file

@ -0,0 +1,24 @@
import os
from datetime import timedelta
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(32).hex()
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or f'sqlite:///{os.environ.get("DATABASE_PATH", "/app/data/masina_dock.db")}'
SQLALCHEMY_TRACK_MODIFICATIONS = False
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
PERMANENT_SESSION_LIFETIME = timedelta(hours=12)
MAX_CONTENT_LENGTH = 16 * 1024 * 1024
DISABLE_SIGNUPS = os.environ.get('DISABLE_SIGNUPS', 'False').lower() == 'true'
MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
MAIL_PORT = int(os.environ.get('MAIL_PORT', 587))
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'True').lower() == 'true'
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@masinadock.local')
ENABLE_EMAIL_VERIFICATION = os.environ.get('ENABLE_EMAIL_VERIFICATION', 'False').lower() == 'true'
ENABLE_2FA = os.environ.get('ENABLE_2FA', 'True').lower() == 'true'

27
backend/entrypoint.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/bash
set -e
echo "Starting Masina-Dock initialization..."
# Create directories
mkdir -p /app/data /app/uploads/attachments
# Set permissions so database can be written
chmod -R 777 /app/data /app/uploads
# Initialize database
echo "Initializing database..."
cd /app/backend
python init_db.py
# Set database file permissions
if [ -f /app/data/masina_dock.db ]; then
chmod 666 /app/data/masina_dock.db
echo "Database initialized successfully"
else
echo "Warning: Database file not found after initialization"
fi
# Start application
echo "Starting Gunicorn server..."
exec gunicorn --reload --bind 0.0.0.0:5000 --workers 4 --timeout 120 --access-logfile - --error-logfile - app:app

82
backend/init_db.py Normal file
View file

@ -0,0 +1,82 @@
from app import app
from models import db, User
import os
import sqlite3
def migrate_database():
db_path = '/app/data/masina_dock.db'
if os.path.exists(db_path):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("PRAGMA table_info(user)")
columns = [column[1] for column in cursor.fetchall()]
if 'language' not in columns:
print("Adding language column...")
cursor.execute("ALTER TABLE user ADD COLUMN language VARCHAR(5) DEFAULT 'en'")
if 'unit_system' not in columns:
print("Adding unit_system column...")
cursor.execute("ALTER TABLE user ADD COLUMN unit_system VARCHAR(20) DEFAULT 'imperial'")
if 'currency' not in columns:
print("Adding currency column...")
cursor.execute("ALTER TABLE user ADD COLUMN currency VARCHAR(10) DEFAULT 'GBP'")
if 'photo' not in columns:
print("Adding photo column...")
cursor.execute("ALTER TABLE user ADD COLUMN photo VARCHAR(255)")
if 'must_change_credentials' not in columns:
print("Adding must_change_credentials column...")
cursor.execute("ALTER TABLE user ADD COLUMN must_change_credentials BOOLEAN DEFAULT 0")
if 'email_verified' not in columns:
print("Adding email_verified column...")
cursor.execute("ALTER TABLE user ADD COLUMN email_verified BOOLEAN DEFAULT 1")
if 'email_verification_token' not in columns:
print("Adding email_verification_token column...")
cursor.execute("ALTER TABLE user ADD COLUMN email_verification_token VARCHAR(100)")
if 'email_verification_sent_at' not in columns:
print("Adding email_verification_sent_at column...")
cursor.execute("ALTER TABLE user ADD COLUMN email_verification_sent_at DATETIME")
if 'two_factor_enabled' not in columns:
print("Adding two_factor_enabled column...")
cursor.execute("ALTER TABLE user ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0")
if 'two_factor_secret' not in columns:
print("Adding two_factor_secret column...")
cursor.execute("ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)")
if 'backup_codes' not in columns:
print("Adding backup_codes column...")
cursor.execute("ALTER TABLE user ADD COLUMN backup_codes TEXT")
if 'last_login' not in columns:
print("Adding last_login column...")
cursor.execute("ALTER TABLE user ADD COLUMN last_login DATETIME")
conn.commit()
print("Database migration completed!")
except Exception as e:
print(f"Migration error: {e}")
conn.rollback()
finally:
conn.close()
def init_database():
with app.app_context():
migrate_database()
db.create_all()
print("Database initialized successfully!")
print("Please register your admin account at http://localhost:5000/register")
if __name__ == '__main__':
init_database()

40
backend/migrate_db.py Normal file
View file

@ -0,0 +1,40 @@
import sqlite3
import os
def migrate_database():
db_path = '/app/data/masina_dock.db'
if not os.path.exists(db_path):
print("Database does not exist. Will be created on first run.")
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("PRAGMA table_info(user)")
columns = [column[1] for column in cursor.fetchall()]
if 'language' not in columns:
print("Adding language column...")
cursor.execute("ALTER TABLE user ADD COLUMN language VARCHAR(5) DEFAULT 'en'")
if 'unit_system' not in columns:
print("Adding unit_system column...")
cursor.execute("ALTER TABLE user ADD COLUMN unit_system VARCHAR(20) DEFAULT 'metric'")
if 'currency' not in columns:
print("Adding currency column...")
cursor.execute("ALTER TABLE user ADD COLUMN currency VARCHAR(10) DEFAULT 'USD'")
conn.commit()
print("Database migration completed successfully!")
except Exception as e:
print(f"Migration error: {e}")
conn.rollback()
finally:
conn.close()
if __name__ == '__main__':
migrate_database()

153
backend/models.py Normal file
View file

@ -0,0 +1,153 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
import secrets
db = SQLAlchemy()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
theme = db.Column(db.String(10), default='dark')
first_login = db.Column(db.Boolean, default=True)
must_change_credentials = db.Column(db.Boolean, default=False)
language = db.Column(db.String(5), default='en')
unit_system = db.Column(db.String(20), default='imperial')
currency = db.Column(db.String(10), default='GBP')
photo = db.Column(db.String(255))
email_verified = db.Column(db.Boolean, default=False)
email_verification_token = db.Column(db.String(100))
email_verification_sent_at = db.Column(db.DateTime)
two_factor_enabled = db.Column(db.Boolean, default=False)
two_factor_secret = db.Column(db.String(32))
backup_codes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
vehicles = db.relationship('Vehicle', backref='owner', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def generate_email_verification_token(self):
self.email_verification_token = secrets.token_urlsafe(32)
self.email_verification_sent_at = datetime.utcnow()
return self.email_verification_token
def verify_email(self, token):
if self.email_verification_token == token:
self.email_verified = True
self.email_verification_token = None
return True
return False
def generate_backup_codes(self):
codes = [secrets.token_hex(4).upper() for _ in range(10)]
self.backup_codes = ','.join([generate_password_hash(code) for code in codes])
return codes
def verify_backup_code(self, code):
if not self.backup_codes:
return False
codes = self.backup_codes.split(',')
for i, hashed_code in enumerate(codes):
if check_password_hash(hashed_code, code.upper()):
codes.pop(i)
self.backup_codes = ','.join(codes)
return True
return False
class Vehicle(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
year = db.Column(db.Integer, nullable=False)
make = db.Column(db.String(50), nullable=False)
model = db.Column(db.String(50), nullable=False)
vin = db.Column(db.String(17))
license_plate = db.Column(db.String(20))
odometer = db.Column(db.Integer, default=0)
photo = db.Column(db.String(255))
status = db.Column(db.String(20), default='active')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
service_records = db.relationship('ServiceRecord', backref='vehicle', lazy=True, cascade='all, delete-orphan')
fuel_records = db.relationship('FuelRecord', backref='vehicle', lazy=True, cascade='all, delete-orphan')
reminders = db.relationship('Reminder', backref='vehicle', lazy=True, cascade='all, delete-orphan')
todos = db.relationship('Todo', backref='vehicle', lazy=True, cascade='all, delete-orphan')
recurring_expenses = db.relationship('RecurringExpense', backref='vehicle', lazy=True, cascade='all, delete-orphan')
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))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
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)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Reminder(db.Model):
id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
description = db.Column(db.String(200), nullable=False)
urgency = db.Column(db.String(20), default='not_urgent')
due_date = db.Column(db.Date)
due_odometer = db.Column(db.Integer)
metric = db.Column(db.String(50))
recurring = db.Column(db.Boolean, default=False)
interval_type = db.Column(db.String(20))
interval_value = db.Column(db.Integer)
completed = db.Column(db.Boolean, default=False)
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Todo(db.Model):
id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
description = db.Column(db.String(200), nullable=False)
cost = db.Column(db.Float, default=0.0)
priority = db.Column(db.String(20), default='medium')
status = db.Column(db.String(20), default='planned')
type = db.Column(db.String(50))
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
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)

13
backend/requirements.txt Normal file
View file

@ -0,0 +1,13 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Flask-CORS==5.0.0
Flask-Mail==0.10.0
pandas==2.2.3
openpyxl==3.1.5
python-dotenv==1.0.1
pyotp==2.9.0
qrcode==8.0
Pillow==11.0.0
Werkzeug==3.0.6
gunicorn==23.0.0