181 lines
6.9 KiB
Python
181 lines
6.9 KiB
Python
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))
|