1711 lines
53 KiB
Python
1711 lines
53 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,
|
|
ExportForm,
|
|
)
|
|
from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv
|
|
|
|
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.http import HttpResponseBadRequest, StreamingHttpResponse
|
|
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},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def export_index(request):
|
|
# Se enlaza el form si la URL trae algo para que, al volver de una descarga
|
|
# con parámetros mal puestos, se vean los errores en la pantalla.
|
|
form = ExportForm(request.GET or None, user=request.user)
|
|
|
|
return render(
|
|
request,
|
|
"expenses/export_form.html",
|
|
{"active_menu": "settings", "form": form},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def export_download(request):
|
|
form = ExportForm(request.GET, user=request.user)
|
|
if not form.is_valid():
|
|
return HttpResponseBadRequest("Parámetros de exportación no válidos.")
|
|
|
|
data = form.cleaned_data
|
|
dialect = DIALECTS[data["fmt"]]
|
|
stem, build_export = EXPORTS[data["kind"]]
|
|
header, rows = build_export(
|
|
request.user, data["date_from"], data["date_to"], data["account"], dialect
|
|
)
|
|
|
|
# Streaming y no HttpResponse: con los volúmenes de hoy da igual en memoria,
|
|
# pero el coste de código es el mismo.
|
|
response = StreamingHttpResponse(
|
|
stream_csv(header, rows, dialect),
|
|
content_type="text/csv; charset=utf-8",
|
|
)
|
|
filename = csv_filename(stem, data["date_from"], data["date_to"])
|
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
|
|
return response
|