Fixed bugs 2 #15

Merged
jkuijperm merged 13 commits from dev into main 2026-07-24 09:38:43 +00:00
5 changed files with 243 additions and 53 deletions
Showing only changes of commit c7ba7a970f - Show all commits

View File

@ -96,32 +96,16 @@ class CategoryForm(forms.ModelForm):
if self.instance.pk:
queryset = queryset.exclude(
pk__in=self._self_and_descendant_ids(self.instance)
pk__in=self.instance.descendant_ids()
)
self.fields["parent"].queryset = queryset
@staticmethod
def _self_and_descendant_ids(category):
ids = [category.pk]
pending = [category.pk]
while pending:
children = list(
Category.objects.filter(parent_id__in=pending)
.exclude(pk__in=ids)
.values_list("pk", flat=True)
)
ids.extend(children)
pending = children
return ids
def clean_parent(self):
parent = self.cleaned_data.get("parent")
if parent and self.instance.pk:
if parent.pk in self._self_and_descendant_ids(self.instance):
if parent.pk in self.instance.descendant_ids():
raise forms.ValidationError(
"Una categoría no puede ser su propio padre ni depender "
"de una de sus subcategorías."
@ -133,12 +117,61 @@ class CategoryForm(forms.ModelForm):
class GoalForm(forms.ModelForm):
class Meta:
model = Goal
fields = ['name', 'target_amount', 'category', "show_on_home"]
fields = [
'name',
'kind',
'target_amount',
'category',
"include_subcategories",
"account",
"start_date",
"period",
"show_on_home"
]
widgets = {
"start_date": forms.DateInput(
format="%Y-%m-%d", attrs={"type": "date"}
),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['category'].queryset = (
user.categories.all()
)
self.fields['category'].queryset = user.categories.all()
self.fields['account'].queryset = user.accounts.filter(active=True)
self.fields['category'].required = False
self.fields['account'].required = False
self.fields['start_date'].input_formats = ["%Y-%m-%d"]
def clean(self):
cleaned = super().clean()
kind = cleaned.get("kind")
if kind in (Goal.KIND_PAYMENT, Goal.KIND_BUDGET):
if not cleaned.get('category'):
self.add_error(
"category",
"Selecciona una categoría para este tipo de objetivo.",
)
cleaned["account"] = None
if kind == Goal.KIND_SAVING:
if not cleaned.get("account"):
self.add_error(
"account",
"Selecciona la cuenta donde se acumula el ahorro.",
)
cleaned["category"] = None
if kind == Goal.KIND_BUDGET:
if cleaned.get("period") == Goal.PERIOD_NONE:
self.add_error(
"period",
"Un presupuesto necesita un periodo: mensual o anual",
)
else:
cleaned["period"] = Goal.PERIOD_NONE
return cleaned

View File

@ -0,0 +1,44 @@
# Generated by Django 5.2.10 on 2026-07-23 14:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0009_goal_show_on_home'),
]
operations = [
migrations.AddField(
model_name='goal',
name='account',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='goals', to='expenses.account'),
),
migrations.AddField(
model_name='goal',
name='include_subcategories',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='goal',
name='kind',
field=models.CharField(choices=[('payment', 'Pago / deuda'), ('budget', 'Presupuesto'), ('saving', 'Ahorro')], default='payment', max_length=10),
),
migrations.AddField(
model_name='goal',
name='period',
field=models.CharField(choices=[('none', 'Sin reinicio'), ('month', 'Mensual'), ('year', 'Anual')], default='none', max_length=6),
),
migrations.AddField(
model_name='goal',
name='start_date',
field=models.DateField(blank=True, null=True),
),
migrations.AlterField(
model_name='goal',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='expenses.category'),
),
]

View File

@ -41,6 +41,22 @@ class Category(models.Model):
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(
@ -264,41 +280,119 @@ class FuelEntry(models.Model):
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)
category = models.ForeignKey("Category", on_delete=models.CASCADE)
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
def progress(self):
"""
Calculate the accumulated spending for the goal category.
This method returns the sum of all expenses of the owner in the goal's category.
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")
Returns:
Decimal: Total amount spent in the goal category, or Decimal('0') when there is no spending.
if not self.category:
return Decimal("0")
"""
total = Expense.objects.filter(
owner=self.owner,
category=self.category,
).aggregate(total=Sum("amount"))["total"]
if self.include_subcategories:
category_ids = self.category.descendant_ids()
else:
category_ids = [self.category_id]
return total or 0
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):
"""
Calculate the completion percentage of the goal.
This method returns how much of the target amount has been reached as a percentage.
Returns:
Decimal: Percentage of the goal that has been reached, or Decimal('0') when the target amount is zero.
"""
if self.target_amount == 0:
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

View File

@ -519,3 +519,16 @@ tbody tr:hover {
border-color: #1e7e34;
color: #1e7e34;
}
.progress-fill.ok {
background-color: #1e88e5;
}
.progress-fill.complete {
background-color: #1e7e34;
}
.progress-fill.warning {
background-color: #f9a825;
}
.progress-fill.danger {
background-color: #b71c1c;
}

View File

@ -14,6 +14,7 @@
<table>
<tr>
<th>Nombre</th>
<th>Tipo</th>
<th>Progreso</th>
<th></th>
</tr>
@ -21,22 +22,27 @@
{% for goal in goals %}
<tr>
<td>{{ goal.name }}</td>
<td>
{{ goal.get_kind_display }}
{% if goal.kind == "budget" %}
<small>({{ goal.get_period_display|lower }})</small>
{% endif %}
</td>
<td>
<div class="progress-container">
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
</span>
<div class="progress-bar">
<div class="progress-fill
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
style="width: {{ goal.percentage|unlocalize }}%"></div>
<div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div>
<span class="progress-label">
{{ goal.percentage|floatformat:1}}%
{{ goal.percentage|floatformat:1 }}%
</span>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
</div>
</td>