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 @@
# Routes package

View file

@ -0,0 +1,115 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models.user import User
bp = Blueprint('auth', __name__, url_prefix='/auth')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
totp_token = request.form.get('totp_token')
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
# Check if 2FA is enabled
if user.is_2fa_enabled:
if not totp_token:
# Store user ID in session for 2FA verification
session['2fa_user_id'] = user.id
return render_template('auth/verify_2fa.html')
else:
# Verify 2FA token
if user.verify_totp(totp_token):
login_user(user)
session.pop('2fa_user_id', None)
flash('Login successful!', 'success')
return redirect(url_for('main.dashboard'))
else:
flash('Invalid 2FA code', 'error')
return render_template('auth/verify_2fa.html')
else:
# No 2FA, login directly
login_user(user)
flash('Login successful!', 'success')
return redirect(url_for('main.dashboard'))
flash('Invalid username or password', 'error')
return render_template('auth/login.html')
@bp.route('/verify-2fa', methods=['POST'])
def verify_2fa():
user_id = session.get('2fa_user_id')
if not user_id:
flash('Session expired. Please login again.', 'error')
return redirect(url_for('auth.login'))
user = User.query.get(user_id)
if not user:
flash('User not found', 'error')
return redirect(url_for('auth.login'))
token = request.form.get('token')
if user.verify_totp(token):
login_user(user)
session.pop('2fa_user_id', None)
flash('Login successful!', 'success')
return redirect(url_for('main.dashboard'))
else:
flash('Invalid 2FA code. Please try again.', 'error')
return render_template('auth/verify_2fa.html')
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
if password != confirm_password:
flash('Passwords do not match', 'error')
return redirect(url_for('auth.register'))
if User.query.filter_by(username=username).first():
flash('Username already exists', 'error')
return redirect(url_for('auth.register'))
if User.query.filter_by(email=email).first():
flash('Email already exists', 'error')
return redirect(url_for('auth.register'))
is_first_user = User.query.count() == 0
user = User(
username=username,
email=email,
is_admin=is_first_user
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('Registration successful! Please login.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html')
@bp.route('/logout')
@login_required
def logout():
logout_user()
flash('Logged out successfully', 'success')
return redirect(url_for('auth.login'))

View file

@ -0,0 +1 @@
# This file makes routes a proper Python package

View file

@ -0,0 +1,18 @@
from flask import Blueprint, request, redirect, url_for
from flask_login import login_required, current_user
from app import db
bp = Blueprint('language', __name__, url_prefix='/language')
@bp.route('/switch/<lang>')
@login_required
def switch_language(lang):
"""Switch user's language preference"""
allowed_languages = ['en', 'ro', 'es']
if lang in allowed_languages:
current_user.language = lang
db.session.commit()
# Redirect back to the referring page or dashboard
return redirect(request.referrer or url_for('main.dashboard'))

View file

@ -0,0 +1,810 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, jsonify, current_app
from flask_login import login_required, current_user
from app import db
from app.models.category import Category, Expense
from app.models.user import Tag
from werkzeug.utils import secure_filename
import os
from datetime import datetime
from sqlalchemy import extract, func
import json
bp = Blueprint('main', __name__)
def allowed_file(filename):
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'pdf', 'gif'}
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@bp.route('/')
def index():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
return redirect(url_for('auth.login'))
@bp.route('/dashboard')
@login_required
def dashboard():
from app.models.subscription import Subscription
from datetime import timedelta, date
today = date.today()
categories = Category.query.filter_by(user_id=current_user.id).all()
total_spent = sum(cat.get_total_spent() for cat in categories)
total_expenses = Expense.query.filter_by(user_id=current_user.id).count()
years_query = db.session.query(
extract('year', Expense.date).label('year')
).filter(
Expense.user_id == current_user.id
).distinct().all()
available_years = sorted([int(year[0]) for year in years_query if year[0]], reverse=True)
if not available_years:
available_years = [datetime.now().year]
current_year = datetime.now().year
chart_data = []
for cat in categories:
spent = cat.get_total_spent()
if spent > 0:
chart_data.append({
'name': cat.name,
'value': spent,
'color': cat.color
})
categories_json = [
{
'id': cat.id,
'name': cat.name,
'color': cat.color
}
for cat in categories
]
# Get upcoming subscriptions (next 30 days)
end_date = datetime.now().date() + timedelta(days=30)
upcoming_subscriptions = Subscription.query.filter(
Subscription.user_id == current_user.id,
Subscription.is_active == True,
Subscription.next_due_date <= end_date
).order_by(Subscription.next_due_date).limit(5).all()
# Get suggestions count
from app.smart_detection import get_user_suggestions
suggestions_count = len(get_user_suggestions(current_user.id))
return render_template('dashboard.html',
categories=categories,
total_spent=total_spent,
total_expenses=total_expenses,
chart_data=chart_data,
categories_json=categories_json,
available_years=available_years,
current_year=current_year,
upcoming_subscriptions=upcoming_subscriptions,
suggestions_count=suggestions_count,
today=today)
@bp.route('/api/metrics')
@login_required
def get_metrics():
category_id = request.args.get('category', 'all')
year = int(request.args.get('year', datetime.now().year))
if category_id == 'all':
categories = Category.query.filter_by(user_id=current_user.id).all()
monthly_data = [0] * 12
for cat in categories:
cat_monthly = cat.get_monthly_totals(year)
monthly_data = [monthly_data[i] + cat_monthly[i] for i in range(12)]
pie_data = [cat.get_yearly_total(year) for cat in categories]
pie_labels = [cat.name for cat in categories]
pie_colors = [cat.color for cat in categories]
return jsonify({
'category_name': 'All Categories',
'monthly_data': monthly_data,
'color': '#6366f1',
'pie_data': pie_data,
'pie_labels': pie_labels,
'pie_colors': pie_colors
})
else:
category = Category.query.filter_by(
id=int(category_id),
user_id=current_user.id
).first_or_404()
monthly_data = category.get_monthly_totals(year)
categories = Category.query.filter_by(user_id=current_user.id).all()
pie_data = [cat.get_yearly_total(year) for cat in categories]
pie_labels = [cat.name for cat in categories]
pie_colors = [cat.color for cat in categories]
return jsonify({
'category_name': category.name,
'monthly_data': monthly_data,
'color': category.color,
'pie_data': pie_data,
'pie_labels': pie_labels,
'pie_colors': pie_colors
})
@bp.route('/category/create', methods=['GET', 'POST'])
@login_required
def create_category():
if request.method == 'POST':
name = request.form.get('name')
description = request.form.get('description')
color = request.form.get('color', '#6366f1')
if not name:
flash('Category name is required', 'error')
return redirect(url_for('main.create_category'))
category = Category(
name=name,
description=description,
color=color,
user_id=current_user.id
)
db.session.add(category)
db.session.commit()
flash('Category created successfully!', 'success')
return redirect(url_for('main.dashboard'))
return render_template('create_category.html')
@bp.route('/category/<int:category_id>')
@login_required
def view_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
expenses = Expense.query.filter_by(category_id=category_id, user_id=current_user.id).order_by(Expense.date.desc()).all()
total_spent = category.get_total_spent()
return render_template('view_category.html',
category=category,
expenses=expenses,
total_spent=total_spent)
@bp.route('/category/<int:category_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
if request.method == 'POST':
category.name = request.form.get('name')
category.description = request.form.get('description')
category.color = request.form.get('color')
# Budget settings
monthly_budget = request.form.get('monthly_budget', '').strip()
if monthly_budget:
try:
category.monthly_budget = float(monthly_budget)
if category.monthly_budget < 0:
category.monthly_budget = None
except ValueError:
category.monthly_budget = None
else:
category.monthly_budget = None
# Budget alert threshold (default 100%)
threshold = request.form.get('budget_alert_threshold', '100').strip()
try:
category.budget_alert_threshold = float(threshold) / 100
if category.budget_alert_threshold < 0.5 or category.budget_alert_threshold > 2.0:
category.budget_alert_threshold = 1.0
except ValueError:
category.budget_alert_threshold = 1.0
db.session.commit()
flash('Category updated successfully!', 'success')
return redirect(url_for('main.view_category', category_id=category.id))
return render_template('edit_category.html', category=category)
@bp.route('/category/<int:category_id>/delete', methods=['POST'])
@login_required
def delete_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
for expense in category.expenses:
if expense.file_path:
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
if os.path.exists(file_path):
try:
os.remove(file_path)
except:
pass
db.session.delete(category)
db.session.commit()
flash('Category deleted successfully!', 'success')
return redirect(url_for('main.dashboard'))
@bp.route('/expense/create/<int:category_id>', methods=['GET', 'POST'])
@login_required
def create_expense(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
user_tags = Tag.query.filter_by(user_id=current_user.id).all()
if request.method == 'POST':
description = request.form.get('description')
amount = request.form.get('amount')
date_str = request.form.get('date')
paid_by = request.form.get('paid_by')
tags = request.form.get('tags')
if not all([description, amount]):
flash('Description and amount are required', 'error')
return redirect(url_for('main.create_expense', category_id=category_id))
try:
amount = float(amount)
if amount <= 0:
raise ValueError()
except ValueError:
flash('Please enter a valid amount', 'error')
return redirect(url_for('main.create_expense', category_id=category_id))
file_path = None
if 'file' in request.files:
file = request.files['file']
if file and file.filename and allowed_file(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{current_user.id}_{timestamp}_{filename}"
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
file_path = os.path.join('uploads', filename)
file.save(os.path.join(current_app.root_path, 'static', file_path))
expense_date = datetime.strptime(date_str, '%Y-%m-%d') if date_str else datetime.utcnow()
expense = Expense(
description=description,
amount=amount,
date=expense_date,
paid_by=paid_by,
tags=tags,
file_path=file_path,
category_id=category_id,
user_id=current_user.id
)
db.session.add(expense)
db.session.commit()
# Check budget after adding expense
from app.budget_alerts import check_budget_alerts
try:
check_budget_alerts()
except Exception as e:
print(f"[Budget Check] Error: {e}")
flash('Expense added successfully!', 'success')
return redirect(url_for('main.view_category', category_id=category_id))
today = datetime.now().strftime('%Y-%m-%d')
return render_template('create_expense.html', category=category, today=today, user_tags=user_tags)
@bp.route('/expense/<int:expense_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_expense(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
user_tags = Tag.query.filter_by(user_id=current_user.id).all()
if request.method == 'POST':
expense.description = request.form.get('description')
expense.amount = float(request.form.get('amount'))
expense.date = datetime.strptime(request.form.get('date'), '%Y-%m-%d')
expense.paid_by = request.form.get('paid_by')
expense.tags = request.form.get('tags')
if 'file' in request.files:
file = request.files['file']
if file and file.filename and allowed_file(file.filename):
if expense.file_path:
old_file = os.path.join(current_app.root_path, 'static', expense.file_path)
if os.path.exists(old_file):
try:
os.remove(old_file)
except:
pass
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{current_user.id}_{timestamp}_{filename}"
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
file_path = os.path.join('uploads', filename)
file.save(os.path.join(current_app.root_path, 'static', file_path))
expense.file_path = file_path
db.session.commit()
flash('Expense updated successfully!', 'success')
return redirect(url_for('main.view_category', category_id=expense.category_id))
return render_template('edit_expense.html', expense=expense, user_tags=user_tags)
@bp.route('/expense/<int:expense_id>/delete', methods=['POST'])
@login_required
def delete_expense(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
category_id = expense.category_id
if expense.file_path:
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
if os.path.exists(file_path):
try:
os.remove(file_path)
except:
pass
db.session.delete(expense)
db.session.commit()
flash('Expense deleted successfully!', 'success')
return redirect(url_for('main.view_category', category_id=category_id))
@bp.route('/expense/<int:expense_id>/download')
@login_required
def download_file(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
if not expense.file_path:
flash('No file attached to this expense', 'error')
return redirect(url_for('main.view_category', category_id=expense.category_id))
# Use current_app.root_path to get correct path
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
if not os.path.exists(file_path):
flash('File not found', 'error')
return redirect(url_for('main.view_category', category_id=expense.category_id))
return send_file(file_path, as_attachment=True)
@bp.route('/expense/<int:expense_id>/view')
@login_required
def view_file(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
if not expense.file_path:
flash('No file attached to this expense', 'error')
return redirect(url_for('main.view_category', category_id=expense.category_id))
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
if not os.path.exists(file_path):
flash('File not found', 'error')
return redirect(url_for('main.view_category', category_id=expense.category_id))
# Return file for inline viewing
return send_file(file_path, as_attachment=False)
@bp.route('/api/ocr/process', methods=['POST'])
@login_required
def process_receipt_ocr():
"""
Process uploaded receipt image with OCR
Returns extracted data as JSON
"""
if 'file' not in request.files:
return jsonify({'success': False, 'error': 'No file uploaded'}), 400
file = request.files['file']
if not file or not file.filename:
return jsonify({'success': False, 'error': 'No file selected'}), 400
if not allowed_file(file.filename):
return jsonify({'success': False, 'error': 'Invalid file type'}), 400
try:
from app.ocr import extract_receipt_data, is_valid_receipt_image
# Save temp file
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
temp_filename = f"temp_{current_user.id}_{timestamp}_{filename}"
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
temp_path = os.path.join(upload_folder, temp_filename)
file.save(temp_path)
# Validate image
is_valid, message = is_valid_receipt_image(temp_path)
if not is_valid:
os.remove(temp_path)
return jsonify({'success': False, 'error': message}), 400
# Extract data with OCR
extracted_data = extract_receipt_data(temp_path)
# Format response
response = {
'success': extracted_data['success'],
'amount': extracted_data['amount'],
'merchant': extracted_data['merchant'],
'confidence': extracted_data['confidence'],
'temp_file': temp_filename
}
if extracted_data['date']:
response['date'] = extracted_data['date'].strftime('%Y-%m-%d')
# Don't delete temp file - will be used if user confirms
return jsonify(response)
except Exception as e:
if os.path.exists(temp_path):
os.remove(temp_path)
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/predictions')
@login_required
def predictions():
"""Display spending predictions dashboard"""
from app.predictions import get_spending_predictions, generate_insights
from datetime import datetime
# Get predictions for next 3 months
predictions_data = get_spending_predictions(current_user.id, months_ahead=3)
# Generate insights
insights = generate_insights(
predictions_data['by_category'],
datetime.now()
)
return render_template('predictions.html',
predictions=predictions_data,
insights=insights)
@bp.route('/api/predictions')
@login_required
def api_predictions():
"""Return JSON predictions for charts"""
from app.predictions import get_spending_predictions
months_ahead = request.args.get('months', 3, type=int)
# Limit to reasonable range
if months_ahead < 1 or months_ahead > 12:
return jsonify({'error': 'months must be between 1 and 12'}), 400
predictions = get_spending_predictions(current_user.id, months_ahead)
return jsonify(predictions)
@bp.route('/api/predictions/category/<int:category_id>')
@login_required
def api_category_forecast(category_id):
"""Get detailed forecast for specific category"""
from app.predictions import get_category_forecast
from app.models.category import Category
# Security check: ensure category belongs to user
category = Category.query.filter_by(
id=category_id,
user_id=current_user.id
).first()
if not category:
return jsonify({'error': 'Category not found'}), 404
forecast = get_category_forecast(category, months=6)
return jsonify({
'category': category.name,
'forecast': forecast
})
@bp.route('/api/search')
@login_required
def api_search():
"""Global search API endpoint"""
from app.search import search_all
query = request.args.get('q', '').strip()
if not query or len(query) < 2:
return jsonify({
'success': False,
'error': 'Query must be at least 2 characters',
'results': {
'expenses': [],
'categories': [],
'subscriptions': [],
'tags': [],
'total': 0
}
})
# Perform search with user isolation
results = search_all(query, current_user.id, limit=50)
return jsonify({
'success': True,
'results': results
})
@bp.route('/api/search/suggestions')
@login_required
def api_search_suggestions():
"""Quick search suggestions for autocomplete"""
from app.search import quick_search_suggestions
query = request.args.get('q', '').strip()
if not query or len(query) < 2:
return jsonify({'suggestions': []})
suggestions = quick_search_suggestions(query, current_user.id, limit=5)
return jsonify({'suggestions': suggestions})
@bp.route('/search')
@login_required
def search_page():
"""Search results page"""
from app.search import search_all
query = request.args.get('q', '').strip()
if not query:
return render_template('search.html', results=None, query='')
results = search_all(query, current_user.id, limit=100)
return render_template('search.html', results=results, query=query)
@bp.route('/bank-import', methods=['GET', 'POST'])
@login_required
def bank_import():
"""Bank statement import page"""
if request.method == 'GET':
# Get user's categories for mapping
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('bank_import.html', categories=categories)
# POST: Store uploaded file temporarily and redirect to review
if 'file' not in request.files:
flash('No file uploaded', 'error')
return redirect(url_for('main.bank_import'))
file = request.files['file']
if not file or not file.filename:
flash('No file selected', 'error')
return redirect(url_for('main.bank_import'))
# Save temporarily for processing
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
temp_filename = f"bank_{current_user.id}_{timestamp}_{filename}"
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
os.makedirs(temp_folder, exist_ok=True)
temp_path = os.path.join(temp_folder, temp_filename)
file.save(temp_path)
# Redirect to parse API then review
return redirect(url_for('main.bank_import_review', filename=temp_filename))
@bp.route('/bank-import/review/<filename>')
@login_required
def bank_import_review(filename):
"""Review parsed transactions before importing"""
from app.bank_import import parse_bank_statement
# Security: Verify filename belongs to current user
if not filename.startswith(f"bank_{current_user.id}_"):
flash('Invalid file', 'error')
return redirect(url_for('main.bank_import'))
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
temp_path = os.path.join(temp_folder, filename)
if not os.path.exists(temp_path):
flash('File not found. Please upload again.', 'error')
return redirect(url_for('main.bank_import'))
try:
# Read file
with open(temp_path, 'rb') as f:
file_content = f.read()
# Parse bank statement
result = parse_bank_statement(file_content, filename)
if not result['success']:
flash(f"Parsing failed: {result.get('error', 'Unknown error')}", 'error')
# Clean up temp file
try:
os.remove(temp_path)
except:
pass
return redirect(url_for('main.bank_import'))
# Get user's categories
categories = Category.query.filter_by(user_id=current_user.id).all()
# Store temp filename in session for confirmation
from flask import session
session['bank_import_file'] = filename
return render_template('bank_import_review.html',
transactions=result['transactions'],
total_found=result['total_found'],
categories=categories,
bank_format=result.get('bank_format', 'Unknown'),
parse_errors=result.get('parse_errors', []))
except Exception as e:
flash(f'Error processing file: {str(e)}', 'error')
# Clean up temp file
try:
os.remove(temp_path)
except:
pass
return redirect(url_for('main.bank_import'))
@bp.route('/bank-import/confirm', methods=['POST'])
@login_required
def bank_import_confirm():
"""Confirm and import selected transactions"""
from flask import session
# Get temp filename from session
filename = session.get('bank_import_file')
if not filename or not filename.startswith(f"bank_{current_user.id}_"):
flash('Invalid session. Please try again.', 'error')
return redirect(url_for('main.bank_import'))
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
temp_path = os.path.join(temp_folder, filename)
# Get selected transactions from form
selected_indices = request.form.getlist('selected_transactions')
category_mappings = {} # Map transaction index to category_id
for idx in selected_indices:
category_id = request.form.get(f'category_{idx}')
if category_id:
category_mappings[int(idx)] = int(category_id)
if not selected_indices:
flash('No transactions selected for import', 'warning')
return redirect(url_for('main.bank_import_review', filename=filename))
try:
# Re-parse to get transactions
with open(temp_path, 'rb') as f:
file_content = f.read()
from app.bank_import import parse_bank_statement
result = parse_bank_statement(file_content, filename)
if not result['success']:
raise Exception('Re-parsing failed')
# Import selected transactions
imported_count = 0
skipped_count = 0
for idx_str in selected_indices:
idx = int(idx_str)
if idx >= len(result['transactions']):
continue
trans = result['transactions'][idx]
category_id = category_mappings.get(idx)
if not category_id:
skipped_count += 1
continue
# Check if transaction already exists (deduplication)
existing = Expense.query.filter_by(
user_id=current_user.id,
date=trans['date'],
amount=trans['amount'],
description=trans['description'][:50] # Partial match
).first()
if existing:
skipped_count += 1
continue
# Create expense
expense = Expense(
description=trans['description'],
amount=trans['amount'],
date=datetime.combine(trans['date'], datetime.min.time()),
category_id=category_id,
user_id=current_user.id,
tags='imported, bank-statement'
)
db.session.add(expense)
imported_count += 1
db.session.commit()
# Clean up temp file
try:
os.remove(temp_path)
session.pop('bank_import_file', None)
except:
pass
if imported_count > 0:
flash(f'Successfully imported {imported_count} transactions!', 'success')
if skipped_count > 0:
flash(f'{skipped_count} transactions were skipped (duplicates or no category)', 'info')
else:
flash('No transactions were imported', 'warning')
return redirect(url_for('main.dashboard'))
except Exception as e:
db.session.rollback()
flash(f'Import failed: {str(e)}', 'error')
return redirect(url_for('main.bank_import'))
@bp.route('/api/bank-import/parse', methods=['POST'])
@login_required
def api_bank_import_parse():
"""API endpoint for parsing bank statement (AJAX)"""
if 'file' not in request.files:
return jsonify({'success': False, 'error': 'No file uploaded'}), 400
file = request.files['file']
if not file or not file.filename:
return jsonify({'success': False, 'error': 'No file selected'}), 400
try:
from app.bank_import import parse_bank_statement
# Read file content
file_content = file.read()
filename = secure_filename(file.filename)
# Parse
result = parse_bank_statement(file_content, filename)
if not result['success']:
return jsonify(result), 400
# Convert dates to strings for JSON
for trans in result['transactions']:
trans['date'] = trans['date'].isoformat()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500

View file

@ -0,0 +1,281 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.user import User, Tag
from app.models.category import Category, Expense
from werkzeug.security import generate_password_hash
import csv
import io
from datetime import datetime
import json
bp = Blueprint('settings', __name__, url_prefix='/settings')
def admin_required(f):
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_admin:
flash('Admin access required', 'error')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function
@bp.route('/')
@login_required
def index():
users = User.query.all() if current_user.is_admin else []
tags = Tag.query.filter_by(user_id=current_user.id).all()
return render_template('settings/index.html', users=users, tags=tags)
# USER MANAGEMENT
@bp.route('/profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
if request.method == 'POST':
current_user.username = request.form.get('username')
current_user.email = request.form.get('email')
current_user.currency = request.form.get('currency', 'USD')
current_user.language = request.form.get('language', 'en')
# Budget alert preferences
current_user.budget_alerts_enabled = request.form.get('budget_alerts_enabled') == 'on'
alert_email = request.form.get('alert_email', '').strip()
current_user.alert_email = alert_email if alert_email else None
new_password = request.form.get('new_password')
if new_password:
current_user.set_password(new_password)
db.session.commit()
flash('Profile updated successfully!', 'success')
return redirect(url_for('settings.index'))
from app.translations import get_available_languages
languages = get_available_languages()
return render_template('settings/edit_profile.html', languages=languages)
@bp.route('/users/create', methods=['GET', 'POST'])
@login_required
@admin_required
def create_user():
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
is_admin = request.form.get('is_admin') == 'on'
if User.query.filter_by(username=username).first():
flash('Username already exists', 'error')
return redirect(url_for('settings.create_user'))
if User.query.filter_by(email=email).first():
flash('Email already exists', 'error')
return redirect(url_for('settings.create_user'))
user = User(username=username, email=email, is_admin=is_admin)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash(f'User {username} created successfully!', 'success')
return redirect(url_for('settings.index'))
return render_template('settings/create_user.html')
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_user(user_id):
user = User.query.get_or_404(user_id)
if request.method == 'POST':
user.username = request.form.get('username')
user.email = request.form.get('email')
user.is_admin = request.form.get('is_admin') == 'on'
new_password = request.form.get('new_password')
if new_password:
user.set_password(new_password)
db.session.commit()
flash(f'User {user.username} updated!', 'success')
return redirect(url_for('settings.index'))
return render_template('settings/edit_user.html', user=user)
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
if user_id == current_user.id:
flash('Cannot delete your own account', 'error')
return redirect(url_for('settings.index'))
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
flash(f'User {user.username} deleted', 'success')
return redirect(url_for('settings.index'))
# TAG MANAGEMENT
@bp.route('/tags/create', methods=['GET', 'POST'])
@login_required
def create_tag():
if request.method == 'POST':
name = request.form.get('name')
color = request.form.get('color', '#6366f1')
tag = Tag(name=name, color=color, user_id=current_user.id)
db.session.add(tag)
db.session.commit()
flash(f'Tag "{name}" created!', 'success')
return redirect(url_for('settings.index'))
return render_template('settings/create_tag.html')
@bp.route('/tags/<int:tag_id>/delete', methods=['POST'])
@login_required
def delete_tag(tag_id):
tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first_or_404()
db.session.delete(tag)
db.session.commit()
flash(f'Tag "{tag.name}" deleted', 'success')
return redirect(url_for('settings.index'))
# IMPORT/EXPORT
@bp.route('/export')
@login_required
def export_data():
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Category', 'Description', 'Amount', 'Date', 'Paid By', 'Tags'])
expenses = Expense.query.filter_by(user_id=current_user.id).all()
for expense in expenses:
writer.writerow([
expense.category.name,
expense.description,
expense.amount,
expense.date.strftime('%Y-%m-%d'),
expense.paid_by or '',
expense.tags or ''
])
output.seek(0)
return send_file(
io.BytesIO(output.getvalue().encode('utf-8')),
mimetype='text/csv',
as_attachment=True,
download_name=f'expenses_{datetime.now().strftime("%Y%m%d")}.csv'
)
@bp.route('/import', methods=['GET', 'POST'])
@login_required
def import_data():
if request.method == 'POST':
if 'file' not in request.files:
flash('No file uploaded', 'error')
return redirect(url_for('settings.import_data'))
file = request.files['file']
if file.filename == '':
flash('No file selected', 'error')
return redirect(url_for('settings.import_data'))
if not file.filename.endswith('.csv'):
flash('Only CSV files are supported', 'error')
return redirect(url_for('settings.import_data'))
try:
stream = io.StringIO(file.stream.read().decode('UTF8'), newline=None)
csv_reader = csv.DictReader(stream)
imported = 0
for row in csv_reader:
category_name = row.get('Category')
category = Category.query.filter_by(name=category_name, user_id=current_user.id).first()
if not category:
category = Category(name=category_name, user_id=current_user.id)
db.session.add(category)
db.session.flush()
expense = Expense(
description=row.get('Description'),
amount=float(row.get('Amount', 0)),
date=datetime.strptime(row.get('Date'), '%Y-%m-%d'),
paid_by=row.get('Paid By'),
tags=row.get('Tags'),
category_id=category.id,
user_id=current_user.id
)
db.session.add(expense)
imported += 1
db.session.commit()
flash(f'Successfully imported {imported} expenses!', 'success')
return redirect(url_for('main.dashboard'))
except Exception as e:
db.session.rollback()
flash(f'Import failed: {str(e)}', 'error')
return redirect(url_for('settings.import_data'))
return render_template('settings/import.html')
# 2FA Management
@bp.route('/2fa/setup', methods=['GET', 'POST'])
@login_required
def setup_2fa():
if request.method == 'POST':
token = request.form.get('token')
if not current_user.totp_secret:
flash('2FA setup not initiated', 'error')
return redirect(url_for('settings.setup_2fa'))
if current_user.verify_totp(token):
current_user.is_2fa_enabled = True
db.session.commit()
flash('2FA enabled successfully!', 'success')
return redirect(url_for('settings.index'))
else:
flash('Invalid code. Please try again.', 'error')
# Generate QR code
if not current_user.totp_secret:
current_user.generate_totp_secret()
db.session.commit()
import qrcode
import io
import base64
uri = current_user.get_totp_uri()
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
return render_template('settings/setup_2fa.html',
qr_code=qr_base64,
secret=current_user.totp_secret)
@bp.route('/2fa/disable', methods=['POST'])
@login_required
def disable_2fa():
current_user.is_2fa_enabled = False
current_user.totp_secret = None
db.session.commit()
flash('2FA disabled successfully', 'success')
return redirect(url_for('settings.index'))

View file

@ -0,0 +1,304 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.subscription import Subscription, RecurringPattern
from app.models.category import Category
from app.smart_detection import (
detect_recurring_expenses,
save_detected_patterns,
get_user_suggestions,
convert_pattern_to_subscription,
dismiss_pattern
)
from datetime import datetime, timedelta
bp = Blueprint('subscriptions', __name__, url_prefix='/subscriptions')
@bp.route('/')
@login_required
def index():
"""View all subscriptions and suggestions"""
subscriptions = Subscription.query.filter_by(
user_id=current_user.id,
is_active=True
).order_by(Subscription.next_due_date).all()
suggestions = get_user_suggestions(current_user.id)
# Calculate total monthly cost
monthly_cost = sum(
sub.amount if sub.frequency == 'monthly' else
sub.amount / 4 if sub.frequency == 'quarterly' else
sub.amount / 12 if sub.frequency == 'yearly' else
sub.amount * 4 if sub.frequency == 'weekly' else
sub.amount * 2 if sub.frequency == 'biweekly' else
sub.amount
for sub in subscriptions
)
yearly_cost = sum(sub.get_annual_cost() for sub in subscriptions)
return render_template('subscriptions/index.html',
subscriptions=subscriptions,
suggestions=suggestions,
monthly_cost=monthly_cost,
yearly_cost=yearly_cost)
@bp.route('/detect', methods=['POST'])
@login_required
def detect():
"""Run detection algorithm to find recurring expenses"""
patterns = detect_recurring_expenses(current_user.id)
if patterns:
saved = save_detected_patterns(patterns)
flash(f'Found {saved} potential subscription(s)!', 'success')
else:
flash('No recurring patterns detected. Add more expenses to improve detection.', 'info')
return redirect(url_for('subscriptions.index'))
@bp.route('/create', methods=['GET', 'POST'])
@login_required
def create():
"""Manually create a subscription"""
if request.method == 'POST':
name = request.form.get('name')
amount = float(request.form.get('amount', 0))
frequency = request.form.get('frequency')
custom_interval_days = request.form.get('custom_interval_days')
category_id = request.form.get('category_id')
start_date = request.form.get('start_date')
end_date = request.form.get('end_date')
total_occurrences = request.form.get('total_occurrences')
auto_create_expense = request.form.get('auto_create_expense') == 'on'
notes = request.form.get('notes')
# Validate custom interval
if frequency == 'custom':
if not custom_interval_days:
flash('Custom interval is required when using custom frequency', 'error')
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/create.html', categories=categories)
interval_value = int(custom_interval_days)
if interval_value < 1 or interval_value > 365:
flash('Custom interval must be between 1 and 365 days', 'error')
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/create.html', categories=categories)
# Parse dates
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() if start_date else datetime.now().date()
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None
subscription = Subscription(
name=name,
amount=amount,
frequency=frequency,
custom_interval_days=int(custom_interval_days) if custom_interval_days and frequency == 'custom' else None,
category_id=category_id,
user_id=current_user.id,
start_date=start_date_obj,
next_due_date=start_date_obj,
end_date=end_date_obj,
total_occurrences=int(total_occurrences) if total_occurrences else None,
auto_create_expense=auto_create_expense,
notes=notes,
is_confirmed=True,
auto_detected=False
)
db.session.add(subscription)
db.session.commit()
flash(f'Subscription "{name}" added successfully!', 'success')
return redirect(url_for('subscriptions.index'))
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/create.html', categories=categories)
@bp.route('/<int:subscription_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(subscription_id):
"""Edit a subscription"""
subscription = Subscription.query.filter_by(
id=subscription_id,
user_id=current_user.id
).first_or_404()
if request.method == 'POST':
frequency = request.form.get('frequency')
custom_interval_days = request.form.get('custom_interval_days')
# Validate custom interval
if frequency == 'custom':
if not custom_interval_days:
flash('Custom interval is required when using custom frequency', 'error')
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/edit.html', subscription=subscription, categories=categories)
interval_value = int(custom_interval_days)
if interval_value < 1 or interval_value > 365:
flash('Custom interval must be between 1 and 365 days', 'error')
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/edit.html', subscription=subscription, categories=categories)
subscription.name = request.form.get('name')
subscription.amount = float(request.form.get('amount', 0))
subscription.frequency = frequency
subscription.custom_interval_days = int(custom_interval_days) if custom_interval_days and frequency == 'custom' else None
subscription.category_id = request.form.get('category_id')
subscription.auto_create_expense = request.form.get('auto_create_expense') == 'on'
subscription.notes = request.form.get('notes')
next_due_date = request.form.get('next_due_date')
if next_due_date:
subscription.next_due_date = datetime.strptime(next_due_date, '%Y-%m-%d').date()
end_date = request.form.get('end_date')
subscription.end_date = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None
total_occurrences = request.form.get('total_occurrences')
subscription.total_occurrences = int(total_occurrences) if total_occurrences else None
db.session.commit()
flash(f'Subscription "{subscription.name}" updated!', 'success')
return redirect(url_for('subscriptions.index'))
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/edit.html',
subscription=subscription,
categories=categories)
@bp.route('/<int:subscription_id>/delete', methods=['POST'])
@login_required
def delete(subscription_id):
"""Delete a subscription"""
subscription = Subscription.query.filter_by(
id=subscription_id,
user_id=current_user.id
).first_or_404()
name = subscription.name
db.session.delete(subscription)
db.session.commit()
flash(f'Subscription "{name}" deleted!', 'success')
return redirect(url_for('subscriptions.index'))
@bp.route('/<int:subscription_id>/toggle', methods=['POST'])
@login_required
def toggle(subscription_id):
"""Toggle subscription active status"""
subscription = Subscription.query.filter_by(
id=subscription_id,
user_id=current_user.id
).first_or_404()
subscription.is_active = not subscription.is_active
db.session.commit()
status = 'activated' if subscription.is_active else 'paused'
flash(f'Subscription "{subscription.name}" {status}!', 'success')
return redirect(url_for('subscriptions.index'))
@bp.route('/suggestion/<int:pattern_id>/accept', methods=['POST'])
@login_required
def accept_suggestion(pattern_id):
"""Accept a detected pattern and convert to subscription"""
subscription = convert_pattern_to_subscription(pattern_id, current_user.id)
if subscription:
flash(f'Subscription "{subscription.name}" added!', 'success')
else:
flash('Could not add subscription.', 'error')
return redirect(url_for('subscriptions.index'))
@bp.route('/suggestion/<int:pattern_id>/dismiss', methods=['POST'])
@login_required
def dismiss_suggestion(pattern_id):
"""Dismiss a detected pattern"""
if dismiss_pattern(pattern_id, current_user.id):
flash('Suggestion dismissed.', 'info')
else:
flash('Could not dismiss suggestion.', 'error')
return redirect(url_for('subscriptions.index'))
@bp.route('/api/upcoming')
@login_required
def api_upcoming():
"""API endpoint for upcoming subscriptions"""
days = int(request.args.get('days', 30))
end_date = datetime.now().date() + timedelta(days=days)
upcoming = Subscription.query.filter(
Subscription.user_id == current_user.id,
Subscription.is_active == True,
Subscription.next_due_date <= end_date
).order_by(Subscription.next_due_date).all()
return jsonify({
'subscriptions': [{
'id': sub.id,
'name': sub.name,
'amount': float(sub.amount),
'next_due_date': sub.next_due_date.isoformat(),
'days_until': (sub.next_due_date - datetime.now().date()).days
} for sub in upcoming]
})
@bp.route('/auto-create', methods=['POST'])
@login_required
def auto_create_expenses():
"""Auto-create expenses for due subscriptions (can be run via cron)"""
from app.models.category import Expense
subscriptions = Subscription.query.filter_by(
user_id=current_user.id,
is_active=True,
auto_create_expense=True
).all()
created_count = 0
for sub in subscriptions:
if sub.should_create_expense_today():
# Create the expense
expense = Expense(
amount=sub.amount,
description=f"{sub.name} (Auto-created)",
date=datetime.now().date(),
category_id=sub.category_id,
user_id=current_user.id
)
db.session.add(expense)
# Update subscription
sub.last_auto_created = datetime.now().date()
sub.advance_next_due_date()
created_count += 1
db.session.commit()
if created_count > 0:
flash(f'Auto-created {created_count} expense(s) from subscriptions!', 'success')
else:
flash('No expenses due for auto-creation today.', 'info')
return redirect(url_for('subscriptions.index'))