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
+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))