expenses_manager/expenses_manager/expenses/views.py
JKuijperM 488e370a04 feat: redirección del check is_transfer al formulario de traspaso
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:52:13 +02:00

1670 lines
52 KiB
Python

import logging
import calendar
from datetime import date, datetime, timedelta
from urllib.parse import urlencode
from decimal import Decimal
from django.contrib import messages
from .models import (
Account,
Category,
Expense,
FuelEntry,
Tag,
Income,
Goal,
Transfer,
)
from .forms import (
ExpenseForm,
IncomeForm,
TagForm,
AccountForm,
FuelEntryForm,
CategoryForm,
GoalForm,
TransferForm,
)
from django.core.paginator import Paginator
from django.db.models import Sum, Count, Min, ProtectedError
from django.db.models.functions import (
ExtractMonth,
ExtractYear,
ExtractDay,
Lower,
TruncMonth,
)
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
from django.urls import reverse
logger = logging.getLogger(__name__)
def _get_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def _get_date(value):
"""Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida."""
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except (TypeError, ValueError):
return None
def _get_decimal(value):
"""Parsea un importe de la URL. None si falta o no es un número usable.
Se descartan NaN e Infinity aparte: Decimal los acepta pero luego revientan
en cuanto se comparan o se guardan.
"""
try:
number = Decimal(value)
except (TypeError, ValueError, ArithmeticError):
return None
return number if number.is_finite() else None
# Umbral del eje del gráfico con rango libre: dos meses es donde un gráfico
# diario deja de leerse, así que por encima se agrupa por mes.
RANGE_DAY_CHART_MAX_DAYS = 62
def _resolve_date_range(request):
"""Lee el rango de fechas libre de la URL.
Devuelve (date_from, date_to); cualquiera de los dos puede ser None, porque
"desde el 1 de marzo" sin fecha final es un filtro válido.
Si el rango está invertido (desde > hasta) se ignoran LAS DOS fechas: devolver
una lista vacía sin explicación es peor que ignorar un filtro mal puesto.
"""
date_from = _get_date(request.GET.get("date_from"))
date_to = _get_date(request.GET.get("date_to"))
if date_from and date_to and date_from > date_to:
return None, None
return date_from, date_to
def _safe_next(request):
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 next_url
return ""
def _redirect_back(request, fallback):
next_url = _safe_next(request)
return redirect(next_url or fallback)
def sub_months(year, month, n):
month -= n
while month <= 0:
month += 12
year -= 1
return year, month
def _category_tree(categories):
"""Aplana las categorías en preorden (padres antes que hijos, cada nivel
ordenado por nombre) junto con su profundidad, para poder indentarlas."""
by_parent = {}
for category in categories:
by_parent.setdefault(category.parent_id, []).append(category)
for children in by_parent.values():
children.sort(key=lambda c: c.name.lower())
rows = []
def walk(parent_id, depth):
for category in by_parent.get(parent_id, []):
rows.append({"category": category, "depth": depth})
walk(category.id, depth + 1)
walk(None, 0)
return rows
def _analysis_expenses(user):
"""Gastos para análisis: excluye los traspasos entre cuentas propias.
Un traspaso mueve dinero entre cuentas del usuario, así que suma al saldo de
cada cuenta pero NO es gasto real. Usa este helper en KPIs, gráficos y
comparativas.
NO lo uses en cálculos de saldo (_account_balances, current_balance,
monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan,
y filtrarlos descuadraría todos los saldos.
"""
return Expense.objects.filter(owner=user).exclude(is_transfer=True)
def _reject_transfer_leg(request, movement):
"""Corta la edición o el borrado suelto de una pata de traspaso.
Devuelve un redirect a la edición del traspaso, o None si el movimiento es
normal y se puede tocar. Las patas las mantiene cuadradas Transfer.sync(),
así que tocar una por separado descuadraría la otra sin que nada lo avise:
es justo el problema que el modelo Transfer viene a resolver.
Los listados ya no enseñan esos enlaces, pero la URL sigue ahí a mano.
"""
transfer = movement.transfer
if transfer is None:
return None
messages.error(
request,
"Este movimiento forma parte de un traspaso. Edítalo o bórralo desde el "
"traspaso para que las dos cuentas sigan cuadradas.",
)
return redirect("transfer_edit", pk=transfer.pk)
def _transfer_create_url(data, account_param):
"""URL de "nuevo traspaso" arrastrando lo que ya se hubiera escrito.
`data` es el POST de un formulario de gasto o de ingreso y `account_param` el
papel que juega su cuenta en el traspaso: "from_account" si viene de un gasto
(el dinero sale de ahí) y "to_account" si viene de un ingreso.
Lo que no se pueda interpretar se deja fuera: el formulario de traspaso saldrá
con ese campo vacío, que es mejor que un error por un dato a medio escribir.
"""
params = {}
if (date_value := _get_date(data.get("date"))) is not None:
params["date"] = date_value.isoformat()
if (amount := _get_decimal(data.get("amount"))) is not None:
params["amount"] = str(amount)
if (account_id := _get_int(data.get("account"))) is not None:
params[account_param] = account_id
url = reverse("transfer_create")
return f"{url}?{urlencode(params)}" if params else url
def _transfer_initial(request):
"""Datos de arranque del formulario de traspaso leídos de la URL.
Los manda el formulario de gasto o de ingreso cuando el usuario marca la
casilla de traspaso. Nada de esto es de fiar (es la URL): lo que no se pueda
interpretar se ignora y el campo sale vacío.
"""
initial = {}
if (date_value := _get_date(request.GET.get("date"))) is not None:
initial["date"] = date_value
if (amount := _get_decimal(request.GET.get("amount"))) is not None:
initial["amount"] = amount
# Las cuentas se comprueban contra las del usuario: un id ajeno o de una
# cuenta desactivada no se preselecciona aunque venga en la URL.
wanted = {
field: account_id
for field in ("from_account", "to_account")
if (account_id := _get_int(request.GET.get(field))) is not None
}
if wanted:
own = set(
Account.objects.filter(
owner=request.user, active=True, pk__in=wanted.values()
).values_list("pk", flat=True)
)
initial.update({f: pk for f, pk in wanted.items() if pk in own})
return initial
def _account_balances(accounts):
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
account.current_balance() en bucle, que dispararía N+1).
Los traspasos cuentan aquí a propósito: son movimientos reales de las
cuentas. No metas _analysis_expenses en esta función."""
account_ids = [a.id for a in accounts]
expense_totals = {
row["account_id"]: row["total"]
for row in Expense.objects.filter(account_id__in=account_ids)
.values("account_id")
.annotate(total=Sum("amount"))
}
income_totals = {
row["account_id"]: row["total"]
for row in Income.objects.filter(account_id__in=account_ids)
.values("account_id")
.annotate(total=Sum("amount"))
}
account_balances = []
negative_accounts = []
total_balance = Decimal("0")
for acc in accounts:
balance = (
acc.initial_balance
+ (income_totals.get(acc.id) or Decimal("0"))
- (expense_totals.get(acc.id) or Decimal("0"))
)
total_balance += balance
account_balances.append({"account": acc, "balance": balance})
if balance < 0:
negative_accounts.append({"account": acc, "balance": balance})
return account_balances, negative_accounts, total_balance
def _resolve_period(request, today):
"""Traduce los parámetros de la URL en el periodo mirado por el dashboard.
Devuelve (period, selected_year, selected_month), donde selected_month es
None cuando se está mirando el año completo."""
period = request.GET.get("period", "")
if period == "this_month":
return period, today.year, today.month
if period == "last_month":
selected_year, selected_month = sub_months(today.year, today.month, 1)
return period, selected_year, selected_month
if period == "this_year":
return period, today.year, None
selected_year = _get_int(request.GET.get("year")) or today.year
selected_month = _get_int(request.GET.get("month")) or None
return period, selected_year, selected_month
def _build_account_charts(accounts, balance_by_account, selected_year, today):
"""Serie mensual de saldo de cada cuenta para las gráficas del dashboard.
En el año en curso la serie se corta en el mes actual y ese último punto
se sustituye por el saldo real de la cuenta, que llega ya calculado en
balance_by_account para no repetir current_balance() por cuenta."""
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
current_balance = balance_by_account[acc.id]
if selected_year == today.year:
current_month_index = today.month - 1
if current_month_index < len(m_balance):
m_balance[current_month_index] = float(current_balance)
m_balance = m_balance[:today.month]
accounts_charts.append({
"id": acc.id,
"name": acc.name,
"data": m_balance,
"current_balance": current_balance,
})
return accounts_charts
@login_required
def home(request):
today = date.today()
# Base de los KPIs, la comparativa y el mini-gráfico: sin traspasos.
# Los últimos movimientos usan su propia query, que sí los incluye.
expenses = _analysis_expenses(request.user)
# ---- KPIs del mes en curso ----
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
kpi_count = month_expenses.count()
kpi_categories = month_expenses.values("category").distinct().count()
# ---- Comparativa con el mes anterior ----
prev_year, prev_month = sub_months(today.year, today.month, 1)
prev_total = (
expenses.filter(date__year=prev_year, date__month=prev_month).aggregate(
total=Sum("amount")
)["total"]
or Decimal("0")
)
diff_amount = kpi_total - prev_total
# None cuando no hay mes anterior con gastos (evita división por cero)
diff_pct = None
if prev_total:
diff_pct = (diff_amount / prev_total) * 100
# ---- Saldos de cuentas ----
accounts = list(Account.objects.filter(owner=request.user, active=True))
account_balances, negative_accounts, total_balance = _account_balances(accounts)
# ---- Objetivos ----
# Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas).
# is_exceeded() solo aplica a kind == budget, así que pago y ahorro nunca la disparan.
all_goals = list(
Goal.objects.filter(owner=request.user).select_related("category", "account")
)
goals = [g for g in all_goals if g.show_on_home]
exceeded_goals = [g for g in all_goals if g.is_exceeded()]
has_alerts = bool(exceeded_goals or negative_accounts)
# ---- Últimos movimientos (gastos + ingresos mezclados) ----
# Es un listado, no un análisis: los traspasos son movimientos reales de las
# cuentas y salen marcados con un distintivo, así que no se cuelga de
# "expenses" (que ya los ha filtrado) sino de la query sin filtrar.
recent_expenses = (
Expense.objects.filter(owner=request.user)
.select_related("category", "account")
.order_by("-date", "-id")[:8]
)
recent_incomes = (
Income.objects.filter(owner=request.user)
.select_related("account")
.order_by("-date", "-id")[:8]
)
movements = sorted(
[
{
"kind": "expense",
"date": e.date,
"label": e.category.name,
"account": e.account.name,
"amount": e.amount,
"is_transfer": e.is_transfer,
}
for e in recent_expenses
]
+ [
{
"kind": "income",
"date": i.date,
"label": i.name,
"account": i.account.name,
"amount": i.amount,
"is_transfer": i.is_transfer,
}
for i in recent_incomes
],
key=lambda m: m["date"],
reverse=True,
)[:8]
# ---- Mini-gráfico de 6 meses (sin cambios) ----
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)})
return render(
request,
"expenses/home.html",
{
"active_menu": "home",
"kpi_total": kpi_total,
"kpi_count": kpi_count,
"kpi_categories": kpi_categories,
"prev_total": prev_total,
"diff_amount": diff_amount,
"diff_pct": diff_pct,
"total_balance": total_balance,
"account_balances": account_balances,
"negative_accounts": negative_accounts,
"goals": goals,
"exceeded_goals": exceeded_goals,
"has_alerts": has_alerts,
"movements": movements,
"mini_chart_labels": [x["label"] for x in mini_data],
"mini_chart_data": [x["total"] for x in mini_data],
},
)
@login_required
def expense_list(request):
# Las relaciones de traspaso van en select_related porque la tabla consulta
# expense.transfer en cada fila para decidir los enlaces de acción.
expenses = Expense.objects.filter(owner=request.user).select_related(
"transfer_out", "transfer_fee"
)
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
]
date_from, date_to = _resolve_date_range(request)
range_active = bool(date_from or date_to)
# Un solo criterio de periodo a la vez: si hay rango, manda el rango y los
# selectores de año y mes no se aplican (la plantilla lo avisa).
if range_active:
if date_from:
expenses = expenses.filter(date__gte=date_from)
if date_to:
expenses = expenses.filter(date__lte=date_to)
else:
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")
# La tabla sigue listando los traspasos (hay que poder verlos y corregirlos),
# pero el total y los contadores miden gasto real, así que los excluyen. Por
# eso la etiqueta del total dice "sin traspasos" en la plantilla.
analysis_expenses = expenses.exclude(is_transfer=True)
total_amount = analysis_expenses.aggregate(total=Sum("amount"))["total"] or 0
expense_count = analysis_expenses.count()
category_count = analysis_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)
filters_active = bool(
year or month or category or selected_tags or account_id or range_active
)
return render(
request,
"expenses/expense_list.html",
{
"active_menu": "expenses",
"expenses": page_obj,
"page_obj": page_obj,
"selected_year": year,
"selected_month": month,
"date_from": date_from,
"date_to": date_to,
"range_active": range_active,
"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,
"filters_active": filters_active,
"query_params": query_params.urlencode(),
},
)
@login_required
def expense_create(request):
if request.method == "POST":
# Marcado como traspaso: esto no se guarda aquí. Un traspaso son dos patas
# que crea Transfer.sync(), así que se reconduce al formulario de traspaso
# con lo que ya hubiera escrito. Se mira el POST en bruto y antes de
# validar porque el gasto puede estar a medias y da igual: no se crea.
if request.POST.get("is_transfer"):
return redirect(_transfer_create_url(request.POST, "from_account"))
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):
expense = get_object_or_404(
Expense,
pk=pk,
owner=request.user,
)
blocked = _reject_transfer_leg(request, expense)
if blocked:
return blocked
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,
)
blocked = _reject_transfer_leg(request, expense)
if blocked:
return blocked
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()
account_id = _get_int(request.GET.get("account"))
compare_requested = request.GET.get("compare") == "1"
# Time presets
period, selected_year, selected_month = _resolve_period(request, today)
# Rango de fechas libre. Si está activo manda sobre año y mes, y además
# desactiva la comparativa: no hay un "periodo anterior" obvio de un rango
# arbitrario. compare_suppressed sirve para decírselo al usuario.
date_from, date_to = _resolve_date_range(request)
range_active = bool(date_from or date_to)
compare_enabled = compare_requested and not range_active
compare_suppressed = compare_requested and range_active
# Accounts
accounts = list(Account.objects.filter(owner=request.user, active=True))
selected_account_obj = None
if account_id:
selected_account_obj = next((a for a in accounts if a.id == account_id), None)
# Calculate the KPI
account_balances, _, total_balance = _account_balances(accounts)
balance_by_account = {row["account"].id: row["balance"] for row in account_balances}
if selected_account_obj:
kpi_balance = balance_by_account[selected_account_obj.id]
else:
kpi_balance = total_balance
# Filter by base expenses
# Todo lo que cuelga de aquí es análisis (KPIs, gráficos, comparativa), así
# que va sin traspasos. Los saldos de arriba sí los incluyen.
expenses = _analysis_expenses(request.user)
if account_id:
expenses = expenses.filter(account_id=account_id)
if range_active:
expenses_filtered = expenses
if date_from:
expenses_filtered = expenses_filtered.filter(date__gte=date_from)
if date_to:
expenses_filtered = expenses_filtered.filter(date__lte=date_to)
else:
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 range_active:
# Con rango libre el eje no lo decide el mes sino la longitud del rango.
# Si falta un extremo se acota para poder medirlo: por abajo con el gasto
# más antiguo del conjunto, por arriba con hoy.
span_from = date_from or expenses_filtered.aggregate(first=Min("date"))["first"]
span_to = date_to or today
if span_from is None:
# Sin gastos y sin fecha inicial no hay longitud que medir.
chart_labels = []
chart_totals = []
chart_type = "month"
else:
span_days = (span_to - span_from).days + 1
daily_average = total_amount / span_days if span_days > 0 else 0
# projected_end_of_month se queda en 0: proyectar a fin de mes desde
# un rango arbitrario no significa nada.
if span_days <= RANGE_DAY_CHART_MAX_DAYS:
# Agrupar por el propio campo, no con TruncDate: "date" ya es un
# DateField y TruncDate le aplicaría una conversión de zona
# horaria que en SQLite falla si no está la base de datos tz.
by_day_qs = expenses_filtered.values("date").annotate(
total=Sum("amount")
)
day_totals = {row["date"]: float(row["total"]) for row in by_day_qs}
axis = [span_from + timedelta(days=i) for i in range(span_days)]
chart_labels = [d.strftime("%d/%m") for d in axis]
chart_totals = [day_totals.get(d, 0) for d in axis]
chart_type = "day"
else:
by_month_qs = (
expenses_filtered.annotate(bucket=TruncMonth("date"))
.values("bucket")
.annotate(total=Sum("amount"))
)
month_totals = {
(row["bucket"].year, row["bucket"].month): float(row["total"])
for row in by_month_qs
}
axis = []
cursor = (span_from.year, span_from.month)
last = (span_to.year, span_to.month)
while cursor <= last:
axis.append(cursor)
y, m = cursor
cursor = (y + 1, 1) if m == 12 else (y, m + 1)
chart_labels = [f"{m:02d}/{y}" for y, m in axis]
chart_totals = [month_totals.get(key, 0) for key in axis]
chart_type = "month"
elif 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 = _analysis_expenses(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 = _build_account_charts(
accounts, balance_by_account, selected_year, today
)
# 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,
"chart_type": chart_type,
"year_list": year_list,
"months": list(range(1, 13)),
"selected_year": selected_year,
"selected_month": selected_month,
"date_from": date_from,
"date_to": date_to,
"range_active": range_active,
"compare_suppressed": compare_suppressed,
"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)
.annotate(expense_count=Count("expenses"))
.order_by(Lower("name"))
)
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 = list(Account.objects.filter(owner=request.user).order_by(Lower("name")))
account_rows, _, _ = _account_balances(accounts)
return render(
request,
"expenses/account_list.html",
{"active_menu": "accounts", "account_rows": account_rows},
)
@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):
"""Delete an account via *soft-delete* (sets active=False).
Unlike expense_delete, tag_delete or income_delete, which delete the
record for real, accounts are never physically removed: they are
deactivated. Historical expenses and incomes keep their ForeignKey to
the account, so balances and charts for past periods remain correct.
An inactive account disappears from forms (querysets filtered by
active=True) but stays visible and dimmed in the account list.
"""
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":
# Igual que en expense_create, pero la cuenta es el destino: el dinero
# entra en ella.
if request.POST.get("is_transfer"):
return redirect(_transfer_create_url(request.POST, "to_account"))
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).select_related(
"account", "transfer_in"
)
year_list = Income.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"))
account_id = _get_int(request.GET.get("account"))
date_from, date_to = _resolve_date_range(request)
range_active = bool(date_from or date_to)
# Misma precedencia que en expense_list: si hay rango, manda el rango y los
# selectores de año y mes no se aplican (la plantilla lo avisa).
if range_active:
if date_from:
incomes = incomes.filter(date__gte=date_from)
if date_to:
incomes = incomes.filter(date__lte=date_to)
else:
if year:
incomes = incomes.filter(date__year=year)
if month:
incomes = incomes.filter(date__month=month)
if account_id:
incomes = incomes.filter(account_id=account_id)
incomes = incomes.order_by("-date")
# La tabla sigue listando los traspasos, pero el total mide ingreso real y
# los excluye. Por eso la etiqueta dice "sin traspasos" en la plantilla.
totals = incomes.exclude(is_transfer=True).aggregate(
total=Sum("amount"), count=Count("id")
)
total_amount = totals["total"] or 0
income_count = totals["count"]
# Pagination
paginator = Paginator(incomes, 10)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
query_params = request.GET.copy()
query_params.pop("page", None)
filters_active = bool(
year or month or account_id or range_active
)
return render(
request,
"expenses/income_list.html",
{
"active_menu": "incomes",
"incomes": page_obj,
"page_obj": page_obj,
"year_list": [y.year for y in year_list],
"months": months,
"accounts": Account.objects.filter(owner=request.user),
"selected_year": year,
"selected_month": month,
"selected_account": account_id,
"date_from": date_from,
"date_to": date_to,
"range_active": range_active,
"kpi_total": total_amount,
"kpi_count": income_count,
"filters_active": filters_active,
"query_params": query_params.urlencode(),
},
)
@login_required
def income_edit(request, pk):
income = get_object_or_404(Income, pk=pk, owner=request.user)
blocked = _reject_transfer_leg(request, income)
if blocked:
return blocked
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)
blocked = _reject_transfer_leg(request, income)
if blocked:
return blocked
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, instance=expense, user=request.user)
if form.is_valid():
# Update expense
expense = 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 = _safe_next(request)
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 = _safe_next(request)
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 = list(
Category.objects.filter(owner=request.user)
.annotate(expense_count=Count("expenses"))
.select_related("parent")
)
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",
"category_rows": _category_tree(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":
if category.is_system:
messages.error(
request,
f"No se puede eliminar «{category.name}»; la usa la aplicación para "
"clasificar los traspasos y sus comisiones."
)
return redirect("category_list")
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)
selected_kind = request.GET.get("kind") or ""
if selected_kind in dict(Goal.KIND_CHOICES):
goals = goals.filter(kind=selected_kind)
else:
selected_kind = ""
goals = goals.order_by(Lower("name"))
return render(
request,
"goals/list.html",
{
"goals": goals,
"kind_choices": Goal.KIND_CHOICES,
"selected_kind": selected_kind,
"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"})
@login_required
def transfer_list(request):
transfers = Transfer.objects.filter(owner=request.user).select_related(
"from_account", "to_account"
)
return render(
request,
"transfers/list.html",
{
"active_menu": "transfers",
"transfers": transfers,
},
)
@login_required
def transfer_create(request):
if request.method == "POST":
form = TransferForm(request.POST, user=request.user)
if form.is_valid():
transfer = form.save(commit=False)
transfer.owner = request.user
# sync() guarda el traspaso y crea las patas en una sola transacción.
transfer.sync()
messages.success(request, "Traspaso creado.")
return redirect("transfer_list")
else:
# La URL puede traer fecha, importe y cuenta del formulario de gasto o de
# ingreso del que se venga.
form = TransferForm(user=request.user, initial=_transfer_initial(request))
return render(
request,
"transfers/form.html",
{
"active_menu": "transfers",
"form": form,
"title": "Nuevo traspaso",
},
)
@login_required
def transfer_edit(request, pk):
transfer = get_object_or_404(Transfer, pk=pk, owner=request.user)
if request.method == "POST":
form = TransferForm(request.POST, instance=transfer, user=request.user)
if form.is_valid():
transfer = form.save(commit=False)
transfer.sync()
messages.success(request, "Traspaso actualizado.")
return redirect("transfer_list")
else:
form = TransferForm(instance=transfer, user=request.user)
return render(
request,
"transfers/form.html",
{
"active_menu": "transfers",
"form": form,
"title": "Editar traspaso",
},
)
@login_required
def transfer_delete(request, pk):
transfer = get_object_or_404(Transfer, pk=pk, owner=request.user)
if request.method == "POST":
# Transfer.delete() se lleva también las dos patas y la comisión.
transfer.delete()
messages.success(request, "Traspaso eliminado.")
return redirect("transfer_list")
return render(
request,
"transfers/confirm_delete.html",
{"active_menu": "transfers", "transfer": transfer},
)