Files
kakebo/app/monthly/views.py
Andros Fenollosa 625bc22362 Add CSV export, budget subtracts planned expenses
- Export all expenses as CSV from Settings (semicolon, UTF-8 BOM)
- Budget calculation now includes planned expenses:
  income - fixed - savings - planned
- Budget live update also accounts for planned expenses
2026-03-29 11:53:54 +02:00

199 lines
5.6 KiB
Python

import calendar
from datetime import date, timedelta
from decimal import Decimal
from django.db.models import Sum
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from app.expenses.models import Category, Expense, FixedExpenseConcept
from app.yearly.models import PlannedExpense
from .models import Income, MonthlyFixedExpense, MonthlyGoal, MonthlyNote
def _offset_month(year, month, offset):
"""Shift year/month by offset months."""
total = (year * 12 + month - 1) + offset
return total // 12, total % 12 + 1
def _get_month_weeks(year, month):
"""Return list of (start_date, end_date) for each week of the month."""
first_day = date(year, month, 1)
last_day = date(year, month, calendar.monthrange(year, month)[1])
weeks = []
# Start from the Monday of the first week
current = first_day - timedelta(days=first_day.weekday())
while current <= last_day:
week_start = max(current, first_day)
week_end = min(current + timedelta(days=6), last_day)
weeks.append((week_start, week_end))
current += timedelta(days=7)
return weeks
def _build_month_end_data(year, month):
"""Build weekly expense breakdown for month end section."""
weeks = _get_month_weeks(year, month)
expenses = Expense.objects.filter(
created_at__date__year=year,
created_at__date__month=month,
).select_related("category", "subcategory")
# Weekly totals
weekly_totals = []
for start, end in weeks:
week_total = sum(
e.amount for e in expenses if start <= e.created_at.date() <= end
)
weekly_totals.append(week_total)
monthly_expense_total = sum(weekly_totals)
# Category breakdown by week
categories = Category.objects.prefetch_related("subcategories").all()
category_weeks = []
for cat in categories:
cat_expenses = [e for e in expenses if e.category_id == cat.id]
if not cat_expenses:
continue
cat_weekly = []
for start, end in weeks:
wk_total = sum(
e.amount for e in cat_expenses if start <= e.created_at.date() <= end
)
cat_weekly.append(wk_total)
cat_total = sum(cat_weekly)
category_weeks.append(
{
"category": cat,
"weekly": cat_weekly,
"total": cat_total,
}
)
# Transpose: rows=weeks, cols=categories
week_rows = []
for i in range(len(weeks)):
row = []
for cw in category_weeks:
row.append(cw["weekly"][i])
week_rows.append({"week_num": i + 1, "values": row})
return {
"weeks": weeks,
"week_count": len(weeks),
"weekly_totals": weekly_totals,
"monthly_expense_total": monthly_expense_total,
"category_weeks": category_weeks,
"week_rows": week_rows,
}
@login_required
def month_view(request):
today = timezone.localdate()
offset = int(request.GET.get("offset", 0))
year, month = _offset_month(today.year, today.month, offset)
current_month = date(year, month, 1)
is_current_month = offset == 0
note, _ = MonthlyNote.objects.get_or_create(year=year, month=month)
# Income
incomes = Income.objects.filter(date__year=year, date__month=month)
total = incomes.aggregate(t=Sum("amount"))["t"] or Decimal("0")
# Clone months for income
available_income_months = (
Income.objects.values_list("date__year", "date__month")
.distinct()
.order_by("-date__year", "-date__month")
)
clone_months = [
(y, m) for y, m in available_income_months if not (y == year and m == month)
]
# Fixed expenses (bulk create missing, then fetch all)
concepts = FixedExpenseConcept.objects.all()
existing_ids = set(
MonthlyFixedExpense.objects.filter(year=year, month=month).values_list(
"concept_id", flat=True
)
)
to_create = [
MonthlyFixedExpense(year=year, month=month, concept=c)
for c in concepts
if c.id not in existing_ids
]
if to_create:
MonthlyFixedExpense.objects.bulk_create(to_create)
fe_entries = MonthlyFixedExpense.objects.filter(
year=year, month=month
).select_related("concept")
fe_total = fe_entries.aggregate(t=Sum("amount"))["t"] or Decimal("0")
# Clone months for fixed expenses
available_fe_months = (
MonthlyFixedExpense.objects.filter(amount__gt=0)
.values_list("year", "month")
.distinct()
.order_by("-year", "-month")
)
clone_fe_months = [
(y, m) for y, m in available_fe_months if not (y == year and m == month)
]
# Default previous month
prev_year, prev_month = _offset_month(year, month, -1)
# Planned expenses for this month
planned_expenses = PlannedExpense.objects.filter(year=year, month=month)
planned_total = sum(pe.amount for pe in planned_expenses)
# Budget (income - fixed - savings - planned)
budget = total - fe_total - note.savings_target - planned_total
# Month end data
month_end = _build_month_end_data(year, month)
# Include planned expenses in month end total
month_end["monthly_expense_total"] += planned_total
context = {
"note": note,
"current_month": current_month,
"incomes": incomes,
"total": total,
"year": year,
"month": month,
"today": today.isoformat(),
"clone_months": clone_months,
"clone_fe_months": clone_fe_months,
"prev_year": prev_year,
"prev_month": prev_month,
"fe_entries": fe_entries,
"fe_total": fe_total,
"savings_target": note.savings_target,
"budget": budget,
"goals": MonthlyGoal.objects.filter(
year=year, month=month, kind=MonthlyGoal.GOAL
),
"promises": MonthlyGoal.objects.filter(
year=year, month=month, kind=MonthlyGoal.PROMISE
),
"offset": offset,
"prev_offset": offset - 1,
"next_offset": offset + 1,
"is_current_month": is_current_month,
# Month end
"month_end": month_end,
"savings_result": budget - month_end["monthly_expense_total"],
# Planned expenses
"planned_expenses": planned_expenses,
"planned_total": planned_total,
}
return render(request, "pages/monthly/month.html", context)