05/31 Phase 3
This commit is contained in:
@@ -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))
|
||||
@@ -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