05/31 Phase 3
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user