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>
422 lines
12 KiB
Python
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
|