786 lines
26 KiB
Python
786 lines
26 KiB
Python
from datetime import date
|
|
from decimal import Decimal
|
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
from django.db import models, transaction
|
|
from django.conf import settings
|
|
from django.db.models import Q, 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",
|
|
)
|
|
|
|
is_system = models.BooleanField(
|
|
default=False,
|
|
help_text="Categoría creada por la app (traspasos, comisiones). No borrable.",
|
|
)
|
|
|
|
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",
|
|
)
|
|
|
|
is_transfer = models.BooleanField(
|
|
default=False,
|
|
verbose_name="Es un traspaso entre cuentas",
|
|
help_text=(
|
|
"Márcalo si este movimiento solo mueve dinero entre cuentas propias. "
|
|
"No contará como gasto ni como ingreso en los análisis, pero sí "
|
|
"afectará al saldo de la cuenta."
|
|
),
|
|
)
|
|
|
|
created_at = models.DateField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ["-date"]
|
|
|
|
def __str__(self):
|
|
return "{} - {}".format(self.date, self.amount)
|
|
|
|
@property
|
|
def transfer(self):
|
|
"""El Transfer del que este gasto es una pata, o None si es normal.
|
|
|
|
Cubre las dos patas de gasto de un traspaso: la de salida
|
|
(transfer_out) y la comisión (transfer_fee). Un gasto que pertenece a un
|
|
traspaso no se edita ni se borra por separado: se hace desde el
|
|
traspaso, que es quien mantiene las tres patas cuadradas.
|
|
"""
|
|
for relation in ("transfer_out", "transfer_fee"):
|
|
try:
|
|
return getattr(self, relation)
|
|
except ObjectDoesNotExist:
|
|
continue
|
|
return None
|
|
|
|
|
|
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()
|
|
|
|
is_transfer = models.BooleanField(
|
|
default=False,
|
|
verbose_name="Es un traspaso entre cuentas",
|
|
help_text=(
|
|
"Márcalo si este movimiento solo mueve dinero entre cuentas propias. "
|
|
"No contará como gasto ni como ingreso en los análisis, pero sí "
|
|
"afectará al saldo de la cuenta."
|
|
),
|
|
)
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ["-date"]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} - {self.amount}"
|
|
|
|
@property
|
|
def transfer(self):
|
|
"""El Transfer del que este ingreso es la pata de entrada, o None."""
|
|
try:
|
|
return self.transfer_in
|
|
except ObjectDoesNotExist:
|
|
return None
|
|
|
|
|
|
def _system_category(user, slug, name):
|
|
"""Categoría de sistema del usuario (traspasos, comisiones), creándola si falta.
|
|
|
|
Se busca por slug y no por nombre porque el nombre lo puede cambiar el
|
|
usuario; el slug es el identificador estable.
|
|
"""
|
|
cat, _ = Category.objects.get_or_create(
|
|
slug=slug,
|
|
owner=user,
|
|
defaults={"name": name, "is_system": True},
|
|
)
|
|
return cat
|
|
|
|
|
|
class Transfer(models.Model):
|
|
"""Un traspaso entre dos cuentas propias.
|
|
|
|
No sustituye a los dos movimientos que ya registraban un traspaso: los
|
|
orquesta. Sigue habiendo un Expense en la cuenta origen y un Income en la
|
|
destino, los dos con is_transfer=True, pero ahora están vinculados aquí y
|
|
es este modelo quien los crea, los edita y los borra a la vez (sync() y
|
|
delete()).
|
|
|
|
Por eso los cuatro métodos de saldo de Account (current_balance,
|
|
monthly_balance, balance_until, monthly_net) no saben nada de Transfer y no
|
|
necesitan saberlo: el traspaso ya está dentro de sus sumas, porque sus dos
|
|
patas siguen siendo un Income y un Expense reales.
|
|
"""
|
|
|
|
TRANSFER_CATEGORY_SLUG = "traspaso"
|
|
FEE_CATEGORY_SLUG = "comision"
|
|
|
|
owner = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transfers"
|
|
)
|
|
date = models.DateField()
|
|
# max_digits=12 frente a los 10 de Expense.amount: un traspaso de más de diez
|
|
# dígitos no cabría en su pata de gasto. Se deja así a propósito, porque
|
|
# igualarlo obliga a migrar Expense por un caso de 100 millones de euros.
|
|
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
|
from_account = models.ForeignKey(
|
|
Account, on_delete=models.PROTECT, related_name="transfers_out"
|
|
)
|
|
to_account = models.ForeignKey(
|
|
Account, on_delete=models.PROTECT, related_name="transfers_in"
|
|
)
|
|
|
|
# Las dos patas y la comisión opcional. SET_NULL para que si algo las borra
|
|
# por fuera el Transfer no arrastre, pero el flujo normal las borra en cascada
|
|
# desde el propio Transfer (ver delete()).
|
|
#
|
|
# blank=True porque un traspaso siempre acaba con sus dos patas, pero no las
|
|
# tiene en el instante en que se construye: sync() las crea a partir de él.
|
|
# Exigirlas en full_clean() describiría mal ese momento.
|
|
expense_leg = models.OneToOneField(
|
|
Expense,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="transfer_out",
|
|
)
|
|
income_leg = models.OneToOneField(
|
|
Income,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="transfer_in",
|
|
)
|
|
|
|
fee_amount = models.DecimalField(
|
|
max_digits=10, decimal_places=2, null=True, blank=True
|
|
)
|
|
fee_expense = models.OneToOneField(
|
|
Expense,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="transfer_fee",
|
|
)
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ["-date"]
|
|
|
|
def __str__(self):
|
|
return f"{self.date} - {self.amount} ({self.from_account} → {self.to_account})"
|
|
|
|
def clean(self):
|
|
if self.from_account_id and self.from_account_id == self.to_account_id:
|
|
raise ValidationError(
|
|
{"to_account": "El origen y el destino tienen que ser cuentas distintas."}
|
|
)
|
|
|
|
@transaction.atomic
|
|
def sync(self):
|
|
"""Guarda el traspaso y pone sus patas al día en la misma transacción.
|
|
|
|
Un solo método para crear y para editar: si no hay patas las crea, y si
|
|
las hay les propaga importe, fecha y cuentas (la categoría no, que el
|
|
usuario puede haberla recolocado). La comisión aparece o desaparece
|
|
según fee_amount.
|
|
|
|
Ojo con la comisión: va con is_transfer=False a propósito. Las dos patas
|
|
del movimiento solo mueven dinero entre cuentas propias y no son gasto
|
|
real, pero la comisión sale del patrimonio de verdad, así que es un gasto
|
|
normal y tiene que contar en el dashboard y en los presupuestos.
|
|
"""
|
|
if self.expense_leg_id:
|
|
leg = self.expense_leg
|
|
leg.date = self.date
|
|
leg.amount = self.amount
|
|
leg.account = self.from_account
|
|
leg.description = self._expense_description()
|
|
leg.save()
|
|
else:
|
|
self.expense_leg = Expense.objects.create(
|
|
owner=self.owner,
|
|
account=self.from_account,
|
|
category=_system_category(
|
|
self.owner, self.TRANSFER_CATEGORY_SLUG, "Traspaso"
|
|
),
|
|
amount=self.amount,
|
|
date=self.date,
|
|
description=self._expense_description(),
|
|
is_transfer=True,
|
|
)
|
|
|
|
if self.income_leg_id:
|
|
leg = self.income_leg
|
|
leg.date = self.date
|
|
leg.amount = self.amount
|
|
leg.account = self.to_account
|
|
leg.name = self._income_name()
|
|
leg.save()
|
|
else:
|
|
self.income_leg = Income.objects.create(
|
|
owner=self.owner,
|
|
account=self.to_account,
|
|
name=self._income_name(),
|
|
amount=self.amount,
|
|
date=self.date,
|
|
is_transfer=True,
|
|
)
|
|
|
|
# La comisión que sobra se borra después de guardar el Transfer, para no
|
|
# dejar ni un instante el FK apuntando a una fila que ya no existe.
|
|
stale_fee = None
|
|
|
|
if self.fee_amount:
|
|
if self.fee_expense_id:
|
|
fee = self.fee_expense
|
|
fee.date = self.date
|
|
fee.amount = self.fee_amount
|
|
fee.account = self.from_account
|
|
fee.save()
|
|
else:
|
|
self.fee_expense = Expense.objects.create(
|
|
owner=self.owner,
|
|
account=self.from_account,
|
|
category=_system_category(
|
|
self.owner, self.FEE_CATEGORY_SLUG, "Comisión"
|
|
),
|
|
amount=self.fee_amount,
|
|
date=self.date,
|
|
description=self._fee_description(),
|
|
is_transfer=False,
|
|
)
|
|
elif self.fee_expense_id:
|
|
stale_fee = self.fee_expense
|
|
self.fee_expense = None
|
|
|
|
self.save()
|
|
|
|
if stale_fee is not None:
|
|
stale_fee.delete()
|
|
|
|
return self
|
|
|
|
@transaction.atomic
|
|
def delete(self, *args, **kwargs):
|
|
"""Borra el traspaso y sus tres patas.
|
|
|
|
Las patas son SET_NULL, así que borrarlas no arrastraría el Transfer;
|
|
el arrastre va en este sentido, y va aquí y no en la vista para que
|
|
valga también desde el shell o los tests.
|
|
"""
|
|
legs = [self.expense_leg, self.income_leg, self.fee_expense]
|
|
result = super().delete(*args, **kwargs)
|
|
|
|
for leg in legs:
|
|
if leg is not None:
|
|
leg.delete()
|
|
|
|
return result
|
|
|
|
def _expense_description(self):
|
|
return f"Traspaso a {self.to_account.name}"
|
|
|
|
def _income_name(self):
|
|
return f"Traspaso desde {self.from_account.name}"
|
|
|
|
def _fee_description(self):
|
|
return f"Comisión del traspaso a {self.to_account.name}"
|
|
|
|
|
|
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
|
|
|
|
|
|
def _deduped_total(movements):
|
|
"""Suma importes sin que el JOIN del M2M cuente la misma fila dos veces.
|
|
|
|
Un aggregate() sobre un queryset que lleva el JOIN de la vinculación dentro
|
|
suma cada movimiento tantas veces como objetivos tenga vinculados, y
|
|
.distinct() no lo arregla: el DISTINCT se aplica a las filas del SELECT
|
|
final y en un aggregate no hay filas que deduplicar, porque el JOIN ya
|
|
multiplicó antes de sumar. Sum("amount", distinct=True) es todavía peor,
|
|
porque deduplica por el valor del importe: dos gastos distintos de 10 € se
|
|
sumarían como uno.
|
|
|
|
Así que primero los ids (ahí el DISTINCT sí vale) y luego la suma. El
|
|
`pk__in` con un queryset dentro es una subconsulta, no un segundo viaje.
|
|
"""
|
|
ids = movements.values("pk").distinct()
|
|
total = movements.model.objects.filter(pk__in=ids).aggregate(
|
|
total=Sum("amount")
|
|
)["total"]
|
|
|
|
return total or Decimal("0")
|
|
|
|
|
|
class Goal(models.Model):
|
|
KIND_PAYMENT = "payment"
|
|
KIND_BUDGET = "budget"
|
|
KIND_SAVING = "saving"
|
|
KIND_COLLECTION = "collection"
|
|
|
|
KIND_CHOICES = [
|
|
(KIND_PAYMENT, "Pago / deuda"),
|
|
(KIND_BUDGET, "Presupuesto"),
|
|
(KIND_SAVING, "Ahorro"),
|
|
(KIND_COLLECTION, "Cobro pendiente"),
|
|
]
|
|
|
|
# Qué objetivos admiten vincular cada tipo de movimiento. Las dos vías de
|
|
# marcado (el formulario del movimiento y la pantalla del objetivo) leen de
|
|
# aquí: si cada una lo decidiera por su cuenta, acabarían divergiendo.
|
|
KINDS_LINKING_EXPENSES = (KIND_PAYMENT, KIND_BUDGET)
|
|
KINDS_LINKING_INCOMES = (KIND_COLLECTION,)
|
|
|
|
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)
|
|
|
|
# Movimientos sueltos vinculados a mano. Conviven con la selección por
|
|
# categoría, no la sustituyen: un objetivo puede tirar de categoría, de
|
|
# movimientos sueltos, o de los dos a la vez.
|
|
#
|
|
# blank=True y no null: un M2M no admite null. Borrar el objetivo limpia la
|
|
# tabla intermedia y no toca los movimientos, que es lo que se quiere.
|
|
linked_expenses = models.ManyToManyField(
|
|
"Expense", blank=True, related_name="goals", verbose_name="Gastos vinculados"
|
|
)
|
|
linked_incomes = models.ManyToManyField(
|
|
"Income", blank=True, related_name="goals", verbose_name="Ingresos vinculados"
|
|
)
|
|
|
|
@property
|
|
def links_incomes(self):
|
|
"""Si este objetivo se mide con ingresos en vez de con gastos."""
|
|
return self.kind in self.KINDS_LINKING_INCOMES
|
|
|
|
@property
|
|
def links(self):
|
|
"""El M2M que le toca a este objetivo, para no repetir el if por ahí."""
|
|
return self.linked_incomes if self.links_incomes else self.linked_expenses
|
|
|
|
@property
|
|
def accepts_links(self):
|
|
"""Si vincularle movimientos significa algo.
|
|
|
|
El ahorro no: se mide con el saldo de su cuenta, así que un gasto
|
|
vinculado ahí quedaría guardado y no contaría en el progreso.
|
|
"""
|
|
return self.kind in self.KINDS_LINKING_EXPENSES + self.KINDS_LINKING_INCOMES
|
|
|
|
def candidate_movements(self):
|
|
"""Movimientos que este objetivo puede vincular.
|
|
|
|
Único sitio donde se decide qué es vinculable; lo usan las dos vías de
|
|
marcado. Fuera las patas de un traspaso: mover dinero entre cuentas
|
|
propias no es ni un pago ni un cobro. La comisión sí entra, porque es
|
|
gasto real (va con is_transfer=False) y cuenta en los análisis.
|
|
"""
|
|
model = Income if self.links_incomes else Expense
|
|
movements = model.objects.filter(owner_id=self.owner_id).exclude(
|
|
is_transfer=True
|
|
)
|
|
|
|
start = self._period_start()
|
|
if start:
|
|
movements = movements.filter(date__gte=start)
|
|
|
|
return movements
|
|
|
|
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.
|
|
# Ojo: aquí los traspasos SÍ cuentan. Mandar dinero a la cuenta de
|
|
# ahorro es precisamente cómo se progresa en un objetivo de ahorro,
|
|
# así que no filtres is_transfer en esta rama.
|
|
return self.account.current_balance() if self.account else Decimal("0")
|
|
|
|
if self.kind == self.KIND_COLLECTION:
|
|
# Un cobro pendiente mide lo que te han devuelto, así que suma los
|
|
# ingresos vinculados a mano. Aquí no hay selección por categoría
|
|
# que valga: Income no tiene categoría, que es justo el motivo de
|
|
# que exista la vinculación.
|
|
incomes = Income.objects.filter(goals=self, owner_id=self.owner_id).exclude(
|
|
is_transfer=True
|
|
)
|
|
|
|
return _deduped_total(self._since_start(incomes))
|
|
|
|
category_ids = []
|
|
# Por _id y no por el objeto: mirar self.category traería la categoría
|
|
# de la base de datos, una consulta por objetivo al pintar una lista.
|
|
if self.category_id:
|
|
if self.include_subcategories:
|
|
category_ids = self.category.descendant_ids()
|
|
else:
|
|
category_ids = [self.category_id]
|
|
|
|
# Un solo queryset con un OR, y no dos sumas por separado: un gasto que
|
|
# esté en la categoría del objetivo Y además vinculado a mano se
|
|
# contaría dos veces. No daría error, daría un número mal.
|
|
scope = Q(goals=self)
|
|
if category_ids:
|
|
scope |= Q(category_id__in=category_ids)
|
|
|
|
# Pago y presupuesto miden gasto real, así que los traspasos entre
|
|
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
|
|
expenses = Expense.objects.filter(scope, owner_id=self.owner_id).exclude(
|
|
is_transfer=True
|
|
)
|
|
|
|
return _deduped_total(self._since_start(expenses))
|
|
|
|
def _since_start(self, movements):
|
|
"""Acota al periodo del objetivo. El cobro pendiente lo necesita igual."""
|
|
start = self._period_start()
|
|
|
|
return movements.filter(date__gte=start) if start else movements
|
|
|
|
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
|