Extrae helpers del dashboard y elimina el N+1 de saldos

La vista pasaba de 226 lineas y mezclaba tres cosas distintas: traducir los
parametros de la URL a un periodo, calcular los KPIs y montar las series de
las graficas por cuenta. Saca las dos que son autonomas a _resolve_period y
_build_account_charts y deja dashboard en 170.

De paso quita el N+1: la vista llamaba a current_balance() una vez por
cuenta para el KPI y otra vez por cuenta dentro del bucle de graficas, y
cada llamada son dos queries. Ahora los saldos salen de _account_balances,
que los calcula con dos agregados, y se reparten por un dict indexado por
id. Medido con CaptureQueriesContext: con 1/3/6 cuentas se pasa de 25/53/95
queries a 21/37/61.

accounts deja de ser un QuerySet y pasa a lista, porque ahora se recorre
mas de una vez, y la cuenta seleccionada se busca en memoria en vez de con
otra query.

Lo que sigue costando una query por cuenta es monthly_balance(), que no se
toca aqui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-14 20:12:03 +02:00
parent b4c9f8507b
commit f074005a7f

View File

@ -112,6 +112,67 @@ def _account_balances(accounts):
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()
@ -407,34 +468,27 @@ def expense_delete(request, pk):
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
period, selected_year, selected_month = _resolve_period(request, today)
# Accounts
accounts = Account.objects.filter(owner=request.user, active=True)
accounts = list(Account.objects.filter(owner=request.user, active=True))
selected_account_obj = None
if account_id:
selected_account_obj = accounts.filter(id=account_id).first()
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 = selected_account_obj.current_balance()
kpi_balance = balance_by_account[selected_account_obj.id]
else:
kpi_balance = sum(account.current_balance() for account in accounts)
kpi_balance = total_balance
# Filter by base expenses
expenses = Expense.objects.filter(owner=request.user)
if account_id:
@ -561,33 +615,10 @@ def dashboard(request):
})
# 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())
accounts_charts = _build_account_charts(
accounts, balance_by_account, selected_year, today
)
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)