expenses_manager/expenses_manager/expenses/views.py
2026-07-27 15:57:44 +02:00

1028 lines
29 KiB
Python

import logging
import calendar
from datetime import date, datetime
from django.contrib import messages
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
from .forms import (
ExpenseForm,
IncomeForm,
TagForm,
AccountForm,
FuelEntryForm,
CategoryForm,
GoalForm,
)
from django.core.paginator import Paginator
from django.db.models import Sum, ProtectedError
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
from django.contrib.auth.decorators import login_required
from django.utils.http import url_has_allowed_host_and_scheme
from django.shortcuts import get_object_or_404, render, redirect
logger = logging.getLogger(__name__)
def _get_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def _redirect_back(request, fallback):
next_url = request.POST.get("next") or request.GET.get("next")
if next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(fallback)
def sub_months(year, month, n):
month -= n
while month <= 0:
month += 12
year -= 1
return year, month
@login_required
def home(request):
expenses = Expense.objects.filter(owner=request.user)
# Last expenses
last_expenses = expenses.select_related("category").order_by("-date")[:5]
# Simple KPIs (current month)
today = date.today()
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or 0
kpi_count = month_expenses.count()
kpi_categories = month_expenses.values("category").distinct().count()
six_months = []
for i in range(5, -1, -1):
y, m = sub_months(today.year, today.month, i)
six_months.append((y, m))
mini_data = []
for y, m in six_months:
total = (
expenses.filter(date__year=y, date__month=m).aggregate(total=Sum("amount"))[
"total"
]
or 0
)
mini_data.append(
{
"label": f"{m}/{y}",
"total": float(total),
}
)
goals = Goal.objects.filter(owner=request.user, show_on_home=True)
return render(
request,
"expenses/home.html",
{
"active_menu": "home",
"last_expenses": last_expenses,
"kpi_total": kpi_total,
"kpi_count": kpi_count,
"kpi_categories": kpi_categories,
"mini_chart_labels": [x["label"] for x in mini_data],
"mini_chart_data": [x["total"] for x in mini_data],
"goals": goals,
},
)
@login_required
def expense_list(request):
expenses = Expense.objects.filter(owner=request.user)
categories = Category.objects.filter(owner=request.user)
year_list = Expense.objects.filter(owner=request.user).dates("date", "year")
months = list(range(1, 13))
# Filters
year = _get_int(request.GET.get("year"))
month = _get_int(request.GET.get("month"))
category = _get_int(request.GET.get("category"))
account_id = _get_int(request.GET.get("account"))
tag_ids = [
t_id
for t in request.GET.getlist("tag")
if (t_id:= _get_int(t)) is not None
]
if year:
expenses = expenses.filter(date__year=year)
if month:
expenses = expenses.filter(date__month=month)
if category:
expenses = expenses.filter(category_id=category)
if tag_ids:
expenses = expenses.filter(tags__id__in=tag_ids).distinct()
if account_id:
expenses = expenses.filter(account_id=account_id)
selected_tags = tag_ids or []
expenses = expenses.order_by("-date")
total_amount = expenses.aggregate(total=Sum("amount"))["total"] or 0
expense_count = expenses.count()
category_count = expenses.values("category").distinct().count()
# Pagination
paginator = Paginator(expenses, 10)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
query_params = request.GET.copy()
query_params.pop("page", None)
# tags with state
tags_with_state = []
for tag in Tag.objects.filter(owner=request.user):
if tag.id in selected_tags:
new_tags = [t for t in selected_tags if t != tag.id]
active = True
else:
new_tags = selected_tags + [tag.id]
active = False
query = "&".join([f"tag={t}" for t in new_tags])
tags_with_state.append(
{
"id": tag.id,
"name": tag.name,
"active": active,
"query": query,
}
)
advanced_filters_open = bool(category or selected_tags)
return render(
request,
"expenses/expense_list.html",
{
"active_menu": "expenses",
"expenses": page_obj,
"page_obj": page_obj,
"selected_year": year,
"selected_month": month,
"selected_category": category,
"categories": categories,
"year_list": [y.year for y in year_list],
"months": months,
"kpi_total": total_amount,
"kpi_count": expense_count,
"kpi_categories": category_count,
"selected_tags": selected_tags,
"tags": Tag.objects.filter(owner=request.user),
"tags_with_state": tags_with_state,
"accounts": Account.objects.filter(owner=request.user),
"selected_account": account_id,
"advanced_filters_open": advanced_filters_open,
"query_params": query_params.urlencode(),
},
)
@login_required
def expense_create(request):
if request.method == "POST":
form = ExpenseForm(request.POST, user=request.user)
if form.is_valid():
expense = form.save(commit=False)
expense.owner = request.user
expense.save()
form.save_m2m()
messages.success(request, "Gasto creado correctamente.")
return redirect("expense_list")
else:
form = ExpenseForm(user=request.user)
return render(
request,
"expenses/expense_form.html",
{"active_menu": "expenses", "form": form},
)
@login_required
def expense_edit(request, pk):
# sourcery skip: assign-if-exp, merge-else-if-into-elif
expense = get_object_or_404(
Expense,
pk=pk,
owner=request.user,
)
if request.method == "POST":
form = ExpenseForm(request.POST or None, instance=expense, user=request.user)
if form.is_valid():
expense = form.save()
messages.success(request, "Gasto actualizado")
return redirect("expense_list")
else:
form = ExpenseForm(instance=expense, user=request.user)
return render(
request,
"expenses/expense_form.html",
{
"active_menu": "expenses",
"form": form,
},
)
@login_required
def expense_delete(request, pk):
expense = get_object_or_404(
Expense,
pk=pk,
owner=request.user,
)
if request.method == "POST":
expense.delete()
messages.success(request, "Gasto eliminado correctamente.")
return redirect("expense_list")
return render(
request,
"expenses/expense_confirm_delete.html",
{"active_menu": "expenses", "expense": expense},
)
@login_required
def dashboard(request):
today = date.today()
period = request.GET.get("period", "")
account_id = _get_int(request.GET.get("account"))
compare_enabled = request.GET.get("compare") == "1"
# Time presets
if period == "this_month":
selected_year, selected_month = today.year, today.month
elif period == "last_month":
selected_year, selected_month = sub_months(today.year, today.month, 1)
elif period == "this_year":
selected_year, selected_month = today.year, None
else:
selected_year = _get_int(request.GET.get("year")) or _get_int(today.year)
selected_month = _get_int(request.GET.get("month"))
selected_month = _get_int(selected_month) if selected_month else None
# Accounts
accounts = Account.objects.filter(owner=request.user, active=True)
selected_account_obj = None
if account_id:
selected_account_obj = accounts.filter(id=account_id).first()
# Calculate the KPI
if selected_account_obj:
kpi_balance = selected_account_obj.current_balance()
else:
kpi_balance = sum(account.current_balance() for account in accounts)
# Filter by base expenses
expenses = Expense.objects.filter(owner=request.user)
if account_id:
expenses = expenses.filter(account_id=account_id)
expenses_filtered = expenses.filter(date__year=selected_year)
if selected_month:
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
# Basic KPIs
total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0
expense_count = expenses_filtered.count()
category_count = expenses_filtered.values("category").distinct().count()
# Total by category
by_category_qs = by_category = (
expenses_filtered.values("category__name")
.annotate(total=Sum("amount"))
.order_by("-total")
)
CATEGORY_LIMIT = 10 # Top 10
by_category_chart = []
for index, row in enumerate(by_category_qs):
if index >= CATEGORY_LIMIT:
break
cat_name = row["category__name"] or "Sin categoría"
total_cat = float(row["total"])
by_category_chart.append({
"category__name": cat_name,
"total": total_cat
})
#Graphic
daily_average = 0
projected_end_of_month = 0
if selected_month:
by_day_qs = (
expenses_filtered.annotate(day=ExtractDay("date"))
.values("day")
.annotate(total=Sum("amount"))
)
day_totals = {row["day"]: float(row["total"]) for row in by_day_qs}
num_days = calendar.monthrange(selected_year, selected_month)[1]
if selected_year == today.year and selected_month == today.month:
days_passed = today.day
else:
days_passed = calendar.monthrange(selected_year, selected_month)[1]
daily_average = total_amount / days_passed if days_passed > 0 else 0
total_days_in_month = calendar.monthrange(selected_year, selected_month)[1]
projected_end_of_month = daily_average * total_days_in_month
chart_labels = list(range(1, num_days + 1))
chart_totals = [day_totals.get(d, 0) for d in chart_labels]
chart_type = "day"
else:
by_month_qs = (
expenses_filtered.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
month_totals = {row["month"]: float(row["total"]) for row in by_month_qs}
chart_labels = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]
chart_totals = [month_totals.get(m, 0) for m in range(1, 13)]
chart_type = "month"
year_list = (
Expense.objects.filter(owner=request.user)
.annotate(year=ExtractYear("date"))
.values_list("year", flat=True)
.distinct()
.order_by("-year")
)
# Comparison
previous_total = 0
kpi_difference = 0
percentage = 0
kpi_trend = "equal"
category_comparison = []
if compare_enabled:
previous_expenses = Expense.objects.filter(owner=request.user)
if account_id:
previous_expenses = previous_expenses.filter(account_id=account_id)
if selected_month:
prev_year, prev_month = sub_months(selected_year, selected_month, 1)
previous_expenses = previous_expenses.filter(date__year=prev_year, date__month=prev_month)
else:
prev_year = selected_year - 1
previous_expenses = previous_expenses.filter(date__year=prev_year)
previous_total = previous_expenses.aggregate(total=Sum("amount"))["total"] or 0
kpi_difference = total_amount - previous_total
if previous_total > 0:
percentage = (kpi_difference / previous_total) * 100
if kpi_difference > 0:
kpi_trend = "up"
elif kpi_difference < 0:
kpi_trend = "down"
# Comparison by category
previous_by_category = previous_expenses.values("category__name").annotate(total=Sum("amount"))
current_map = {row["category__name"]: float(row["total"]) for row in by_category}
previous_map = {row["category__name"]: float(row["total"]) for row in previous_by_category}
for cat in set(current_map) | set(previous_map):
cur_t = current_map.get(cat, 0)
prev_t = previous_map.get(cat, 0)
category_comparison.append({
"category": cat,
"current": cur_t,
"previous": prev_t,
"difference": cur_t - prev_t,
"difference_abs": abs(cur_t - prev_t)
})
# Anual evolution by accounts
accounts_charts = []
for acc in accounts:
try:
monthly_data = acc.monthly_balance(selected_year)
m_balance = [float(row["balance"]) for row in monthly_data]
except Exception:
logger.exception(
"Error calculando monthly_balance para la cuenta %s (año %s)",
acc.id, selected_year
)
m_balance = [0] * 12
if selected_year == today.year:
current_month_index = today.month - 1
if current_month_index < len(m_balance):
m_balance[current_month_index] = float(acc.current_balance())
m_balance = m_balance[:today.month]
accounts_charts.append({
"id": acc.id,
"name": acc.name,
"data": m_balance,
"current_balance": acc.current_balance(),
})
# Goals
goals = Goal.objects.filter(owner=request.user)
# Recent expenses
recent_expenses = Expense.objects.filter(owner=request.user).order_by('-date', '-id')[:7]
# Send the data to the dashboard
return render(
request,
"expenses/dashboard.html",
{
"active_menu": "dashboard",
"today": today,
"by_category": by_category,
"by_category_chart": by_category_chart,
"chart_labels": chart_labels,
"chart_data": chart_totals,
"year_list": year_list,
"months": list(range(1, 13)),
"selected_year": selected_year,
"selected_month": selected_month,
"kpi_total": total_amount,
"kpi_count": expense_count,
"kpi_categories": category_count,
"compare_enabled": compare_enabled,
"kpi_previous_total": previous_total,
"kpi_difference": kpi_difference,
"kpi_difference_abs": abs(kpi_difference),
"kpi_percentage": percentage,
"category_comparison": category_comparison,
"kpi_trend": kpi_trend,
"accounts": accounts,
"selected_account": account_id,
"selected_account_obj": selected_account_obj,
"kpi_balance": kpi_balance,
"accounts_charts": accounts_charts,
"period": period,
"goals": goals,
"recent_expenses": recent_expenses,
"daily_average": daily_average,
"projected_end_of_month": projected_end_of_month,
},
)
@login_required
def tag_list(request):
tags = Tag.objects.filter(owner=request.user)
return render(
request, "expenses/tag_list.html", {"active_menu": "settings", "tags": tags}
)
@login_required
def tag_create(request):
if request.method == "POST":
form = TagForm(request.POST)
if form.is_valid():
tag = form.save(commit=False)
tag.owner = request.user
tag.save()
messages.success(request, "Etiqueta creada correctamente.")
return redirect("tag_list")
else:
form = TagForm()
return render(
request, "expenses/tag_form.html", {"active_menu": "settings", "form": form}
)
@login_required
def tag_edit(request, pk):
tag = get_object_or_404(Tag, pk=pk, owner=request.user)
if request.method == "POST":
form = TagForm(request.POST, instance=tag)
if form.is_valid():
form.save()
messages.success(request, "Etiqueta actualizada.")
return redirect("tag_list")
else:
form = TagForm(instance=tag)
return render(
request, "expenses/tag_form.html", {"active_menu": "settings", "form": form}
)
@login_required
def tag_delete(request, pk):
tag = get_object_or_404(Tag, pk=pk, owner=request.user)
if request.method == "POST":
tag.delete()
messages.success(request, "Etiqueta eliminada.")
return redirect("tag_list")
return render(
request,
"expenses/tag_confirm_delete.html",
{"active_menu": "settings", "tag": tag},
)
@login_required
def account_list(request):
accounts = Account.objects.filter(owner=request.user)
return render(
request,
"expenses/account_list.html",
{"active_menu": "accounts", "accounts": accounts},
)
@login_required
def account_create(request):
if request.method == "POST":
form = AccountForm(request.POST)
if form.is_valid():
account = form.save(commit=False)
account.owner = request.user
account.save()
messages.success(request, "Cuenta creada correctamente.")
return redirect("account_list")
else:
form = AccountForm()
return render(
request, "expenses/account_form.html", {"active_menu": "accounts", "form": form}
)
@login_required
def account_edit(request, pk):
account = get_object_or_404(Account, pk=pk, owner=request.user)
if request.method == "POST":
form = AccountForm(request.POST, instance=account)
if form.is_valid():
form.save()
messages.success(request, "Cuenta actualizada.")
return redirect("account_list")
else:
form = AccountForm(instance=account)
return render(
request, "expenses/account_form.html", {"active_menu": "accounts", "form": form}
)
@login_required
def account_delete(request, pk):
account = get_object_or_404(Account, pk=pk, owner=request.user)
if request.method == "POST":
account.active = False
account.save()
messages.success(request, "Cuenta eliminada.")
return redirect("account_list")
return render(
request,
"expenses/account_confirm_delete.html",
{"active_menu": "accounts", "account": account},
)
@login_required
def income_create(request):
if request.method == "POST":
form = IncomeForm(request.POST, user=request.user)
if form.is_valid():
income = form.save(commit=False)
income.owner = request.user
income.save()
messages.success(request, "Ingreso creado correctamente.")
return redirect("income_list")
else:
form = IncomeForm(user=request.user)
return render(
request, "expenses/income_form.html", {"active_menu": "incomes", "form": form}
)
@login_required
def income_list(request):
incomes = Income.objects.filter(owner=request.user)
return render(
request,
"expenses/income_list.html",
{"active_menu": "incomes", "incomes": incomes},
)
@login_required
def income_edit(request, pk):
income = get_object_or_404(Income, pk=pk, owner=request.user)
if request.method == "POST":
form = IncomeForm(request.POST, instance=income, user=request.user)
if form.is_valid():
form.save()
messages.success(request, "Ingreso actualizado.")
return redirect("income_list")
else:
form = IncomeForm(instance=income, user=request.user)
return render(
request, "expenses/income_form.html", {"active_menu": "incomes", "form": form}
)
@login_required
def income_delete(request, pk):
income = get_object_or_404(Income, pk=pk, owner=request.user)
if request.method == "POST":
income.delete()
messages.success(request, "Ingreso eliminado.")
return redirect("income_list")
return render(
request,
"expenses/income_confirm_delete.html",
{"active_menu": "incomes", "income": income},
)
@login_required
def fuel_create(request):
if request.method == "POST":
form = FuelEntryForm(request.POST, user=request.user)
if form.is_valid():
category, _ = Category.objects.get_or_create(
slug="gasolina",
owner=request.user,
defaults={"name": "Gasolina"},
)
expense = form.save(commit=False)
expense.owner = request.user
expense.category = category
expense.description = "Repostaje"
expense.save()
FuelEntry.objects.create(
expense=expense,
odometer=form.cleaned_data["odometer"],
liters=form.cleaned_data["liters"],
)
return redirect("fuel_list")
else:
form = FuelEntryForm(user=request.user)
return render(
request, "fuel/create.html", {"active_menu": "fuel", "form": form}
)
@login_required
def fuel_list(request):
selected_year = request.GET.get("year")
current_year = datetime.now().year
selected_year = int(selected_year) if selected_year else current_year
fuels = (
FuelEntry.objects.filter(
expense__owner=request.user, expense__date__year=selected_year
)
.select_related("expense")
.order_by("expense__date")
)
monthly_expenses = (
fuels.annotate(month=ExtractMonth("expense__date"))
.values("month")
.annotate(total=Sum("expense__amount"))
)
month_map = {m["month"]: float(m["total"]) for m in monthly_expenses}
monthly_data = [month_map.get(month, 0) for month in range(1, 13)]
year_list = (
FuelEntry.objects.filter(
expense__owner=request.user,
)
.annotate(year=ExtractYear("expense__date"))
.values_list("year", flat=True)
.distinct()
.order_by("year")
)
km_data = []
dates = []
for fuel in fuels:
km = fuel.km_since_previous()
if km:
km_data.append(km)
dates.append(fuel.expense.date.strftime("%Y-%m-%d"))
return render(
request,
"fuel/list.html",
{
"active_menu": "fuel",
"fuels": fuels,
"monthly_data": monthly_data,
"km_data": km_data,
"km_dates": dates,
"selected_year": selected_year,
"year_list": year_list,
},
)
@login_required
def fuel_edit(request, pk):
expense = get_object_or_404(
Expense,
pk=pk,
owner=request.user,
)
fuel = get_object_or_404(FuelEntry, expense=expense)
if request.method == "POST":
form = FuelEntryForm(request.POST, user=request.user)
if form.is_valid():
# Update expense
expense.date = form.save(commit=False)
expense.description = "Repostaje"
expense.save()
# Update FuelEntry
fuel.odometer = form.cleaned_data["odometer"]
fuel.liters = form.cleaned_data["liters"]
fuel.save()
return _redirect_back(request, "expense_list")
else:
form = FuelEntryForm(instance=expense, user=request.user)
next_url = request.POST.get("next") or request.GET.get("next", "")
return render(
request,
"fuel/create.html",
{
"active_menu": "fuel" if next_url.startswith("/fuel") else "expenses",
"form": form,
"editing": True,
"next": next_url
},
)
@login_required
def fuel_delete(request, pk):
expense = get_object_or_404(Expense, pk=pk, owner=request.user)
fuel = get_object_or_404(FuelEntry, expense=expense)
if request.method == "POST":
expense.delete()
messages.success(request, "Repostaje eliminado.")
return _redirect_back(request, "fuel_list")
next_url = request.POST.get("next") or request.GET.get("next", "")
return render(
request,
"fuel/confirm_delete.html",
{
"active_menu":"fuel" if next_url.startswith("/fuel") else "expenses",
"fuel": fuel,
"next": next_url
},
)
@login_required
def category_list(request):
categories = Category.objects.filter(owner=request.user)
if request.method == "POST":
form = CategoryForm(request.POST, user=request.user)
if form.is_valid():
category = form.save(commit=False)
category.owner = request.user
category.save()
return redirect("category_list")
else:
form = CategoryForm(user=request.user)
return render(
request,
"categories/list.html",
{
"active_menu": "settings",
"categories": categories,
"form": form,
},
)
@login_required
def category_edit(request, pk):
category = get_object_or_404(Category, pk=pk, owner=request.user)
if request.method == "POST":
form = CategoryForm(request.POST, instance=category, user=request.user)
if form.is_valid():
form.save()
messages.success(request, "Categoría actualizada")
return redirect("category_list")
else:
form = CategoryForm(instance=category, user=request.user)
return render(
request,
"categories/form.html",
{
"active_menu": "settings",
"form":form,
"category": category,
}
)
@login_required
def category_delete(request, pk):
category = get_object_or_404(Category, pk=pk, owner=request.user)
if request.method == "POST":
try:
category.delete()
except ProtectedError:
messages.error(
request,
f"No se puede eliminar «{category.name}»; tiene gastos asociados. "
"Reasigna esos gastos a otra categoría antes de borrarla."
)
else:
messages.success(request, "Categoría eliminada.")
return redirect("category_list")
return render(
request,
"categories/confirm_delete.html",
{
"active_menu": "settings",
"category": category,
"children": category.children.all(),
"expense_count": category.expenses.count(),
"goal_count": category.goal_set.count(),
},
)
@login_required
def goal_list(request):
goals = Goal.objects.filter(owner=request.user)
return render(
request,
"goals/list.html",
{
"goals": goals,
"active_menu": "settings",
},
)
@login_required
def goal_create(request):
if request.method == "POST":
form = GoalForm(request.POST, user=request.user)
if form.is_valid():
goal = form.save(commit=False)
goal.owner = request.user
goal.save()
return redirect("goal_list")
else:
form = GoalForm(user=request.user)
return render(
request,
"goals/form.html",
{
"form": form,
"title": "Nuevo objetivo",
"active_menu": "settings",
},
)
@login_required
def goal_edit(request, pk):
goal = get_object_or_404(Goal,
pk=pk,
owner=request.user)
if request.method == "POST":
form = GoalForm(request.POST, instance=goal, user=request.user)
if form.is_valid():
form.save()
return redirect("goal_list")
else:
form = GoalForm(instance=goal, user=request.user)
return render(
request,
"goals/form.html",
{
"form": form,
"title": "Editar objetivo",
"active_menu": "settings",
},
)
@login_required
def goal_delete(request, pk):
goal = get_object_or_404(Goal,
pk=pk,
owner=request.user)
if request.method == "POST":
goal.delete()
messages.success(request, "Objetivo eliminado.")
return redirect("goal_list")
return render(
request,
"goals/confirm_delete.html",
{"active_menu":"settings", "goal":goal}
)
@login_required
def settings_index(request):
return render(request, "settings/index.html", {"active_menu": "settings"})