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('//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('//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('//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('//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//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))