El listado de objetivos mezclaba pagos, presupuestos y ahorros sin forma de separarlos. Anade un filtro por tipo, con el mismo patron que los filtros de gastos, y ordena los objetivos por nombre. Un valor de "kind" que no este en KIND_CHOICES se ignora en vez de romper. De paso, la barra de progreso pasa a usar goal.progress_state en lugar de decidir el color en la plantilla con un if sobre el porcentaje. La logica ya vivia en el modelo, que ademas distingue presupuestos (donde acercarse al 100% es un aviso) del resto; la plantilla del dashboard seguia con las clases antiguas low/medium/high, que se retiran del CSS. Incluye estado vacio y thead/tbody en la tabla, que le faltaban. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1167 lines
34 KiB
Python
1167 lines
34 KiB
Python
import logging
|
|
import calendar
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from django.contrib import messages
|
|
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
|
|
from .forms import (
|
|
ExpenseForm,
|
|
IncomeForm,
|
|
TagForm,
|
|
AccountForm,
|
|
FuelEntryForm,
|
|
CategoryForm,
|
|
GoalForm,
|
|
)
|
|
|
|
from django.core.paginator import Paginator
|
|
from django.db.models import Sum, Count, ProtectedError
|
|
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
|
|
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.utils.http import url_has_allowed_host_and_scheme
|
|
from django.shortcuts import get_object_or_404, render, redirect
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def _get_int(value):
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _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)
|
|
|
|
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 _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)."""
|
|
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
|
|
|
|
|
|
@login_required
|
|
def home(request):
|
|
today = date.today()
|
|
expenses = Expense.objects.filter(owner=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) ----
|
|
recent_expenses = expenses.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,
|
|
}
|
|
for e in recent_expenses
|
|
]
|
|
+ [
|
|
{
|
|
"kind": "income",
|
|
"date": i.date,
|
|
"label": i.name,
|
|
"account": i.account.name,
|
|
"amount": i.amount,
|
|
}
|
|
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):
|
|
expenses = Expense.objects.filter(owner=request.user)
|
|
|
|
categories = Category.objects.filter(owner=request.user)
|
|
|
|
year_list = Expense.objects.filter(owner=request.user).dates("date", "year")
|
|
|
|
months = list(range(1, 13))
|
|
|
|
# Filters
|
|
year = _get_int(request.GET.get("year"))
|
|
month = _get_int(request.GET.get("month"))
|
|
category = _get_int(request.GET.get("category"))
|
|
account_id = _get_int(request.GET.get("account"))
|
|
|
|
tag_ids = [
|
|
t_id
|
|
for t in request.GET.getlist("tag")
|
|
if (t_id:= _get_int(t)) is not None
|
|
]
|
|
|
|
if year:
|
|
expenses = expenses.filter(date__year=year)
|
|
|
|
if month:
|
|
expenses = expenses.filter(date__month=month)
|
|
|
|
if category:
|
|
expenses = expenses.filter(category_id=category)
|
|
|
|
if tag_ids:
|
|
expenses = expenses.filter(tags__id__in=tag_ids).distinct()
|
|
|
|
if account_id:
|
|
expenses = expenses.filter(account_id=account_id)
|
|
|
|
selected_tags = tag_ids or []
|
|
|
|
expenses = expenses.order_by("-date")
|
|
|
|
total_amount = expenses.aggregate(total=Sum("amount"))["total"] or 0
|
|
|
|
expense_count = expenses.count()
|
|
|
|
category_count = expenses.values("category").distinct().count()
|
|
|
|
# Pagination
|
|
paginator = Paginator(expenses, 10)
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
query_params = request.GET.copy()
|
|
query_params.pop("page", None)
|
|
|
|
# tags with state
|
|
tags_with_state = []
|
|
|
|
for tag in Tag.objects.filter(owner=request.user):
|
|
if tag.id in selected_tags:
|
|
new_tags = [t for t in selected_tags if t != tag.id]
|
|
active = True
|
|
else:
|
|
new_tags = selected_tags + [tag.id]
|
|
active = False
|
|
|
|
query = "&".join([f"tag={t}" for t in new_tags])
|
|
|
|
tags_with_state.append(
|
|
{
|
|
"id": tag.id,
|
|
"name": tag.name,
|
|
"active": active,
|
|
"query": query,
|
|
}
|
|
)
|
|
|
|
advanced_filters_open = bool(category or selected_tags)
|
|
|
|
return render(
|
|
request,
|
|
"expenses/expense_list.html",
|
|
{
|
|
"active_menu": "expenses",
|
|
"expenses": page_obj,
|
|
"page_obj": page_obj,
|
|
"selected_year": year,
|
|
"selected_month": month,
|
|
"selected_category": category,
|
|
"categories": categories,
|
|
"year_list": [y.year for y in year_list],
|
|
"months": months,
|
|
"kpi_total": total_amount,
|
|
"kpi_count": expense_count,
|
|
"kpi_categories": category_count,
|
|
"selected_tags": selected_tags,
|
|
"tags": Tag.objects.filter(owner=request.user),
|
|
"tags_with_state": tags_with_state,
|
|
"accounts": Account.objects.filter(owner=request.user),
|
|
"selected_account": account_id,
|
|
"advanced_filters_open": advanced_filters_open,
|
|
"query_params": query_params.urlencode(),
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def expense_create(request):
|
|
if request.method == "POST":
|
|
form = ExpenseForm(request.POST, user=request.user)
|
|
if form.is_valid():
|
|
expense = form.save(commit=False)
|
|
expense.owner = request.user
|
|
expense.save()
|
|
form.save_m2m()
|
|
|
|
messages.success(request, "Gasto creado correctamente.")
|
|
return redirect("expense_list")
|
|
else:
|
|
form = ExpenseForm(user=request.user)
|
|
|
|
return render(
|
|
request,
|
|
"expenses/expense_form.html",
|
|
{"active_menu": "expenses", "form": form},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def expense_edit(request, pk):
|
|
expense = get_object_or_404(
|
|
Expense,
|
|
pk=pk,
|
|
owner=request.user,
|
|
)
|
|
|
|
if request.method == "POST":
|
|
form = ExpenseForm(request.POST or None, instance=expense, user=request.user)
|
|
|
|
if form.is_valid():
|
|
expense = form.save()
|
|
messages.success(request, "Gasto actualizado")
|
|
return redirect("expense_list")
|
|
else:
|
|
form = ExpenseForm(instance=expense, user=request.user)
|
|
|
|
return render(
|
|
request,
|
|
"expenses/expense_form.html",
|
|
{
|
|
"active_menu": "expenses",
|
|
"form": form,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def expense_delete(request, pk):
|
|
expense = get_object_or_404(
|
|
Expense,
|
|
pk=pk,
|
|
owner=request.user,
|
|
)
|
|
|
|
if request.method == "POST":
|
|
expense.delete()
|
|
messages.success(request, "Gasto eliminado correctamente.")
|
|
return redirect("expense_list")
|
|
|
|
return render(
|
|
request,
|
|
"expenses/expense_confirm_delete.html",
|
|
{"active_menu": "expenses", "expense": expense},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def dashboard(request):
|
|
today = date.today()
|
|
|
|
period = request.GET.get("period", "")
|
|
account_id = _get_int(request.GET.get("account"))
|
|
compare_enabled = request.GET.get("compare") == "1"
|
|
|
|
# Time presets
|
|
if period == "this_month":
|
|
selected_year, selected_month = today.year, today.month
|
|
elif period == "last_month":
|
|
selected_year, selected_month = sub_months(today.year, today.month, 1)
|
|
elif period == "this_year":
|
|
selected_year, selected_month = today.year, None
|
|
else:
|
|
selected_year = _get_int(request.GET.get("year")) or _get_int(today.year)
|
|
selected_month = _get_int(request.GET.get("month"))
|
|
selected_month = _get_int(selected_month) if selected_month else None
|
|
|
|
# Accounts
|
|
accounts = Account.objects.filter(owner=request.user, active=True)
|
|
selected_account_obj = None
|
|
if account_id:
|
|
selected_account_obj = accounts.filter(id=account_id).first()
|
|
|
|
# Calculate the KPI
|
|
if selected_account_obj:
|
|
kpi_balance = selected_account_obj.current_balance()
|
|
else:
|
|
kpi_balance = sum(account.current_balance() for account in accounts)
|
|
|
|
# Filter by base expenses
|
|
expenses = Expense.objects.filter(owner=request.user)
|
|
if account_id:
|
|
expenses = expenses.filter(account_id=account_id)
|
|
|
|
expenses_filtered = expenses.filter(date__year=selected_year)
|
|
if selected_month:
|
|
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
|
|
|
|
# Basic KPIs
|
|
total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0
|
|
expense_count = expenses_filtered.count()
|
|
category_count = expenses_filtered.values("category").distinct().count()
|
|
|
|
# Total by category
|
|
by_category_qs = by_category = (
|
|
expenses_filtered.values("category__name")
|
|
.annotate(total=Sum("amount"))
|
|
.order_by("-total")
|
|
)
|
|
|
|
CATEGORY_LIMIT = 10 # Top 10
|
|
|
|
by_category_chart = []
|
|
for index, row in enumerate(by_category_qs):
|
|
if index >= CATEGORY_LIMIT:
|
|
break
|
|
|
|
cat_name = row["category__name"] or "Sin categoría"
|
|
total_cat = float(row["total"])
|
|
|
|
by_category_chart.append({
|
|
"category__name": cat_name,
|
|
"total": total_cat
|
|
})
|
|
|
|
#Graphic
|
|
daily_average = 0
|
|
projected_end_of_month = 0
|
|
if selected_month:
|
|
by_day_qs = (
|
|
expenses_filtered.annotate(day=ExtractDay("date"))
|
|
.values("day")
|
|
.annotate(total=Sum("amount"))
|
|
)
|
|
day_totals = {row["day"]: float(row["total"]) for row in by_day_qs}
|
|
|
|
num_days = calendar.monthrange(selected_year, selected_month)[1]
|
|
if selected_year == today.year and selected_month == today.month:
|
|
days_passed = today.day
|
|
else:
|
|
days_passed = calendar.monthrange(selected_year, selected_month)[1]
|
|
|
|
daily_average = total_amount / days_passed if days_passed > 0 else 0
|
|
total_days_in_month = calendar.monthrange(selected_year, selected_month)[1]
|
|
projected_end_of_month = daily_average * total_days_in_month
|
|
|
|
chart_labels = list(range(1, num_days + 1))
|
|
chart_totals = [day_totals.get(d, 0) for d in chart_labels]
|
|
chart_type = "day"
|
|
else:
|
|
by_month_qs = (
|
|
expenses_filtered.annotate(month=ExtractMonth("date"))
|
|
.values("month")
|
|
.annotate(total=Sum("amount"))
|
|
)
|
|
month_totals = {row["month"]: float(row["total"]) for row in by_month_qs}
|
|
chart_labels = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]
|
|
chart_totals = [month_totals.get(m, 0) for m in range(1, 13)]
|
|
chart_type = "month"
|
|
|
|
year_list = (
|
|
Expense.objects.filter(owner=request.user)
|
|
.annotate(year=ExtractYear("date"))
|
|
.values_list("year", flat=True)
|
|
.distinct()
|
|
.order_by("-year")
|
|
)
|
|
|
|
# Comparison
|
|
previous_total = 0
|
|
kpi_difference = 0
|
|
percentage = 0
|
|
kpi_trend = "equal"
|
|
category_comparison = []
|
|
|
|
if compare_enabled:
|
|
previous_expenses = Expense.objects.filter(owner=request.user)
|
|
if account_id:
|
|
previous_expenses = previous_expenses.filter(account_id=account_id)
|
|
|
|
if selected_month:
|
|
prev_year, prev_month = sub_months(selected_year, selected_month, 1)
|
|
previous_expenses = previous_expenses.filter(date__year=prev_year, date__month=prev_month)
|
|
else:
|
|
prev_year = selected_year - 1
|
|
previous_expenses = previous_expenses.filter(date__year=prev_year)
|
|
|
|
previous_total = previous_expenses.aggregate(total=Sum("amount"))["total"] or 0
|
|
kpi_difference = total_amount - previous_total
|
|
|
|
if previous_total > 0:
|
|
percentage = (kpi_difference / previous_total) * 100
|
|
|
|
if kpi_difference > 0:
|
|
kpi_trend = "up"
|
|
elif kpi_difference < 0:
|
|
kpi_trend = "down"
|
|
|
|
# Comparison by category
|
|
previous_by_category = previous_expenses.values("category__name").annotate(total=Sum("amount"))
|
|
current_map = {row["category__name"]: float(row["total"]) for row in by_category}
|
|
previous_map = {row["category__name"]: float(row["total"]) for row in previous_by_category}
|
|
|
|
for cat in set(current_map) | set(previous_map):
|
|
cur_t = current_map.get(cat, 0)
|
|
prev_t = previous_map.get(cat, 0)
|
|
category_comparison.append({
|
|
"category": cat,
|
|
"current": cur_t,
|
|
"previous": prev_t,
|
|
"difference": cur_t - prev_t,
|
|
"difference_abs": abs(cur_t - prev_t)
|
|
})
|
|
|
|
# Anual evolution by accounts
|
|
accounts_charts = []
|
|
for acc in accounts:
|
|
try:
|
|
monthly_data = acc.monthly_balance(selected_year)
|
|
m_balance = [float(row["balance"]) for row in monthly_data]
|
|
except Exception:
|
|
logger.exception(
|
|
"Error calculando monthly_balance para la cuenta %s (año %s)",
|
|
acc.id, selected_year
|
|
)
|
|
m_balance = [0] * 12
|
|
|
|
if selected_year == today.year:
|
|
current_month_index = today.month - 1
|
|
|
|
if current_month_index < len(m_balance):
|
|
m_balance[current_month_index] = float(acc.current_balance())
|
|
|
|
m_balance = m_balance[:today.month]
|
|
|
|
accounts_charts.append({
|
|
"id": acc.id,
|
|
"name": acc.name,
|
|
"data": m_balance,
|
|
"current_balance": acc.current_balance(),
|
|
})
|
|
|
|
# Goals
|
|
goals = Goal.objects.filter(owner=request.user)
|
|
|
|
# Recent expenses
|
|
recent_expenses = Expense.objects.filter(owner=request.user).order_by('-date', '-id')[:7]
|
|
|
|
# Send the data to the dashboard
|
|
return render(
|
|
request,
|
|
"expenses/dashboard.html",
|
|
{
|
|
"active_menu": "dashboard",
|
|
"today": today,
|
|
"by_category": by_category,
|
|
"by_category_chart": by_category_chart,
|
|
"chart_labels": chart_labels,
|
|
"chart_data": chart_totals,
|
|
"year_list": year_list,
|
|
"months": list(range(1, 13)),
|
|
"selected_year": selected_year,
|
|
"selected_month": selected_month,
|
|
"kpi_total": total_amount,
|
|
"kpi_count": expense_count,
|
|
"kpi_categories": category_count,
|
|
"compare_enabled": compare_enabled,
|
|
"kpi_previous_total": previous_total,
|
|
"kpi_difference": kpi_difference,
|
|
"kpi_difference_abs": abs(kpi_difference),
|
|
"kpi_percentage": percentage,
|
|
"category_comparison": category_comparison,
|
|
"kpi_trend": kpi_trend,
|
|
"accounts": accounts,
|
|
"selected_account": account_id,
|
|
"selected_account_obj": selected_account_obj,
|
|
"kpi_balance": kpi_balance,
|
|
"accounts_charts": accounts_charts,
|
|
"period": period,
|
|
"goals": goals,
|
|
"recent_expenses": recent_expenses,
|
|
"daily_average": daily_average,
|
|
"projected_end_of_month": projected_end_of_month,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def tag_list(request):
|
|
tags = (
|
|
Tag.objects.filter(owner=request.user)
|
|
.annotate(expense_count=Count("expenses"))
|
|
.order_by("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("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":
|
|
form = IncomeForm(request.POST, user=request.user)
|
|
if form.is_valid():
|
|
income = form.save(commit=False)
|
|
income.owner = request.user
|
|
income.save()
|
|
messages.success(request, "Ingreso creado correctamente.")
|
|
return redirect("income_list")
|
|
else:
|
|
form = IncomeForm(user=request.user)
|
|
|
|
return render(
|
|
request, "expenses/income_form.html", {"active_menu": "incomes", "form": form}
|
|
)
|
|
|
|
|
|
@login_required
|
|
def income_list(request):
|
|
incomes = Income.objects.filter(owner=request.user)
|
|
|
|
return render(
|
|
request,
|
|
"expenses/income_list.html",
|
|
{"active_menu": "incomes", "incomes": incomes},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def income_edit(request, pk):
|
|
income = get_object_or_404(Income, pk=pk, owner=request.user)
|
|
|
|
if request.method == "POST":
|
|
form = IncomeForm(request.POST, instance=income, user=request.user)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, "Ingreso actualizado.")
|
|
return redirect("income_list")
|
|
else:
|
|
form = IncomeForm(instance=income, user=request.user)
|
|
|
|
return render(
|
|
request, "expenses/income_form.html", {"active_menu": "incomes", "form": form}
|
|
)
|
|
|
|
|
|
@login_required
|
|
def income_delete(request, pk):
|
|
income = get_object_or_404(Income, pk=pk, owner=request.user)
|
|
|
|
if request.method == "POST":
|
|
income.delete()
|
|
messages.success(request, "Ingreso eliminado.")
|
|
return redirect("income_list")
|
|
|
|
return render(
|
|
request,
|
|
"expenses/income_confirm_delete.html",
|
|
{"active_menu": "incomes", "income": income},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def fuel_create(request):
|
|
if request.method == "POST":
|
|
form = FuelEntryForm(request.POST, user=request.user)
|
|
|
|
if form.is_valid():
|
|
category, _ = Category.objects.get_or_create(
|
|
slug="gasolina",
|
|
owner=request.user,
|
|
defaults={"name": "Gasolina"},
|
|
)
|
|
|
|
expense = form.save(commit=False)
|
|
expense.owner = request.user
|
|
expense.category = category
|
|
expense.description = "Repostaje"
|
|
expense.save()
|
|
|
|
FuelEntry.objects.create(
|
|
expense=expense,
|
|
odometer=form.cleaned_data["odometer"],
|
|
liters=form.cleaned_data["liters"],
|
|
)
|
|
|
|
return redirect("fuel_list")
|
|
|
|
else:
|
|
form = FuelEntryForm(user=request.user)
|
|
|
|
return render(
|
|
request, "fuel/create.html", {"active_menu": "fuel", "form": form}
|
|
)
|
|
|
|
|
|
@login_required
|
|
def fuel_list(request):
|
|
selected_year = request.GET.get("year")
|
|
|
|
current_year = datetime.now().year
|
|
|
|
selected_year = int(selected_year) if selected_year else current_year
|
|
fuels = (
|
|
FuelEntry.objects.filter(
|
|
expense__owner=request.user, expense__date__year=selected_year
|
|
)
|
|
.select_related("expense")
|
|
.order_by("expense__date")
|
|
)
|
|
|
|
monthly_expenses = (
|
|
fuels.annotate(month=ExtractMonth("expense__date"))
|
|
.values("month")
|
|
.annotate(total=Sum("expense__amount"))
|
|
)
|
|
|
|
month_map = {m["month"]: float(m["total"]) for m in monthly_expenses}
|
|
|
|
monthly_data = [month_map.get(month, 0) for month in range(1, 13)]
|
|
|
|
year_list = (
|
|
FuelEntry.objects.filter(
|
|
expense__owner=request.user,
|
|
)
|
|
.annotate(year=ExtractYear("expense__date"))
|
|
.values_list("year", flat=True)
|
|
.distinct()
|
|
.order_by("year")
|
|
)
|
|
|
|
km_data = []
|
|
dates = []
|
|
|
|
for fuel in fuels:
|
|
km = fuel.km_since_previous()
|
|
if km:
|
|
km_data.append(km)
|
|
dates.append(fuel.expense.date.strftime("%Y-%m-%d"))
|
|
|
|
return render(
|
|
request,
|
|
"fuel/list.html",
|
|
{
|
|
"active_menu": "fuel",
|
|
"fuels": fuels,
|
|
"monthly_data": monthly_data,
|
|
"km_data": km_data,
|
|
"km_dates": dates,
|
|
"selected_year": selected_year,
|
|
"year_list": year_list,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def fuel_edit(request, pk):
|
|
expense = get_object_or_404(Expense, pk=pk, owner=request.user)
|
|
|
|
fuel = get_object_or_404(FuelEntry, expense=expense)
|
|
|
|
if request.method == "POST":
|
|
form = FuelEntryForm(request.POST, 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 = Category.objects.filter(owner=request.user)
|
|
|
|
if request.method == "POST":
|
|
form = CategoryForm(request.POST, user=request.user)
|
|
if form.is_valid():
|
|
category = form.save(commit=False)
|
|
category.owner = request.user
|
|
category.save()
|
|
return redirect("category_list")
|
|
else:
|
|
form = CategoryForm(user=request.user)
|
|
|
|
return render(
|
|
request,
|
|
"categories/list.html",
|
|
{
|
|
"active_menu": "settings",
|
|
"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":
|
|
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("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"})
|