248 lines
9.0 KiB
Python
248 lines
9.0 KiB
Python
from flask import (Blueprint, render_template, request, redirect, url_for,
|
|
Response, flash, send_file, stream_with_context)
|
|
from flask_login import login_required, current_user
|
|
from app.models.transaction import Transaction
|
|
from app.services.report_service import (
|
|
monthly_report, quarterly_report, yearly_report,
|
|
net_worth_history, category_trends, tax_year_summary,
|
|
take_net_worth_snapshot, category_mom_comparison,
|
|
)
|
|
from app.services.export_service import (
|
|
transactions_csv_stream, transactions_to_excel,
|
|
report_to_pdf, build_report_html,
|
|
)
|
|
from datetime import date
|
|
import io
|
|
|
|
reports_bp = Blueprint('reports', __name__, url_prefix='/reports')
|
|
|
|
_CUR_YEAR = date.today().year
|
|
_CUR_MONTH = date.today().month
|
|
|
|
|
|
@reports_bp.route('/')
|
|
@login_required
|
|
def index():
|
|
today = date.today()
|
|
report = monthly_report(today.year, today.month)
|
|
nw_history = net_worth_history()
|
|
cat_trend = category_trends(6)
|
|
mom_data = category_mom_comparison()
|
|
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
|
return render_template('reports/index.html',
|
|
report=report,
|
|
nw_history=nw_history,
|
|
cat_trend=cat_trend,
|
|
mom_data=mom_data,
|
|
years=years,
|
|
current_year=_CUR_YEAR,
|
|
current_month=_CUR_MONTH,
|
|
report_type='monthly',
|
|
selected_year=today.year,
|
|
selected_month=today.month,
|
|
selected_quarter=None)
|
|
|
|
|
|
@reports_bp.route('/monthly')
|
|
@login_required
|
|
def monthly():
|
|
year = request.args.get('year', _CUR_YEAR, type=int)
|
|
month = request.args.get('month', _CUR_MONTH, type=int)
|
|
report = monthly_report(year, month)
|
|
nw_history = net_worth_history()
|
|
cat_trend = category_trends(6)
|
|
mom_data = category_mom_comparison()
|
|
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
|
return render_template('reports/index.html',
|
|
report=report,
|
|
nw_history=nw_history,
|
|
cat_trend=cat_trend,
|
|
mom_data=mom_data,
|
|
years=years,
|
|
current_year=_CUR_YEAR,
|
|
current_month=_CUR_MONTH,
|
|
report_type='monthly',
|
|
selected_year=year,
|
|
selected_month=month,
|
|
selected_quarter=None)
|
|
|
|
|
|
@reports_bp.route('/quarterly')
|
|
@login_required
|
|
def quarterly():
|
|
year = request.args.get('year', _CUR_YEAR, type=int)
|
|
quarter = request.args.get('quarter', ((_CUR_MONTH - 1) // 3) + 1, type=int)
|
|
report = quarterly_report(year, quarter)
|
|
nw_history = net_worth_history()
|
|
cat_trend = category_trends(6)
|
|
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
|
return render_template('reports/index.html',
|
|
report=report,
|
|
nw_history=nw_history,
|
|
cat_trend=cat_trend,
|
|
mom_data=[],
|
|
years=years,
|
|
current_year=_CUR_YEAR,
|
|
current_month=_CUR_MONTH,
|
|
report_type='quarterly',
|
|
selected_year=year,
|
|
selected_month=None,
|
|
selected_quarter=quarter)
|
|
|
|
|
|
@reports_bp.route('/yearly')
|
|
@login_required
|
|
def yearly():
|
|
year = request.args.get('year', _CUR_YEAR, type=int)
|
|
report = yearly_report(year)
|
|
nw_history = net_worth_history()
|
|
cat_trend = category_trends(12)
|
|
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
|
return render_template('reports/index.html',
|
|
report=report,
|
|
nw_history=nw_history,
|
|
cat_trend=cat_trend,
|
|
mom_data=[],
|
|
years=years,
|
|
current_year=_CUR_YEAR,
|
|
current_month=_CUR_MONTH,
|
|
report_type='yearly',
|
|
selected_year=year,
|
|
selected_month=None,
|
|
selected_quarter=None)
|
|
|
|
|
|
@reports_bp.route('/tax')
|
|
@login_required
|
|
def tax():
|
|
year = request.args.get('year', _CUR_YEAR, type=int)
|
|
report = tax_year_summary(year)
|
|
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
|
return render_template('reports/tax.html',
|
|
report=report,
|
|
years=years,
|
|
selected_year=year)
|
|
|
|
|
|
# ── Exports ───────────────────────────────────────────────────────────────────
|
|
|
|
@reports_bp.route('/export/csv')
|
|
@login_required
|
|
def export_csv():
|
|
year = request.args.get('year', _CUR_YEAR, type=int)
|
|
month = request.args.get('month', type=int)
|
|
txn_type = request.args.get('type', 'all')
|
|
|
|
query = Transaction.query.filter(
|
|
Transaction.date >= date(year, month if month else 1, 1),
|
|
Transaction.date <= date(year, month if month else 12,
|
|
31 if not month else
|
|
__import__('calendar').monthrange(year, month)[1]),
|
|
)
|
|
if txn_type != 'all':
|
|
query = query.filter(Transaction.transaction_type == txn_type)
|
|
query = query.order_by(Transaction.date.desc())
|
|
|
|
label = f'{year}-{month:02d}' if month else str(year)
|
|
filename = f'pfm_transactions_{label}.csv'
|
|
|
|
return Response(
|
|
stream_with_context(transactions_csv_stream(query)),
|
|
mimetype='text/csv',
|
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
|
)
|
|
|
|
|
|
@reports_bp.route('/export/excel')
|
|
@login_required
|
|
def export_excel():
|
|
year = request.args.get('year', _CUR_YEAR, type=int)
|
|
month = request.args.get('month', type=int)
|
|
|
|
query = Transaction.query.filter(
|
|
Transaction.date >= date(year, month if month else 1, 1),
|
|
Transaction.date <= date(year, month if month else 12,
|
|
31 if not month else
|
|
__import__('calendar').monthrange(year, month)[1]),
|
|
Transaction.transaction_type.in_(['income', 'expense']),
|
|
)
|
|
query = query.order_by(Transaction.date.desc())
|
|
|
|
label = f'{year}-{month:02d}' if month else str(year)
|
|
period_label = f'{year} Month {month}' if month else str(year)
|
|
excel_bytes = transactions_to_excel(query, period_label)
|
|
filename = f'pfm_transactions_{label}.xlsx'
|
|
|
|
return send_file(
|
|
io.BytesIO(excel_bytes),
|
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
as_attachment=True,
|
|
download_name=filename,
|
|
)
|
|
|
|
|
|
@reports_bp.route('/export/pdf')
|
|
@login_required
|
|
def export_pdf():
|
|
report_type = request.args.get('type', 'monthly')
|
|
year = request.args.get('year', _CUR_YEAR, type=int)
|
|
month = request.args.get('month', _CUR_MONTH, type=int)
|
|
quarter = request.args.get('quarter', 1, type=int)
|
|
|
|
if report_type == 'yearly':
|
|
report = yearly_report(year)
|
|
elif report_type == 'quarterly':
|
|
report = quarterly_report(year, quarter)
|
|
else:
|
|
report = monthly_report(year, month)
|
|
|
|
symbol = current_user.currency_symbol or '$'
|
|
html = build_report_html(report, report_type, symbol)
|
|
pdf = report_to_pdf(html)
|
|
|
|
if pdf is None:
|
|
flash('PDF generation failed. WeasyPrint may not be installed.', 'danger')
|
|
return redirect(url_for('reports.index'))
|
|
|
|
filename = f'pfm_report_{report["period"].replace(" ", "_")}.pdf'
|
|
return send_file(
|
|
io.BytesIO(pdf),
|
|
mimetype='application/pdf',
|
|
as_attachment=True,
|
|
download_name=filename,
|
|
)
|
|
|
|
|
|
@reports_bp.route('/snapshot', methods=['POST'])
|
|
@login_required
|
|
def manual_snapshot():
|
|
"""Manually trigger a net worth snapshot."""
|
|
snap = take_net_worth_snapshot()
|
|
flash(f'Net worth snapshot saved: {snap.snapshot_date.strftime("%B %d, %Y")}', 'success')
|
|
return redirect(url_for('reports.index'))
|
|
|
|
|
|
@reports_bp.route('/rebuild-snapshot', methods=['POST'])
|
|
@login_required
|
|
def rebuild_snapshot():
|
|
"""Recalculate all balances then replace today's snapshot with fresh data."""
|
|
from app.services.account_service import recalc_all
|
|
from app.models.net_worth_snapshot import NetWorthSnapshot
|
|
|
|
recalc_all()
|
|
|
|
# Delete today's snapshot so take_net_worth_snapshot() creates a fresh one
|
|
today = date.today()
|
|
existing = NetWorthSnapshot.query.filter_by(snapshot_date=today).first()
|
|
if existing:
|
|
from app.extensions import db
|
|
db.session.delete(existing)
|
|
db.session.commit()
|
|
|
|
snap = take_net_worth_snapshot()
|
|
flash(
|
|
f'Balances recalculated and snapshot rebuilt for {snap.snapshot_date.strftime("%B %d, %Y")}.',
|
|
'success'
|
|
)
|
|
return redirect(url_for('reports.index'))
|