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}, )