Fixed bugs 2 #15
@ -96,32 +96,16 @@ class CategoryForm(forms.ModelForm):
|
|||||||
|
|
||||||
if self.instance.pk:
|
if self.instance.pk:
|
||||||
queryset = queryset.exclude(
|
queryset = queryset.exclude(
|
||||||
pk__in=self._self_and_descendant_ids(self.instance)
|
pk__in=self.instance.descendant_ids()
|
||||||
)
|
)
|
||||||
|
|
||||||
self.fields["parent"].queryset = queryset
|
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):
|
def clean_parent(self):
|
||||||
parent = self.cleaned_data.get("parent")
|
parent = self.cleaned_data.get("parent")
|
||||||
|
|
||||||
if parent and self.instance.pk:
|
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(
|
raise forms.ValidationError(
|
||||||
"Una categoría no puede ser su propio padre ni depender "
|
"Una categoría no puede ser su propio padre ni depender "
|
||||||
"de una de sus subcategorías."
|
"de una de sus subcategorías."
|
||||||
@ -133,12 +117,61 @@ class CategoryForm(forms.ModelForm):
|
|||||||
class GoalForm(forms.ModelForm):
|
class GoalForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Goal
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
user = kwargs.pop('user')
|
user = kwargs.pop('user')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
self.fields['category'].queryset = (
|
self.fields['category'].queryset = user.categories.all()
|
||||||
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
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -41,6 +41,22 @@ class Category(models.Model):
|
|||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
super().save(*args, **kwargs)
|
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):
|
class Account(models.Model):
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
@ -264,41 +280,119 @@ class FuelEntry(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Goal(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)
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
target_amount = models.DecimalField(max_digits=12, decimal_places=2)
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
show_on_home = models.BooleanField(default=False)
|
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):
|
def progress(self):
|
||||||
"""
|
if self.kind == self.KIND_SAVING:
|
||||||
Calculate the accumulated spending for the goal category.
|
# Provisional: saldo de la cuenta asociada. Cuando exista el
|
||||||
This method returns the sum of all expenses of the owner in the goal's category.
|
# 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:
|
if not self.category:
|
||||||
Decimal: Total amount spent in the goal category, or Decimal('0') when there is no spending.
|
return Decimal("0")
|
||||||
|
|
||||||
"""
|
if self.include_subcategories:
|
||||||
total = Expense.objects.filter(
|
category_ids = self.category.descendant_ids()
|
||||||
owner=self.owner,
|
else:
|
||||||
category=self.category,
|
category_ids = [self.category_id]
|
||||||
).aggregate(total=Sum("amount"))["total"]
|
|
||||||
|
|
||||||
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):
|
def percentage(self):
|
||||||
"""
|
if not self.target_amount:
|
||||||
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:
|
|
||||||
return 0
|
return 0
|
||||||
return (self.progress() / self.target_amount) * 100
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@ -519,3 +519,16 @@ tbody tr:hover {
|
|||||||
border-color: #1e7e34;
|
border-color: #1e7e34;
|
||||||
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;
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nombre</th>
|
<th>Nombre</th>
|
||||||
|
<th>Tipo</th>
|
||||||
<th>Progreso</th>
|
<th>Progreso</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -21,22 +22,27 @@
|
|||||||
{% for goal in goals %}
|
{% for goal in goals %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ goal.name }}</td>
|
<td>{{ goal.name }}</td>
|
||||||
|
<td>
|
||||||
|
{{ goal.get_kind_display }}
|
||||||
|
{% if goal.kind == "budget" %}
|
||||||
|
<small>({{ goal.get_period_display|lower }})</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="progress-container">
|
<div class="progress-container">
|
||||||
<span class="progress-label">
|
<span class="progress-label">
|
||||||
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
|
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
|
||||||
</span>
|
</span>
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-fill
|
<div class="progress-fill {{ goal.progress_state }}"
|
||||||
{% if goal.percentage < 50 %} low
|
style="width: {{ goal.bar_width|unlocalize }}%"></div>
|
||||||
{% elif goal.percentage < 80 %} medium
|
|
||||||
{% else %} high
|
|
||||||
{% endif %}"
|
|
||||||
style="width: {{ goal.percentage|unlocalize }}%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="progress-label">
|
<span class="progress-label">
|
||||||
{{ goal.percentage|floatformat:1}}%
|
{{ goal.percentage|floatformat:1 }}%
|
||||||
</span>
|
</span>
|
||||||
|
{% if goal.is_exceeded %}
|
||||||
|
<span class="badge badge-inactive">Excedido</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user