expenses_manager/expenses_manager/expenses/models.py
JKuijperM f135797d31 Ordena etiquetas, categorias, cuentas y objetivos ignorando mayusculas
Tag era el unico de los tres modelos con nombre sin ordering en su Meta, asi
que las etiquetas salian en orden de insercion. Al anadirlo aparecio el
problema de fondo: con ordering = ["name"], SQLite ordena por valor binario y
en ASCII todas las mayusculas van antes que todas las minusculas, de modo que
salia AA, MK, ZZ, mk, ms en vez de AA, MK, mk, ms, ZZ.

Importa mas de lo que parece porque en produccion la base de datos es
PostgreSQL, que ordena segun la configuracion regional. El orden pasa a
calcularse en la consulta con Lower("name") en vez de depender de la colacion
del motor, asi que local y NAS coinciden.

Los tres Meta usan ahora ordering = [Lower("name")]. Django admite
expresiones ahi, pero no se propaga solo a todos los sitios: habia tres
sitios con orden explicito que lo pisaban y tambien se corrigen.

- tag_list y account_list tenian .order_by("name")
- goal_list ordenaba igual
- _category_tree reordena los hermanos en memoria, y Python compara por code
  point igual que SQLite, asi que el arbol tampoco quedaba bien

Las dos migraciones son AlterModelOptions, sin cambios de esquema, pero hay
que desplegarlas.

Lo que no resuelve: los acentos. Lower() normaliza mayusculas y minusculas,
pero como se ordenan "N", "a" o "u" sigue dependiendo de la colacion de cada
motor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:18:52 +02:00

422 lines
12 KiB
Python

from datetime import date
from decimal import Decimal
from django.db import models
from django.conf import settings
from django.db.models import Sum
from functools import cached_property
from django.utils.text import slugify
from django.db.models.functions import ExtractMonth, Lower
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(blank=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="categories",
)
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="children",
)
class Meta:
unique_together = ("name", "parent", "owner", "slug")
verbose_name_plural = "categories"
ordering = [Lower("name")]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
from django.utils.text import slugify
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def descendant_ids(self, include_self=True):
"""IDs de esta categoría y de toda su descendencia."""
seen = {self.pk}
pending = [self.pk]
while pending:
children = list(
Category.objects.filter(parent_id__in=pending)
.exclude(pk__in=seen)
.values_list("pk", flat=True)
)
seen.update(children)
pending = children
return list(seen) if include_self else [i for i in seen if i != self.pk]
class Account(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="accounts",
)
name = models.CharField(max_length=100)
initial_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = [Lower("name")]
def current_balance(self):
expenses_total = self.expenses.aggregate(total=Sum("amount"))[
"total"
] or Decimal("0")
income_total = self.incomes.aggregate(total=Sum("amount"))["total"] or Decimal(
"0"
)
return self.initial_balance + income_total - expenses_total
def monthly_balance(self, year=None):
year = year or date.today().year
today = date.today()
previous_income = (
self.incomes.filter(date__year__lt=year)
.aggregate(total=Sum('amount'))['total']
or Decimal('0')
)
previous_expenses = (
self.expenses.filter(date__year__lt=year)
.aggregate(total=Sum('amount'))['total']
or Decimal('0')
)
balance = self.initial_balance + previous_income - previous_expenses
incomes = (
self.incomes.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
expenses = (
self.expenses.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
income_map = {i["month"]: Decimal(str(i["total"] or 0)) for i in incomes}
expenses_map = {e["month"]: Decimal(str(e["total"] or 0)) for e in expenses}
data = []
for month in range(1, 13):
balance += income_map.get(month, Decimal("0"))
balance -= expenses_map.get(month, Decimal("0"))
data.append(
{
"month": month,
"balance": float(balance)
}
)
if year == today.year:
current_month_idx = today.month - 1
if 0 <= current_month_idx < len(data):
data[current_month_idx]["balance"] = float(self.current_balance())
return data
def balance_until(self, date):
incomes_total = self.incomes.filter(date__lte=date).aggregate(
total=Sum("amount")
)["total"] or Decimal("0")
expenses_total = self.expenses.filter(date__lte=date).aggregate(
total=Sum("amount")
)["total"] or Decimal("0")
return self.initial_balance + incomes_total - expenses_total
def monthly_net(self, year=None):
year = year or date.today().year
incomes = (
self.incomes.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
expenses = (
self.expenses.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
income_map = {i["month"]: Decimal(str(i["total"] or 0)) for i in incomes}
expenses_map = {e["month"]: Decimal(str(e["total"] or 0)) for e in expenses}
return [
{
"month": month,
"net": float(
income_map.get(month, Decimal("0"))
- expenses_map.get(month, Decimal("0"))
),
}
for month in range(1, 13)
]
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField(max_length=50)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="tags",
)
class Meta:
unique_together = ("name", "owner")
ordering = [Lower("name")]
def __str__(self):
return self.name
class Expense(models.Model):
date = models.DateField()
amount = models.DecimalField(max_digits=10, decimal_places=2)
description = models.TextField(blank=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="expenses",
)
category = models.ForeignKey(
Category,
on_delete=models.PROTECT,
related_name="expenses",
)
account = models.ForeignKey(
Account,
on_delete=models.PROTECT,
related_name="expenses",
)
tags = models.ManyToManyField(
Tag,
blank=True,
related_name="expenses",
)
created_at = models.DateField(auto_now_add=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return "{} - {}".format(self.date, self.amount)
class Income(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
account = models.ForeignKey(
Account, on_delete=models.CASCADE, related_name="incomes"
)
name = models.CharField(max_length=150)
amount = models.DecimalField(max_digits=12, decimal_places=2)
date = models.DateField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return f"{self.name} - {self.amount}"
class FuelEntry(models.Model):
expense = models.OneToOneField(
Expense, on_delete=models.CASCADE, related_name="fuel_data"
)
odometer = models.PositiveIntegerField() # kilometers
liters = models.DecimalField(max_digits=8, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["odometer"]
def km_since_previous(self):
previous = (
FuelEntry.objects.filter(
expense__owner=self.expense.owner, odometer__lt=self.odometer
)
.order_by("-odometer")
.first()
)
if previous:
return self.odometer - previous.odometer
return None
def price_per_liter(self):
if self.liters:
return self.expense.amount / self.liters
return 0
def consumption(self):
km = self.km_since_previous()
if km and km > 0:
return (self.liters / km) * 100
return None
class Goal(models.Model):
KIND_PAYMENT = "payment"
KIND_BUDGET = "budget"
KIND_SAVING = "saving"
KIND_CHOICES = [
(KIND_PAYMENT, "Pago / deuda"),
(KIND_BUDGET, "Presupuesto"),
(KIND_SAVING, "Ahorro"),
]
PERIOD_NONE = "none"
PERIOD_MONTH = "month"
PERIOD_YEAR = "year"
PERIOD_CHOICES = [
(PERIOD_NONE, "Sin reinicio"),
(PERIOD_MONTH, "Mensual"),
(PERIOD_YEAR, "Anual"),
]
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
target_amount = models.DecimalField(max_digits=12, decimal_places=2)
kind = models.CharField(
max_length=10, choices=KIND_CHOICES, default=KIND_PAYMENT
)
# Para pago y presupuesto
category = models.ForeignKey(
"Category", on_delete=models.CASCADE, null=True, blank=True
)
include_subcategories = models.BooleanField(default=True)
# Para ahorro (de momento se mide con el saldo de la cuenta)
account = models.ForeignKey(
"Account",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="goals",
)
# Desde cuándo cuenta (pago) / cada cuánto se reinicia (presupuesto)
start_date = models.DateField(null=True, blank=True)
period = models.CharField(
max_length=6, choices=PERIOD_CHOICES, default=PERIOD_NONE
)
created_at = models.DateTimeField(auto_now_add=True)
show_on_home = models.BooleanField(default=False)
def _period_start(self):
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
if self.kind == self.KIND_BUDGET:
today = date.today()
if self.period == self.PERIOD_MONTH:
return date(today.year, today.month, 1)
if self.period == self.PERIOD_YEAR:
return date(today.year, 1, 1)
return self.start_date
@cached_property
def progress(self):
"""Progreso del objetivo. Memoizado: se calcula una vez por instancia.
Ojo: es una cached_property, no un método. Se accede como `goal.progress`
(sin paréntesis), tanto en código como en plantillas. El valor se cachea
en la instancia la primera vez; si en la misma petición se modifican
gastos y se necesita recalcular, habría que crear una instancia nueva.
"""
if self.kind == self.KIND_SAVING:
# Provisional: saldo de la cuenta asociada. Cuando exista el
# módulo de inversiones, esta rama es lo único que hay que tocar.
return self.account.current_balance() if self.account else Decimal("0")
if not self.category:
return Decimal("0")
if self.include_subcategories:
category_ids = self.category.descendant_ids()
else:
category_ids = [self.category_id]
expenses = Expense.objects.filter(
owner=self.owner, category_id__in=category_ids
)
start = self._period_start()
if start:
expenses = expenses.filter(date__gte=start)
return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
def percentage(self):
if not self.target_amount:
return 0
return (self.progress / self.target_amount) * 100
def bar_width(self):
return min(float(self.percentage()), 100)
def progress_state(self):
pct = self.percentage()
if self.kind == self.KIND_BUDGET:
if pct > 100:
return "danger"
if pct >= 80:
return "warning"
return "ok"
return "complete" if pct >= 100 else "ok"
def is_exceeded(self):
"""True si es un presupuesto que ya se ha pasado del límite."""
return (
self.kind == self.KIND_BUDGET
and self.progress > self.target_amount
)
def __str__(self):
return self.name