Initial commit

This commit is contained in:
iulian 2025-12-26 00:52:56 +00:00
commit 983cee0320
322 changed files with 57174 additions and 0 deletions

View 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
})

View 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'))

View 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()
})

View 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

View 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
})

View 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