05/31 Phase 3

This commit is contained in:
2026-05-31 16:18:37 -04:00
parent 9f2a0acc38
commit 1560041cdb
13 changed files with 1152 additions and 10 deletions
+163
View File
@@ -0,0 +1,163 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import SelectField, DecimalField, BooleanField, SubmitField, HiddenField
from wtforms.validators import DataRequired, NumberRange
from app.extensions import db
from app.models.budget import Budget
from app.models.category import Category
from app.services.budget_service import get_budget_summary, get_total_budget, get_total_spent
from datetime import date
budgets_bp = Blueprint('budgets', __name__, url_prefix='/budgets')
def _current_month():
return date.today().strftime('%Y-%m')
def _expense_category_choices():
cats = Category.query.filter(
Category.category_type.in_(['expense', 'both']),
Category.is_active == True,
Category.parent_id == None,
).order_by(Category.name).all()
return [(str(c.id), c.name) for c in cats]
class BudgetForm(FlaskForm):
category_id = SelectField('Category', validators=[DataRequired()])
month = HiddenField()
limit_amount = DecimalField('Monthly Limit', validators=[DataRequired(), NumberRange(min=0.01)], places=2)
rollover_enabled = BooleanField('Roll over unused amount to next month')
submit = SubmitField('Save Budget')
@budgets_bp.route('/')
@login_required
def index():
month = request.args.get('month', _current_month())
summary = get_budget_summary(month)
total_budget = get_total_budget(month)
total_spent = get_total_spent(month)
overall_pct = min(round((total_spent / total_budget * 100), 1), 100) if total_budget > 0 else 0
# Month navigation
year, mo = map(int, month.split('-'))
if mo == 1:
prev_month = f'{year-1}-12'
else:
prev_month = f'{year}-{mo-1:02d}'
if mo == 12:
next_month = f'{year+1}-01'
else:
next_month = f'{year}-{mo+1:02d}'
return render_template('budgets/index.html',
month=month,
month_label=date(year, mo, 1).strftime('%B %Y'),
prev_month=prev_month,
next_month=next_month,
summary=summary,
total_budget=total_budget,
total_spent=total_spent,
overall_pct=overall_pct)
@budgets_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
month = request.args.get('month', _current_month())
form = BudgetForm()
form.category_id.choices = _expense_category_choices()
form.month.data = month
if form.validate_on_submit():
month_val = form.month.data or month
existing = Budget.query.filter_by(
category_id=int(form.category_id.data),
month=month_val
).first()
if existing:
flash('Budget for this category already exists this month. Edit it instead.', 'warning')
return redirect(url_for('budgets.index', month=month_val))
budget = Budget(
category_id=int(form.category_id.data),
month=month_val,
limit_amount=form.limit_amount.data,
rollover_enabled=form.rollover_enabled.data,
rollover_amount=0,
)
db.session.add(budget)
db.session.commit()
flash('Budget created.', 'success')
return redirect(url_for('budgets.index', month=month_val))
return render_template('budgets/form.html', form=form, month=month,
title='New Budget', month_label=date(*map(int, month.split('-')), 1).strftime('%B %Y'))
@budgets_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
budget = db.get_or_404(Budget, id)
form = BudgetForm(obj=budget)
form.category_id.choices = _expense_category_choices()
if request.method == 'GET':
form.category_id.data = str(budget.category_id)
form.month.data = budget.month
if form.validate_on_submit():
budget.limit_amount = form.limit_amount.data
budget.rollover_enabled = form.rollover_enabled.data
db.session.commit()
flash('Budget updated.', 'success')
return redirect(url_for('budgets.index', month=budget.month))
year, mo = map(int, budget.month.split('-'))
return render_template('budgets/form.html', form=form, month=budget.month,
title='Edit Budget', budget=budget,
month_label=date(year, mo, 1).strftime('%B %Y'))
@budgets_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
budget = db.get_or_404(Budget, id)
month = budget.month
db.session.delete(budget)
db.session.commit()
flash('Budget removed.', 'info')
return redirect(url_for('budgets.index', month=month))
@budgets_bp.route('/copy', methods=['POST'])
@login_required
def copy_month():
"""Copy all budgets from one month to another."""
from_month = request.form.get('from_month')
to_month = request.form.get('to_month')
if not from_month or not to_month:
flash('Invalid months.', 'danger')
return redirect(url_for('budgets.index'))
from_budgets = Budget.query.filter_by(month=from_month).all()
copied = 0
for fb in from_budgets:
existing = Budget.query.filter_by(
month=to_month, category_id=fb.category_id
).first()
if not existing:
db.session.add(Budget(
category_id=fb.category_id,
month=to_month,
limit_amount=fb.limit_amount,
rollover_enabled=fb.rollover_enabled,
rollover_amount=0,
))
copied += 1
db.session.commit()
flash(f'Copied {copied} budget(s) to {to_month}.', 'success')
return redirect(url_for('budgets.index', month=to_month))
+180
View File
@@ -0,0 +1,180 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, DecimalField, DateField, SelectField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Optional, NumberRange, Length
from app.extensions import db
from app.models.goal import Goal, GoalContribution
from app.models.account import Account
from app.services.goal_service import get_projected_completion, get_emergency_fund_status
from datetime import date
goals_bp = Blueprint('goals', __name__, url_prefix='/goals')
GOAL_COLORS = ['#10b981','#3b82f6','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#f97316']
GOAL_ICONS = [
'bi-piggy-bank','bi-house','bi-airplane','bi-car-front','bi-mortarboard',
'bi-heart-pulse','bi-laptop','bi-gift','bi-trophy','bi-stars',
'bi-umbrella','bi-bicycle','bi-camera','bi-music-note',
]
def _account_choices():
accts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
return [('', '— None —')] + [(str(a.id), a.name) for a in accts]
class GoalForm(FlaskForm):
name = StringField('Goal Name', validators=[DataRequired(), Length(1, 100)])
description = TextAreaField('Description', validators=[Optional()])
target_amount = DecimalField('Target Amount', validators=[DataRequired(), NumberRange(min=0.01)], places=2)
target_date = DateField('Target Date', validators=[Optional()])
linked_account_id = SelectField('Linked Account', validators=[Optional()])
color = StringField('Color', default='#10b981')
icon = StringField('Icon', default='bi-piggy-bank')
submit = SubmitField('Save Goal')
class ContributionForm(FlaskForm):
amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)], places=2)
date = DateField('Date', validators=[DataRequired()], default=date.today)
notes = StringField('Notes', validators=[Optional(), Length(max=255)])
submit = SubmitField('Add Contribution')
@goals_bp.route('/')
@login_required
def index():
active_goals = Goal.query.filter_by(is_completed=False).order_by(Goal.created_at.desc()).all()
completed_goals = Goal.query.filter_by(is_completed=True).order_by(Goal.completed_at.desc()).limit(5).all()
emergency = get_emergency_fund_status()
projections = {}
for g in active_goals:
proj = get_projected_completion(g)
projections[g.id] = proj
return render_template('goals/index.html',
active_goals=active_goals,
completed_goals=completed_goals,
projections=projections,
emergency=emergency)
@goals_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
form = GoalForm()
form.linked_account_id.choices = _account_choices()
if form.validate_on_submit():
goal = Goal(
name=form.name.data.strip(),
description=form.description.data,
target_amount=form.target_amount.data,
target_date=form.target_date.data,
linked_account_id=int(form.linked_account_id.data) if form.linked_account_id.data else None,
color=form.color.data or '#10b981',
icon=form.icon.data or 'bi-piggy-bank',
current_amount=0,
)
db.session.add(goal)
db.session.commit()
flash(f'Goal "{goal.name}" created.', 'success')
return redirect(url_for('goals.index'))
return render_template('goals/form.html', form=form, title='New Goal',
colors=GOAL_COLORS, icons=GOAL_ICONS)
@goals_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
goal = db.get_or_404(Goal, id)
form = GoalForm(obj=goal)
form.linked_account_id.choices = _account_choices()
if request.method == 'GET':
form.linked_account_id.data = str(goal.linked_account_id) if goal.linked_account_id else ''
if form.validate_on_submit():
goal.name = form.name.data.strip()
goal.description = form.description.data
goal.target_amount = form.target_amount.data
goal.target_date = form.target_date.data
goal.linked_account_id = int(form.linked_account_id.data) if form.linked_account_id.data else None
goal.color = form.color.data or goal.color
goal.icon = form.icon.data or goal.icon
db.session.commit()
flash('Goal updated.', 'success')
return redirect(url_for('goals.index'))
return render_template('goals/form.html', form=form, title='Edit Goal',
goal=goal, colors=GOAL_COLORS, icons=GOAL_ICONS)
@goals_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
goal = db.get_or_404(Goal, id)
db.session.delete(goal)
db.session.commit()
flash(f'Goal "{goal.name}" deleted.', 'info')
return redirect(url_for('goals.index'))
@goals_bp.route('/<int:id>/contribute', methods=['GET', 'POST'])
@login_required
def contribute(id):
goal = db.get_or_404(Goal, id)
form = ContributionForm()
if form.validate_on_submit():
contrib = GoalContribution(
goal_id=goal.id,
amount=form.amount.data,
date=form.date.data,
notes=form.notes.data,
)
db.session.add(contrib)
goal.current_amount = float(goal.current_amount) + float(form.amount.data)
# Auto-complete if target reached
if float(goal.current_amount) >= float(goal.target_amount):
from datetime import datetime
goal.is_completed = True
goal.completed_at = datetime.utcnow()
flash(f'🎉 Goal "{goal.name}" completed!', 'success')
else:
flash(f'Contribution of {form.amount.data} added to "{goal.name}".', 'success')
db.session.commit()
return redirect(url_for('goals.index'))
return render_template('goals/contribute.html', form=form, goal=goal)
@goals_bp.route('/<int:id>/contributions')
@login_required
def contributions(id):
goal = db.get_or_404(Goal, id)
contribs = goal.contributions.order_by(GoalContribution.date.desc()).all()
projection = get_projected_completion(goal)
return render_template('goals/contributions.html',
goal=goal, contribs=contribs, projection=projection)
@goals_bp.route('/contributions/<int:id>/delete', methods=['POST'])
@login_required
def delete_contribution(id):
contrib = db.get_or_404(GoalContribution, id)
goal = contrib.goal
goal.current_amount = max(float(goal.current_amount) - float(contrib.amount), 0)
if goal.is_completed and float(goal.current_amount) < float(goal.target_amount):
goal.is_completed = False
goal.completed_at = None
db.session.delete(contrib)
db.session.commit()
flash('Contribution removed.', 'info')
return redirect(url_for('goals.contributions', id=goal.id))