Initial commit: Masina-Dock Vehicle Management System
This commit is contained in:
commit
ae923e2c41
4999 changed files with 1607266 additions and 0 deletions
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
899
backend/app.py
Normal file
899
backend/app.py
Normal 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'})
|
||||
799
backend/app.py.backup-before-edit
Normal file
799
backend/app.py.backup-before-edit
Normal 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
799
backend/app.py.backup-clean
Normal 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
389
backend/auth.py
Normal 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
24
backend/config.py
Normal 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
27
backend/entrypoint.sh
Executable 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
82
backend/init_db.py
Normal 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
40
backend/migrate_db.py
Normal 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
153
backend/models.py
Normal 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
13
backend/requirements.txt
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue