Initial commit
This commit is contained in:
commit
983cee0320
322 changed files with 57174 additions and 0 deletions
110
backup/fina-1/app/routes/admin.py
Normal file
110
backup/fina-1/app/routes/admin.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, bcrypt
|
||||
from app.models import User, Expense, Category
|
||||
from functools import wraps
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/api/admin')
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'message': 'Admin access required'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@bp.route('/users', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_users():
|
||||
users = User.query.all()
|
||||
return jsonify({
|
||||
'users': [{
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'is_admin': user.is_admin,
|
||||
'language': user.language,
|
||||
'currency': user.currency,
|
||||
'two_factor_enabled': user.two_factor_enabled,
|
||||
'created_at': user.created_at.isoformat()
|
||||
} for user in users]
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/users', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
data = request.get_json()
|
||||
|
||||
if not data.get('username') or not data.get('email') or not data.get('password'):
|
||||
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
|
||||
|
||||
# Check if user exists
|
||||
if User.query.filter_by(email=data['email']).first():
|
||||
return jsonify({'success': False, 'message': 'Email already exists'}), 400
|
||||
|
||||
if User.query.filter_by(username=data['username']).first():
|
||||
return jsonify({'success': False, 'message': 'Username already exists'}), 400
|
||||
|
||||
# Create user
|
||||
password_hash = bcrypt.generate_password_hash(data['password']).decode('utf-8')
|
||||
user = User(
|
||||
username=data['username'],
|
||||
email=data['email'],
|
||||
password_hash=password_hash,
|
||||
is_admin=data.get('is_admin', False),
|
||||
language=data.get('language', 'en'),
|
||||
currency=data.get('currency', 'USD')
|
||||
)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Create default categories
|
||||
from app.utils import create_default_categories
|
||||
create_default_categories(user.id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
}
|
||||
}), 201
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
if user_id == current_user.id:
|
||||
return jsonify({'success': False, 'message': 'Cannot delete yourself'}), 400
|
||||
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({'success': False, 'message': 'User not found'}), 404
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'User deleted'})
|
||||
|
||||
|
||||
@bp.route('/stats', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_stats():
|
||||
total_users = User.query.count()
|
||||
total_expenses = Expense.query.count()
|
||||
total_categories = Category.query.count()
|
||||
|
||||
return jsonify({
|
||||
'total_users': total_users,
|
||||
'total_expenses': total_expenses,
|
||||
'total_categories': total_categories
|
||||
})
|
||||
360
backup/fina-1/app/routes/auth.py
Normal file
360
backup/fina-1/app/routes/auth.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, send_file, make_response
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app import db, bcrypt
|
||||
from app.models import User
|
||||
import pyotp
|
||||
import qrcode
|
||||
import io
|
||||
import base64
|
||||
import secrets
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
def generate_backup_codes(count=10):
|
||||
"""Generate backup codes for 2FA"""
|
||||
codes = []
|
||||
for _ in range(count):
|
||||
# Generate 8-character alphanumeric code
|
||||
code = ''.join(secrets.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(8))
|
||||
# Format as XXXX-XXXX for readability
|
||||
formatted_code = f"{code[:4]}-{code[4:]}"
|
||||
codes.append(formatted_code)
|
||||
return codes
|
||||
|
||||
|
||||
def hash_backup_codes(codes):
|
||||
"""Hash backup codes for secure storage"""
|
||||
return [bcrypt.generate_password_hash(code).decode('utf-8') for code in codes]
|
||||
|
||||
|
||||
def verify_backup_code(user, code):
|
||||
"""Verify a backup code and mark it as used"""
|
||||
if not user.backup_codes:
|
||||
return False
|
||||
|
||||
stored_codes = json.loads(user.backup_codes)
|
||||
|
||||
for i, hashed_code in enumerate(stored_codes):
|
||||
if bcrypt.check_password_hash(hashed_code, code):
|
||||
# Remove used code
|
||||
stored_codes.pop(i)
|
||||
user.backup_codes = json.dumps(stored_codes)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
two_factor_code = data.get('two_factor_code')
|
||||
remember = data.get('remember', False)
|
||||
|
||||
# Accept both username and email
|
||||
user = User.query.filter((User.username == username) | (User.email == username)).first()
|
||||
|
||||
if user and bcrypt.check_password_hash(user.password_hash, password):
|
||||
# Check 2FA if enabled
|
||||
if user.two_factor_enabled:
|
||||
if not two_factor_code:
|
||||
if request.is_json:
|
||||
return {'success': False, 'requires_2fa': True}, 200
|
||||
session['pending_user_id'] = user.id
|
||||
return render_template('auth/two_factor.html')
|
||||
|
||||
# Try TOTP code first
|
||||
totp = pyotp.TOTP(user.totp_secret)
|
||||
is_valid = totp.verify(two_factor_code)
|
||||
|
||||
# If TOTP fails, try backup code (format: XXXX-XXXX or XXXXXXXX)
|
||||
if not is_valid:
|
||||
is_valid = verify_backup_code(user, two_factor_code)
|
||||
|
||||
if not is_valid:
|
||||
if request.is_json:
|
||||
return {'success': False, 'message': 'Invalid 2FA code'}, 401
|
||||
flash('Invalid 2FA code', 'error')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
login_user(user, remember=remember)
|
||||
session.permanent = remember
|
||||
|
||||
if request.is_json:
|
||||
return {'success': True, 'redirect': url_for('main.dashboard')}
|
||||
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page if next_page else url_for('main.dashboard'))
|
||||
|
||||
if request.is_json:
|
||||
return {'success': False, 'message': 'Invalid username or password'}, 401
|
||||
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
username = data.get('username')
|
||||
email = data.get('email')
|
||||
password = data.get('password')
|
||||
language = data.get('language', 'en')
|
||||
currency = data.get('currency', 'USD')
|
||||
|
||||
# Check if user exists
|
||||
if User.query.filter_by(email=email).first():
|
||||
if request.is_json:
|
||||
return {'success': False, 'message': 'Email already registered'}, 400
|
||||
flash('Email already registered', 'error')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
if request.is_json:
|
||||
return {'success': False, 'message': 'Username already taken'}, 400
|
||||
flash('Username already taken', 'error')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
# Check if this is the first user (make them admin)
|
||||
is_first_user = User.query.count() == 0
|
||||
|
||||
# Create user
|
||||
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
is_admin=is_first_user,
|
||||
language=language,
|
||||
currency=currency
|
||||
)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Create default categories
|
||||
from app.utils import create_default_categories
|
||||
create_default_categories(user.id)
|
||||
|
||||
login_user(user)
|
||||
|
||||
if request.is_json:
|
||||
return {'success': True, 'redirect': url_for('main.dashboard')}
|
||||
|
||||
flash('Registration successful!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('auth/register.html')
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@bp.route('/setup-2fa', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def setup_2fa():
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
code = data.get('code')
|
||||
|
||||
if not current_user.totp_secret:
|
||||
secret = pyotp.random_base32()
|
||||
current_user.totp_secret = secret
|
||||
|
||||
totp = pyotp.TOTP(current_user.totp_secret)
|
||||
|
||||
if totp.verify(code):
|
||||
# Generate backup codes
|
||||
backup_codes_plain = generate_backup_codes(10)
|
||||
backup_codes_hashed = hash_backup_codes(backup_codes_plain)
|
||||
|
||||
current_user.two_factor_enabled = True
|
||||
current_user.backup_codes = json.dumps(backup_codes_hashed)
|
||||
db.session.commit()
|
||||
|
||||
# Store plain backup codes in session for display
|
||||
session['backup_codes'] = backup_codes_plain
|
||||
|
||||
if request.is_json:
|
||||
return {'success': True, 'message': '2FA enabled successfully', 'backup_codes': backup_codes_plain}
|
||||
|
||||
flash('2FA enabled successfully', 'success')
|
||||
return redirect(url_for('auth.show_backup_codes'))
|
||||
|
||||
if request.is_json:
|
||||
return {'success': False, 'message': 'Invalid code'}, 400
|
||||
|
||||
flash('Invalid code', 'error')
|
||||
|
||||
# Generate QR code
|
||||
if not current_user.totp_secret:
|
||||
current_user.totp_secret = pyotp.random_base32()
|
||||
db.session.commit()
|
||||
|
||||
totp = pyotp.TOTP(current_user.totp_secret)
|
||||
provisioning_uri = totp.provisioning_uri(
|
||||
name=current_user.email,
|
||||
issuer_name='FINA'
|
||||
)
|
||||
|
||||
# Generate QR code image
|
||||
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")
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
qr_code_base64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
return render_template('auth/setup_2fa.html',
|
||||
qr_code=qr_code_base64,
|
||||
secret=current_user.totp_secret)
|
||||
|
||||
|
||||
@bp.route('/backup-codes', methods=['GET'])
|
||||
@login_required
|
||||
def show_backup_codes():
|
||||
"""Display backup codes after 2FA setup"""
|
||||
backup_codes = session.get('backup_codes', [])
|
||||
|
||||
if not backup_codes:
|
||||
flash('No backup codes available', 'error')
|
||||
return redirect(url_for('main.settings'))
|
||||
|
||||
return render_template('auth/backup_codes.html',
|
||||
backup_codes=backup_codes,
|
||||
username=current_user.username)
|
||||
|
||||
|
||||
@bp.route('/backup-codes/download', methods=['GET'])
|
||||
@login_required
|
||||
def download_backup_codes_pdf():
|
||||
"""Download backup codes as PDF"""
|
||||
backup_codes = session.get('backup_codes', [])
|
||||
|
||||
if not backup_codes:
|
||||
flash('No backup codes available', 'error')
|
||||
return redirect(url_for('main.settings'))
|
||||
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib import colors
|
||||
|
||||
# Create PDF in memory
|
||||
buffer = io.BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=letter)
|
||||
width, height = letter
|
||||
|
||||
# Title
|
||||
c.setFont("Helvetica-Bold", 24)
|
||||
c.drawCentredString(width/2, height - 1*inch, "FINA")
|
||||
|
||||
c.setFont("Helvetica-Bold", 18)
|
||||
c.drawCentredString(width/2, height - 1.5*inch, "Two-Factor Authentication")
|
||||
c.drawCentredString(width/2, height - 1.9*inch, "Backup Codes")
|
||||
|
||||
# User info
|
||||
c.setFont("Helvetica", 12)
|
||||
c.drawString(1*inch, height - 2.5*inch, f"User: {current_user.username}")
|
||||
c.drawString(1*inch, height - 2.8*inch, f"Email: {current_user.email}")
|
||||
c.drawString(1*inch, height - 3.1*inch, f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
|
||||
# Warning message
|
||||
c.setFillColorRGB(0.8, 0.2, 0.2)
|
||||
c.setFont("Helvetica-Bold", 11)
|
||||
c.drawString(1*inch, height - 3.7*inch, "IMPORTANT: Store these codes in a secure location!")
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
c.setFont("Helvetica", 10)
|
||||
c.drawString(1*inch, height - 4.0*inch, "Each code can only be used once. Use them if you lose access to your authenticator app.")
|
||||
|
||||
# Backup codes in two columns
|
||||
c.setFont("Courier-Bold", 14)
|
||||
y_position = height - 4.8*inch
|
||||
x_left = 1.5*inch
|
||||
x_right = 4.5*inch
|
||||
|
||||
for i, code in enumerate(backup_codes):
|
||||
if i % 2 == 0:
|
||||
c.drawString(x_left, y_position, f"{i+1:2d}. {code}")
|
||||
else:
|
||||
c.drawString(x_right, y_position, f"{i+1:2d}. {code}")
|
||||
y_position -= 0.4*inch
|
||||
|
||||
# Footer
|
||||
c.setFont("Helvetica", 8)
|
||||
c.setFillColorRGB(0.5, 0.5, 0.5)
|
||||
c.drawCentredString(width/2, 0.5*inch, "Keep this document secure and do not share these codes with anyone.")
|
||||
|
||||
c.save()
|
||||
buffer.seek(0)
|
||||
|
||||
# Clear backup codes from session after download
|
||||
session.pop('backup_codes', None)
|
||||
|
||||
# Create response with PDF
|
||||
response = make_response(buffer.getvalue())
|
||||
response.headers['Content-Type'] = 'application/pdf'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=FINA_BackupCodes_{current_user.username}_{datetime.utcnow().strftime("%Y%m%d")}.pdf'
|
||||
|
||||
return response
|
||||
|
||||
except ImportError:
|
||||
# If reportlab is not installed, return codes as text file
|
||||
text_content = f"FINA - Two-Factor Authentication Backup Codes\n\n"
|
||||
text_content += f"User: {current_user.username}\n"
|
||||
text_content += f"Email: {current_user.email}\n"
|
||||
text_content += f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}\n\n"
|
||||
text_content += "IMPORTANT: Store these codes in a secure location!\n"
|
||||
text_content += "Each code can only be used once.\n\n"
|
||||
text_content += "Backup Codes:\n"
|
||||
text_content += "-" * 40 + "\n"
|
||||
|
||||
for i, code in enumerate(backup_codes, 1):
|
||||
text_content += f"{i:2d}. {code}\n"
|
||||
|
||||
text_content += "-" * 40 + "\n"
|
||||
text_content += "\nKeep this document secure and do not share these codes with anyone."
|
||||
|
||||
# Clear backup codes from session
|
||||
session.pop('backup_codes', None)
|
||||
|
||||
response = make_response(text_content)
|
||||
response.headers['Content-Type'] = 'text/plain'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=FINA_BackupCodes_{current_user.username}_{datetime.utcnow().strftime("%Y%m%d")}.txt'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@bp.route('/disable-2fa', methods=['POST'])
|
||||
@login_required
|
||||
def disable_2fa():
|
||||
current_user.two_factor_enabled = False
|
||||
current_user.backup_codes = None
|
||||
db.session.commit()
|
||||
|
||||
if request.is_json:
|
||||
return {'success': True, 'message': '2FA disabled'}
|
||||
|
||||
flash('2FA disabled', 'success')
|
||||
return redirect(url_for('main.settings'))
|
||||
222
backup/fina-1/app/routes/documents.py
Normal file
222
backup/fina-1/app/routes/documents.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
from flask import Blueprint, request, jsonify, send_file, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import Document
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import mimetypes
|
||||
from datetime import datetime
|
||||
|
||||
bp = Blueprint('documents', __name__, url_prefix='/api/documents')
|
||||
|
||||
# Max file size: 10MB
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# Allowed file types for documents
|
||||
ALLOWED_DOCUMENT_TYPES = {
|
||||
'pdf': 'application/pdf',
|
||||
'csv': 'text/csv',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg'
|
||||
}
|
||||
|
||||
def allowed_document(filename):
|
||||
"""Check if file type is allowed"""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in ALLOWED_DOCUMENT_TYPES.keys()
|
||||
|
||||
def get_file_type_icon(file_type):
|
||||
"""Get material icon name for file type"""
|
||||
icons = {
|
||||
'pdf': 'picture_as_pdf',
|
||||
'csv': 'table_view',
|
||||
'xlsx': 'table_view',
|
||||
'xls': 'table_view',
|
||||
'png': 'image',
|
||||
'jpg': 'image',
|
||||
'jpeg': 'image'
|
||||
}
|
||||
return icons.get(file_type.lower(), 'description')
|
||||
|
||||
@bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
def get_documents():
|
||||
"""
|
||||
Get all documents for current user
|
||||
Security: Filters by current_user.id
|
||||
"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 10, type=int)
|
||||
search = request.args.get('search', '')
|
||||
|
||||
# Security: Only get documents for current user
|
||||
query = Document.query.filter_by(user_id=current_user.id)
|
||||
|
||||
if search:
|
||||
query = query.filter(Document.original_filename.ilike(f'%{search}%'))
|
||||
|
||||
pagination = query.order_by(Document.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'documents': [doc.to_dict() for doc in pagination.items],
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages,
|
||||
'current_page': page
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/', methods=['POST'])
|
||||
@login_required
|
||||
def upload_document():
|
||||
"""
|
||||
Upload a new document
|
||||
Security: Associates document with current_user.id
|
||||
"""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'success': False, 'message': 'No file provided'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
if not file or not file.filename:
|
||||
return jsonify({'success': False, 'message': 'No file selected'}), 400
|
||||
|
||||
if not allowed_document(file.filename):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Invalid file type. Allowed: PDF, CSV, XLS, XLSX, PNG, JPG'
|
||||
}), 400
|
||||
|
||||
# Check file size
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'File too large. Maximum size: {MAX_FILE_SIZE // (1024*1024)}MB'
|
||||
}), 400
|
||||
|
||||
# Generate secure filename
|
||||
original_filename = secure_filename(file.filename)
|
||||
file_ext = original_filename.rsplit('.', 1)[1].lower()
|
||||
timestamp = datetime.utcnow().timestamp()
|
||||
filename = f"{current_user.id}_{timestamp}_{original_filename}"
|
||||
|
||||
# Create documents directory if it doesn't exist
|
||||
documents_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'documents')
|
||||
os.makedirs(documents_dir, exist_ok=True)
|
||||
|
||||
# Save file
|
||||
file_path = os.path.join(documents_dir, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Get document category from form data
|
||||
document_category = request.form.get('category', 'Other')
|
||||
|
||||
# Create document record - Security: user_id is current_user.id
|
||||
document = Document(
|
||||
filename=filename,
|
||||
original_filename=original_filename,
|
||||
file_path=file_path,
|
||||
file_size=file_size,
|
||||
file_type=file_ext.upper(),
|
||||
mime_type=ALLOWED_DOCUMENT_TYPES.get(file_ext, 'application/octet-stream'),
|
||||
document_category=document_category,
|
||||
status='uploaded',
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(document)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Document uploaded successfully',
|
||||
'document': document.to_dict()
|
||||
}), 201
|
||||
|
||||
|
||||
@bp.route('/<int:document_id>/download', methods=['GET'])
|
||||
@login_required
|
||||
def download_document(document_id):
|
||||
"""
|
||||
Download a document
|
||||
Security: Checks document belongs to current_user
|
||||
"""
|
||||
# Security: Filter by user_id
|
||||
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
|
||||
|
||||
if not document:
|
||||
return jsonify({'success': False, 'message': 'Document not found'}), 404
|
||||
|
||||
if not os.path.exists(document.file_path):
|
||||
return jsonify({'success': False, 'message': 'File not found on server'}), 404
|
||||
|
||||
return send_file(
|
||||
document.file_path,
|
||||
mimetype=document.mime_type,
|
||||
as_attachment=True,
|
||||
download_name=document.original_filename
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:document_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_document(document_id):
|
||||
"""
|
||||
Delete a document
|
||||
Security: Checks document belongs to current_user
|
||||
"""
|
||||
# Security: Filter by user_id
|
||||
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
|
||||
|
||||
if not document:
|
||||
return jsonify({'success': False, 'message': 'Document not found'}), 404
|
||||
|
||||
# Delete physical file
|
||||
if os.path.exists(document.file_path):
|
||||
try:
|
||||
os.remove(document.file_path)
|
||||
except Exception as e:
|
||||
print(f"Error deleting file: {e}")
|
||||
|
||||
# Delete database record
|
||||
db.session.delete(document)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Document deleted successfully'})
|
||||
|
||||
|
||||
@bp.route('/<int:document_id>/status', methods=['PUT'])
|
||||
@login_required
|
||||
def update_document_status(document_id):
|
||||
"""
|
||||
Update document status (e.g., mark as analyzed)
|
||||
Security: Checks document belongs to current_user
|
||||
"""
|
||||
# Security: Filter by user_id
|
||||
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
|
||||
|
||||
if not document:
|
||||
return jsonify({'success': False, 'message': 'Document not found'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
new_status = data.get('status')
|
||||
|
||||
if new_status not in ['uploaded', 'processing', 'analyzed', 'error']:
|
||||
return jsonify({'success': False, 'message': 'Invalid status'}), 400
|
||||
|
||||
document.status = new_status
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Document status updated',
|
||||
'document': document.to_dict()
|
||||
})
|
||||
349
backup/fina-1/app/routes/expenses.py
Normal file
349
backup/fina-1/app/routes/expenses.py
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
from flask import Blueprint, request, jsonify, send_file, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import Expense, Category
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
bp = Blueprint('expenses', __name__, url_prefix='/api/expenses')
|
||||
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'pdf'}
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
@bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
def get_expenses():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
category_id = request.args.get('category_id', type=int)
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
search = request.args.get('search', '')
|
||||
|
||||
query = Expense.query.filter_by(user_id=current_user.id)
|
||||
|
||||
if category_id:
|
||||
query = query.filter_by(category_id=category_id)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(Expense.date >= datetime.fromisoformat(start_date))
|
||||
|
||||
if end_date:
|
||||
query = query.filter(Expense.date <= datetime.fromisoformat(end_date))
|
||||
|
||||
if search:
|
||||
query = query.filter(Expense.description.ilike(f'%{search}%'))
|
||||
|
||||
pagination = query.order_by(Expense.date.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'expenses': [expense.to_dict() for expense in pagination.items],
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages,
|
||||
'current_page': page
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/', methods=['POST'])
|
||||
@login_required
|
||||
def create_expense():
|
||||
data = request.form if request.files else request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
if not data.get('amount') or not data.get('category_id') or not data.get('description'):
|
||||
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
|
||||
|
||||
# Handle receipt upload
|
||||
receipt_path = None
|
||||
if 'receipt' in request.files:
|
||||
file = request.files['receipt']
|
||||
if file and file.filename and allowed_file(file.filename):
|
||||
filename = secure_filename(f"{current_user.id}_{datetime.utcnow().timestamp()}_{file.filename}")
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(filepath)
|
||||
receipt_path = filename
|
||||
|
||||
# Create expense
|
||||
expense = Expense(
|
||||
amount=float(data.get('amount')),
|
||||
currency=data.get('currency', current_user.currency),
|
||||
description=data.get('description'),
|
||||
category_id=int(data.get('category_id')),
|
||||
user_id=current_user.id,
|
||||
receipt_path=receipt_path,
|
||||
date=datetime.fromisoformat(data.get('date')) if data.get('date') else datetime.utcnow()
|
||||
)
|
||||
|
||||
# Handle tags
|
||||
if data.get('tags'):
|
||||
if isinstance(data.get('tags'), str):
|
||||
import json
|
||||
tags = json.loads(data.get('tags'))
|
||||
else:
|
||||
tags = data.get('tags')
|
||||
expense.set_tags(tags)
|
||||
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'expense': expense.to_dict()
|
||||
}), 201
|
||||
|
||||
|
||||
@bp.route('/<int:expense_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_expense(expense_id):
|
||||
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first()
|
||||
|
||||
if not expense:
|
||||
return jsonify({'success': False, 'message': 'Expense not found'}), 404
|
||||
|
||||
data = request.form if request.files else request.get_json()
|
||||
|
||||
# Update fields
|
||||
if data.get('amount'):
|
||||
expense.amount = float(data.get('amount'))
|
||||
if data.get('currency'):
|
||||
expense.currency = data.get('currency')
|
||||
if data.get('description'):
|
||||
expense.description = data.get('description')
|
||||
if data.get('category_id'):
|
||||
expense.category_id = int(data.get('category_id'))
|
||||
if data.get('date'):
|
||||
expense.date = datetime.fromisoformat(data.get('date'))
|
||||
if data.get('tags') is not None:
|
||||
if isinstance(data.get('tags'), str):
|
||||
import json
|
||||
tags = json.loads(data.get('tags'))
|
||||
else:
|
||||
tags = data.get('tags')
|
||||
expense.set_tags(tags)
|
||||
|
||||
# Handle receipt upload
|
||||
if 'receipt' in request.files:
|
||||
file = request.files['receipt']
|
||||
if file and file.filename and allowed_file(file.filename):
|
||||
# Delete old receipt
|
||||
if expense.receipt_path:
|
||||
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], expense.receipt_path)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
filename = secure_filename(f"{current_user.id}_{datetime.utcnow().timestamp()}_{file.filename}")
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(filepath)
|
||||
expense.receipt_path = filename
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'expense': expense.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/<int:expense_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_expense(expense_id):
|
||||
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first()
|
||||
|
||||
if not expense:
|
||||
return jsonify({'success': False, 'message': 'Expense not found'}), 404
|
||||
|
||||
# Delete receipt file
|
||||
if expense.receipt_path:
|
||||
receipt_path = os.path.join(current_app.config['UPLOAD_FOLDER'], expense.receipt_path)
|
||||
if os.path.exists(receipt_path):
|
||||
os.remove(receipt_path)
|
||||
|
||||
db.session.delete(expense)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Expense deleted'})
|
||||
|
||||
|
||||
@bp.route('/categories', methods=['GET'])
|
||||
@login_required
|
||||
def get_categories():
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
return jsonify({
|
||||
'categories': [cat.to_dict() for cat in categories]
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/categories', methods=['POST'])
|
||||
@login_required
|
||||
def create_category():
|
||||
data = request.get_json()
|
||||
|
||||
if not data.get('name'):
|
||||
return jsonify({'success': False, 'message': 'Name is required'}), 400
|
||||
|
||||
category = Category(
|
||||
name=data.get('name'),
|
||||
color=data.get('color', '#2b8cee'),
|
||||
icon=data.get('icon', 'category'),
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(category)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'category': category.to_dict()
|
||||
}), 201
|
||||
|
||||
|
||||
@bp.route('/categories/<int:category_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_category(category_id):
|
||||
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
|
||||
|
||||
if not category:
|
||||
return jsonify({'success': False, 'message': 'Category not found'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if data.get('name'):
|
||||
category.name = data.get('name')
|
||||
if data.get('color'):
|
||||
category.color = data.get('color')
|
||||
if data.get('icon'):
|
||||
category.icon = data.get('icon')
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'category': category.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/categories/<int:category_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_category(category_id):
|
||||
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
|
||||
|
||||
if not category:
|
||||
return jsonify({'success': False, 'message': 'Category not found'}), 404
|
||||
|
||||
# Check if category has expenses
|
||||
if category.expenses.count() > 0:
|
||||
return jsonify({'success': False, 'message': 'Cannot delete category with expenses'}), 400
|
||||
|
||||
db.session.delete(category)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Category deleted'})
|
||||
|
||||
|
||||
@bp.route('/export/csv', methods=['GET'])
|
||||
@login_required
|
||||
def export_csv():
|
||||
expenses = Expense.query.filter_by(user_id=current_user.id).order_by(Expense.date.desc()).all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write header
|
||||
writer.writerow(['Date', 'Description', 'Amount', 'Currency', 'Category', 'Tags'])
|
||||
|
||||
# Write data
|
||||
for expense in expenses:
|
||||
writer.writerow([
|
||||
expense.date.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
expense.description,
|
||||
expense.amount,
|
||||
expense.currency,
|
||||
expense.category.name,
|
||||
', '.join(expense.get_tags())
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(output.getvalue().encode('utf-8')),
|
||||
mimetype='text/csv',
|
||||
as_attachment=True,
|
||||
download_name=f'fina_expenses_{datetime.utcnow().strftime("%Y%m%d")}.csv'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/import/csv', methods=['POST'])
|
||||
@login_required
|
||||
def import_csv():
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'success': False, 'message': 'No file provided'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({'success': False, 'message': 'No file selected'}), 400
|
||||
|
||||
if not file.filename.endswith('.csv'):
|
||||
return jsonify({'success': False, 'message': 'File must be CSV'}), 400
|
||||
|
||||
try:
|
||||
stream = io.StringIO(file.stream.read().decode('utf-8'))
|
||||
reader = csv.DictReader(stream)
|
||||
|
||||
imported_count = 0
|
||||
errors = []
|
||||
|
||||
for row in reader:
|
||||
try:
|
||||
# Find or create category
|
||||
category_name = row.get('Category', 'Uncategorized')
|
||||
category = Category.query.filter_by(user_id=current_user.id, name=category_name).first()
|
||||
|
||||
if not category:
|
||||
category = Category(name=category_name, user_id=current_user.id)
|
||||
db.session.add(category)
|
||||
db.session.flush()
|
||||
|
||||
# Parse date
|
||||
date_str = row.get('Date', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
|
||||
expense_date = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Create expense
|
||||
expense = Expense(
|
||||
amount=float(row['Amount']),
|
||||
currency=row.get('Currency', current_user.currency),
|
||||
description=row['Description'],
|
||||
category_id=category.id,
|
||||
user_id=current_user.id,
|
||||
date=expense_date
|
||||
)
|
||||
|
||||
# Handle tags
|
||||
if row.get('Tags'):
|
||||
tags = [tag.strip() for tag in row['Tags'].split(',')]
|
||||
expense.set_tags(tags)
|
||||
|
||||
db.session.add(expense)
|
||||
imported_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Row error: {str(e)}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'imported': imported_count,
|
||||
'errors': errors
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'Import failed: {str(e)}'}), 500
|
||||
289
backup/fina-1/app/routes/main.py
Normal file
289
backup/fina-1/app/routes/main.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import Expense, Category
|
||||
from sqlalchemy import func, extract
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
if current_user.is_authenticated:
|
||||
return render_template('dashboard.html')
|
||||
return render_template('landing.html')
|
||||
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
return render_template('dashboard.html')
|
||||
|
||||
|
||||
@bp.route('/transactions')
|
||||
@login_required
|
||||
def transactions():
|
||||
return render_template('transactions.html')
|
||||
|
||||
|
||||
@bp.route('/reports')
|
||||
@login_required
|
||||
def reports():
|
||||
return render_template('reports.html')
|
||||
|
||||
|
||||
@bp.route('/settings')
|
||||
@login_required
|
||||
def settings():
|
||||
return render_template('settings.html')
|
||||
|
||||
|
||||
@bp.route('/documents')
|
||||
@login_required
|
||||
def documents():
|
||||
return render_template('documents.html')
|
||||
|
||||
|
||||
@bp.route('/api/dashboard-stats')
|
||||
@login_required
|
||||
def dashboard_stats():
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Current month stats
|
||||
current_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Previous month stats
|
||||
if now.month == 1:
|
||||
prev_month_start = now.replace(year=now.year-1, month=12, day=1)
|
||||
else:
|
||||
prev_month_start = current_month_start.replace(month=current_month_start.month-1)
|
||||
|
||||
# Total spent this month
|
||||
current_month_total = db.session.query(func.sum(Expense.amount)).filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= current_month_start,
|
||||
Expense.currency == current_user.currency
|
||||
).scalar() or 0
|
||||
|
||||
# Previous month total
|
||||
prev_month_total = db.session.query(func.sum(Expense.amount)).filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= prev_month_start,
|
||||
Expense.date < current_month_start,
|
||||
Expense.currency == current_user.currency
|
||||
).scalar() or 0
|
||||
|
||||
# Calculate percentage change
|
||||
if prev_month_total > 0:
|
||||
percent_change = ((current_month_total - prev_month_total) / prev_month_total) * 100
|
||||
else:
|
||||
percent_change = 100 if current_month_total > 0 else 0
|
||||
|
||||
# Active categories
|
||||
active_categories = Category.query.filter_by(user_id=current_user.id).count()
|
||||
|
||||
# Total transactions this month
|
||||
total_transactions = Expense.query.filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= current_month_start
|
||||
).count()
|
||||
|
||||
# Category breakdown
|
||||
category_stats = db.session.query(
|
||||
Category.name,
|
||||
Category.color,
|
||||
func.sum(Expense.amount).label('total')
|
||||
).join(Expense).filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= current_month_start,
|
||||
Expense.currency == current_user.currency
|
||||
).group_by(Category.id).all()
|
||||
|
||||
# Monthly breakdown (last 6 months)
|
||||
monthly_data = []
|
||||
for i in range(5, -1, -1):
|
||||
month_date = now - timedelta(days=30*i)
|
||||
month_start = month_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if month_date.month == 12:
|
||||
month_end = month_date.replace(year=month_date.year+1, month=1, day=1)
|
||||
else:
|
||||
month_end = month_date.replace(month=month_date.month+1, day=1)
|
||||
|
||||
month_total = db.session.query(func.sum(Expense.amount)).filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= month_start,
|
||||
Expense.date < month_end,
|
||||
Expense.currency == current_user.currency
|
||||
).scalar() or 0
|
||||
|
||||
monthly_data.append({
|
||||
'month': month_start.strftime('%b'),
|
||||
'total': float(month_total)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total_spent': float(current_month_total),
|
||||
'percent_change': round(percent_change, 1),
|
||||
'active_categories': active_categories,
|
||||
'total_transactions': total_transactions,
|
||||
'currency': current_user.currency,
|
||||
'category_breakdown': [
|
||||
{'name': stat[0], 'color': stat[1], 'amount': float(stat[2])}
|
||||
for stat in category_stats
|
||||
],
|
||||
'monthly_data': monthly_data
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/api/recent-transactions')
|
||||
@login_required
|
||||
def recent_transactions():
|
||||
limit = request.args.get('limit', 10, type=int)
|
||||
|
||||
expenses = Expense.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(Expense.date.desc())\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
return jsonify({
|
||||
'transactions': [expense.to_dict() for expense in expenses]
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/api/reports-stats')
|
||||
@login_required
|
||||
def reports_stats():
|
||||
"""
|
||||
Generate comprehensive financial reports
|
||||
Security: Only returns data for current_user (enforced by user_id filter)
|
||||
"""
|
||||
period = request.args.get('period', '30') # days
|
||||
category_filter = request.args.get('category_id', type=int)
|
||||
|
||||
try:
|
||||
days = int(period)
|
||||
except ValueError:
|
||||
days = 30
|
||||
|
||||
now = datetime.utcnow()
|
||||
period_start = now - timedelta(days=days)
|
||||
|
||||
# Query with security filter
|
||||
query = Expense.query.filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= period_start
|
||||
)
|
||||
|
||||
if category_filter:
|
||||
query = query.filter_by(category_id=category_filter)
|
||||
|
||||
expenses = query.all()
|
||||
|
||||
# Total spent in period
|
||||
total_spent = sum(exp.amount for exp in expenses if exp.currency == current_user.currency)
|
||||
|
||||
# Previous period comparison
|
||||
prev_period_start = period_start - timedelta(days=days)
|
||||
prev_expenses = Expense.query.filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= prev_period_start,
|
||||
Expense.date < period_start,
|
||||
Expense.currency == current_user.currency
|
||||
).all()
|
||||
prev_total = sum(exp.amount for exp in prev_expenses)
|
||||
|
||||
percent_change = 0
|
||||
if prev_total > 0:
|
||||
percent_change = ((total_spent - prev_total) / prev_total) * 100
|
||||
elif total_spent > 0:
|
||||
percent_change = 100
|
||||
|
||||
# Top category
|
||||
category_totals = {}
|
||||
for exp in expenses:
|
||||
if exp.currency == current_user.currency:
|
||||
cat_name = exp.category.name
|
||||
category_totals[cat_name] = category_totals.get(cat_name, 0) + exp.amount
|
||||
|
||||
top_category = max(category_totals.items(), key=lambda x: x[1]) if category_totals else ('None', 0)
|
||||
|
||||
# Average daily spending
|
||||
avg_daily = total_spent / days if days > 0 else 0
|
||||
prev_avg_daily = prev_total / days if days > 0 else 0
|
||||
avg_change = 0
|
||||
if prev_avg_daily > 0:
|
||||
avg_change = ((avg_daily - prev_avg_daily) / prev_avg_daily) * 100
|
||||
elif avg_daily > 0:
|
||||
avg_change = 100
|
||||
|
||||
# Savings rate (placeholder - would need income data)
|
||||
savings_rate = 18.5 # Placeholder
|
||||
|
||||
# Category breakdown for pie chart
|
||||
category_breakdown = []
|
||||
for cat_name, amount in sorted(category_totals.items(), key=lambda x: x[1], reverse=True):
|
||||
category = Category.query.filter_by(user_id=current_user.id, name=cat_name).first()
|
||||
if category:
|
||||
percentage = (amount / total_spent * 100) if total_spent > 0 else 0
|
||||
category_breakdown.append({
|
||||
'name': cat_name,
|
||||
'color': category.color,
|
||||
'amount': float(amount),
|
||||
'percentage': round(percentage, 1)
|
||||
})
|
||||
|
||||
# Daily spending trend (last 30 days)
|
||||
daily_trend = []
|
||||
for i in range(min(30, days)):
|
||||
day_date = now - timedelta(days=i)
|
||||
day_start = day_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
day_total = db.session.query(func.sum(Expense.amount)).filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= day_start,
|
||||
Expense.date < day_end,
|
||||
Expense.currency == current_user.currency
|
||||
).scalar() or 0
|
||||
|
||||
daily_trend.insert(0, {
|
||||
'date': day_date.strftime('%d %b'),
|
||||
'amount': float(day_total)
|
||||
})
|
||||
|
||||
# Monthly comparison (last 6 months)
|
||||
monthly_comparison = []
|
||||
for i in range(5, -1, -1):
|
||||
month_date = now - timedelta(days=30*i)
|
||||
month_start = month_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if month_date.month == 12:
|
||||
month_end = month_date.replace(year=month_date.year+1, month=1, day=1)
|
||||
else:
|
||||
month_end = month_date.replace(month=month_date.month+1, day=1)
|
||||
|
||||
month_total = db.session.query(func.sum(Expense.amount)).filter(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.date >= month_start,
|
||||
Expense.date < month_end,
|
||||
Expense.currency == current_user.currency
|
||||
).scalar() or 0
|
||||
|
||||
monthly_comparison.append({
|
||||
'month': month_start.strftime('%b'),
|
||||
'amount': float(month_total)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total_spent': float(total_spent),
|
||||
'percent_change': round(percent_change, 1),
|
||||
'top_category': {'name': top_category[0], 'amount': float(top_category[1])},
|
||||
'avg_daily': float(avg_daily),
|
||||
'avg_daily_change': round(avg_change, 1),
|
||||
'savings_rate': savings_rate,
|
||||
'category_breakdown': category_breakdown,
|
||||
'daily_trend': daily_trend,
|
||||
'monthly_comparison': monthly_comparison,
|
||||
'currency': current_user.currency,
|
||||
'period_days': days
|
||||
})
|
||||
241
backup/fina-1/app/routes/settings.py
Normal file
241
backup/fina-1/app/routes/settings.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from app import db, bcrypt
|
||||
from app.models import User
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
bp = Blueprint('settings', __name__, url_prefix='/api/settings')
|
||||
|
||||
# Allowed avatar image types
|
||||
ALLOWED_AVATAR_TYPES = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
MAX_AVATAR_SIZE = 20 * 1024 * 1024 # 20MB
|
||||
|
||||
def allowed_avatar(filename):
|
||||
"""Check if file extension is allowed for avatars"""
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AVATAR_TYPES
|
||||
|
||||
|
||||
@bp.route('/profile', methods=['GET'])
|
||||
@login_required
|
||||
def get_profile():
|
||||
"""
|
||||
Get current user profile information
|
||||
Security: Returns only current user's data
|
||||
"""
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'profile': {
|
||||
'username': current_user.username,
|
||||
'email': current_user.email,
|
||||
'language': current_user.language,
|
||||
'currency': current_user.currency,
|
||||
'avatar': current_user.avatar,
|
||||
'is_admin': current_user.is_admin,
|
||||
'two_factor_enabled': current_user.two_factor_enabled,
|
||||
'created_at': current_user.created_at.isoformat()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/profile', methods=['PUT'])
|
||||
@login_required
|
||||
def update_profile():
|
||||
"""
|
||||
Update user profile information
|
||||
Security: Updates only current user's profile
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'No data provided'}), 400
|
||||
|
||||
try:
|
||||
# Update language
|
||||
if 'language' in data:
|
||||
if data['language'] in ['en', 'ro']:
|
||||
current_user.language = data['language']
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Invalid language'}), 400
|
||||
|
||||
# Update currency
|
||||
if 'currency' in data:
|
||||
current_user.currency = data['currency']
|
||||
|
||||
# Update username (check uniqueness)
|
||||
if 'username' in data and data['username'] != current_user.username:
|
||||
existing = User.query.filter_by(username=data['username']).first()
|
||||
if existing:
|
||||
return jsonify({'success': False, 'error': 'Username already taken'}), 400
|
||||
current_user.username = data['username']
|
||||
|
||||
# Update email (check uniqueness)
|
||||
if 'email' in data and data['email'] != current_user.email:
|
||||
existing = User.query.filter_by(email=data['email']).first()
|
||||
if existing:
|
||||
return jsonify({'success': False, 'error': 'Email already taken'}), 400
|
||||
current_user.email = data['email']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Profile updated successfully',
|
||||
'profile': {
|
||||
'username': current_user.username,
|
||||
'email': current_user.email,
|
||||
'language': current_user.language,
|
||||
'currency': current_user.currency,
|
||||
'avatar': current_user.avatar
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/avatar', methods=['POST'])
|
||||
@login_required
|
||||
def upload_avatar():
|
||||
"""
|
||||
Upload custom avatar image
|
||||
Security: Associates avatar with current_user.id, validates file type and size
|
||||
"""
|
||||
if 'avatar' not in request.files:
|
||||
return jsonify({'success': False, 'error': 'No file provided'}), 400
|
||||
|
||||
file = request.files['avatar']
|
||||
|
||||
if not file or not file.filename:
|
||||
return jsonify({'success': False, 'error': 'No file selected'}), 400
|
||||
|
||||
if not allowed_avatar(file.filename):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid file type. Allowed: PNG, JPG, JPEG, GIF, WEBP'
|
||||
}), 400
|
||||
|
||||
# Check file size
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
if file_size > MAX_AVATAR_SIZE:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'File too large. Maximum size: {MAX_AVATAR_SIZE // (1024*1024)}MB'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
# Delete old custom avatar if exists (not default avatars)
|
||||
if current_user.avatar and not current_user.avatar.startswith('icons/avatars/'):
|
||||
old_path = os.path.join(current_app.root_path, 'static', current_user.avatar)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
# Generate secure filename
|
||||
file_ext = file.filename.rsplit('.', 1)[1].lower()
|
||||
timestamp = int(datetime.utcnow().timestamp())
|
||||
filename = f"user_{current_user.id}_{timestamp}.{file_ext}"
|
||||
|
||||
# Create avatars directory in uploads
|
||||
avatars_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'avatars')
|
||||
os.makedirs(avatars_dir, exist_ok=True)
|
||||
|
||||
# Save file
|
||||
file_path = os.path.join(avatars_dir, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Update user avatar (store relative path from static folder)
|
||||
current_user.avatar = f"uploads/avatars/{filename}"
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Avatar uploaded successfully',
|
||||
'avatar': current_user.avatar
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/avatar/default', methods=['PUT'])
|
||||
@login_required
|
||||
def set_default_avatar():
|
||||
"""
|
||||
Set avatar to one of the default avatars
|
||||
Security: Updates only current user's avatar
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'avatar' not in data:
|
||||
return jsonify({'success': False, 'error': 'Avatar path required'}), 400
|
||||
|
||||
avatar_path = data['avatar']
|
||||
|
||||
# Validate it's a default avatar
|
||||
if not avatar_path.startswith('icons/avatars/avatar-'):
|
||||
return jsonify({'success': False, 'error': 'Invalid avatar selection'}), 400
|
||||
|
||||
try:
|
||||
# Delete old custom avatar if exists (not default avatars)
|
||||
if current_user.avatar and not current_user.avatar.startswith('icons/avatars/'):
|
||||
old_path = os.path.join(current_app.root_path, 'static', current_user.avatar)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
current_user.avatar = avatar_path
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Avatar updated successfully',
|
||||
'avatar': current_user.avatar
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/password', methods=['PUT'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""
|
||||
Change user password
|
||||
Security: Requires current password verification
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'No data provided'}), 400
|
||||
|
||||
current_password = data.get('current_password')
|
||||
new_password = data.get('new_password')
|
||||
|
||||
if not current_password or not new_password:
|
||||
return jsonify({'success': False, 'error': 'Current and new password required'}), 400
|
||||
|
||||
# Verify current password
|
||||
if not bcrypt.check_password_hash(current_user.password_hash, current_password):
|
||||
return jsonify({'success': False, 'error': 'Current password is incorrect'}), 400
|
||||
|
||||
if len(new_password) < 6:
|
||||
return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400
|
||||
|
||||
try:
|
||||
current_user.password_hash = bcrypt.generate_password_hash(new_password).decode('utf-8')
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Password changed successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
Loading…
Add table
Add a link
Reference in a new issue