Compare commits

..

No commits in common. "790fa214e3c6f95959a1057ed76096e445f7e331" and "a5c47d1380499caf1803d3be81f0916ce072c95d" have entirely different histories.

28 changed files with 180 additions and 1136 deletions

View File

@ -2,7 +2,6 @@ pipeline{
agent any
environment {
DEBUG = 'TRUE'
DJANGO_SECRET_KEY = 'test-secret-key-for-ci'
DJANGO_SETTINGS_MODULE = "expenses_manager.settings"
PYTHONUNBUFFERED = "1"
@ -34,6 +33,7 @@ pipeline{
cd expenses_manager
. venv/bin/activate
export SECRET_KEY=$DJANGO_SECRET_KEY
venv/bin/pytest --cov
'''
}

View File

@ -92,86 +92,20 @@ class CategoryForm(forms.ModelForm):
super().__init__(*args, **kwargs)
if user:
queryset = Category.objects.filter(owner=user)
if self.instance.pk:
queryset = queryset.exclude(
pk__in=self.instance.descendant_ids()
self.fields["parent"].queryset = Category.objects.filter(
owner=user,
)
self.fields["parent"].queryset = queryset
def clean_parent(self):
parent = self.cleaned_data.get("parent")
if parent and self.instance.pk:
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."
)
return parent
class GoalForm(forms.ModelForm):
class Meta:
model = Goal
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"}
),
}
fields = ['name', 'target_amount', 'category', "show_on_home"]
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
super().__init__(*args, **kwargs)
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.",
self.fields['category'].queryset = (
user.categories.all()
)
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

@ -1,44 +0,0 @@
# 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,22 +41,6 @@ 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(
@ -151,33 +135,19 @@ class Account(models.Model):
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"))
)
data = []
expenses = (
self.expenses.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
for month in range(1, 13):
income = self.incomes.filter(date__year=year, date__month=month).aggregate(
total=Sum("amount")
)["total"] or Decimal("0")
expense = self.expenses.filter(
date__year=year, date__month=month
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
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.append({"month": month, "net": float(income - expense)})
return [
{
"month": month,
"net": float(
income_map.get(month, Decimal("0"))
- expenses_map.get(month, Decimal("0"))
),
}
for month in range(1, 13)
]
return data
def __str__(self):
return self.name
@ -294,119 +264,41 @@ 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)
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
)
category = models.ForeignKey("Category", on_delete=models.CASCADE)
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):
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")
"""
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 not self.category:
return Decimal("0")
Returns:
Decimal: Total amount spent in the goal category, or Decimal('0') when there is no spending.
if self.include_subcategories:
category_ids = self.category.descendant_ids()
else:
category_ids = [self.category_id]
"""
total = Expense.objects.filter(
owner=self.owner,
category=self.category,
).aggregate(total=Sum("amount"))["total"]
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")
return total or 0
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 (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,16 +519,3 @@ 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

@ -1,38 +0,0 @@
{% extends "expenses/base.html" %}
{% block title %}
Categorías
{% endblock %}
{% block content %}
<h2>Eliminar categoría</h2>
<p>¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?</p>
{% if expense_count %}
<p class="form-errors">
Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }}
y no se puede eliminar. Reasigna esos gastos a otra categoría primero.
</p>
{% else %}
{% if children %}
<p class="form-errors">
Atención: también se eliminarán sus subcategorías:
{% for child in children %}<strong>{{child.name}}</strong>{% if not forloop.last %}, {% endif %}{% endfor %}.
</p>
{% endif %}
{% if goal_count %}
<p class="form-errors">
Atención: se eliminarán también {{ goal_count }} objetivo{{ goal_count|pluralize }} asociado{{ goal_count|pluralize }} a esta categoría.
</p>
{% endif %}
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button class="btn danger">Eliminar</button>
<a class="btn" href="{% url 'category_list' %}">Cancelar</a>
</form>
{% endblock %}

View File

@ -1,16 +0,0 @@
{% extends "expenses/base.html" %}
{% block title %}
Categorías
{% endblock %}
{% block content %}
<h2>Editar categoría</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Guardar</button>
<a class="btn" href="{% url 'category_list' %}">Cancelar</a>
</form>
{% endblock %}

View File

@ -30,10 +30,6 @@
<tr>
<td>{{ category.name }}</td>
<td>{% if category.parent %}{{ category.parent.name }}{% endif %}</td>
<td class="table-actions">
<a href="{% url 'category_edit' category.id %}">Editar</a>
<a href="{% url 'category_delete' category.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% endfor %}
</tbody>

View File

@ -128,13 +128,12 @@
{% endfor %}
</td>
<td class="table-actions">
{% if expense.fuel_data %}
<a href="{% url 'fuel_edit' expense.id %}?next={{ request.get_full_path|urlencode }}">Editar</a>
<a href="{% url 'fuel_delete' expense.id %}?next={{ request.get_full_path|urlencode }}" class="danger">Eliminar</a>
{% if expense.category.slug == 'gasolina' %}
<a href="{% url 'fuel_edit' expense.id %}">Editar</a>
{% else %}
<a href="{% url 'expense_edit' expense.id %}">Editar</a>
<a href="{% url 'expense_delete' expense.id %}" class="danger">Eliminar</a>
{% endif %}
<a href="{% url 'expense_delete' expense.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% empty %}

View File

@ -68,13 +68,14 @@
{% for goal in goals %}
<div class="goal-card">
<strong>{{ goal.name }}</strong>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
<div class="progress-bar">
<div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
<div class="progress-fill
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
style="width: {{ goal.percentage|unlocalize }}%"></div>
</div>
<span class="progress-label">

View File

@ -1,26 +0,0 @@
{% extends "expenses/base.html" %}
{% block title %}
Repostajes
{% endblock %}
{% block content %}
<h2>Eliminar repostaje.</h2>
<p>
¿Seguro que quieres eliminar el repostaje del
<strong>{{ fuel.expense.date}}</strong>
({{ fuel.liters }}L por {{fuel.expense.amount}}€)?
</p>
<p class="form-errors">
Se eliminará también el gasto asociado en el listado de gastos.
</p>
<form method="post">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button class="btn danger">Eliminar</button>
<a class="btn" href="{% url 'fuel_list' %}">Cancelar</a>
</form>
{% endblock %}

View File

@ -19,7 +19,6 @@
<form method="post">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
{{ form.as_p }}
<button type="submit">
{% if editing %}
@ -30,11 +29,9 @@
</button>
</form>
{% if next %}
<a class="btn" href="{{ next }}">Volver</a>
{% elif editing %}
<a class="btn" href="{% url 'expense_list' %}">Volver</a>
{% if editing %}
<a href="{% url 'expense_list' %}">Volver</a>
{% else %}
<a class="btn" href="{% url 'fuel_list' %}">Volver</a>
<a href="{% url 'fuel_list' %}">Volver</a>
{% endif %}
{% endblock %}

View File

@ -21,7 +21,6 @@
<th>Gasto</th>
<th>€/L</th>
<th>Km desde anterior</th>
<th></th>
</tr>
</thead>
<tbody>
@ -33,10 +32,6 @@
<td>{{ fuel.expense.amount }}</td>
<td>{{ fuel.price_per_liter|floatformat:2 }}</td>
<td>{{ fuel.km_since_previous }}</td>
<td class="table-actions">
<a href="{% url 'fuel_edit' fuel.expense.id %}?next={{ request.get_full_path|urlencode }}">Editar</a>
<a href="{% url 'fuel_delete' fuel.expense.id %}?next={{ request.get_full_path|urlencode }}" class="danger">Eliminar</a>
</td>
</tr>
{% endfor %}
</tbody>

View File

@ -14,7 +14,6 @@
<table>
<tr>
<th>Nombre</th>
<th>Tipo</th>
<th>Progreso</th>
<th></th>
</tr>
@ -22,27 +21,22 @@
{% 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 {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
<div class="progress-fill
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
style="width: {{ goal.percentage|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>

View File

@ -1,31 +0,0 @@
import pytest
from decimal import Decimal
from expenses.models import Account, Category
@pytest.fixture
def user(db, django_user_model):
return django_user_model.objects.create_user(
username="tester", password="testpass123"
)
@pytest.fixture
def auth_client(client, user):
client.login(username=user.username, password="testpass123")
return client
@pytest.fixture
def account(user):
return Account.objects.create(
owner=user,
name="Cuenta principal",
initial_balance=Decimal("0"),
active=True,
)
@pytest.fixture
def category(user):
return Category.objects.create(owner=user, name="General")

View File

@ -1,86 +0,0 @@
import pytest
from datetime import date
from decimal import Decimal
from expenses.models import Account, Expense, Income, Category
pytestmark = pytest.mark.django_db
def test_current_balance_reflects_income_and_expenses(user, category):
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
Income.objects.create(owner=user, account=acc, name="Nomina", amount=Decimal("50"), date=date.today())
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("30"), date=date.today())
assert acc.current_balance() == Decimal("120")
def test_monthly_balance_returns_12_rows_in_order(user):
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
data = acc.monthly_balance(2020)
assert len(data) == 12
assert [row["month"] for row in data] == list(range(1, 13))
assert all(row["balance"] == 100.0 for row in data)
def test_monthly_balance_is_cumulative_across_months(user, category):
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
year = date.today().year - 1 # avoid the current-month live-balance patch
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("40"), date=date(year, 3, 1))
Income.objects.create(owner=user, account=acc, name="Extra", amount=Decimal("100"), date=date(year, 6, 1))
data = acc.monthly_balance(year)
assert data[2]["balance"] == -40.0 # month 3
assert data[5]["balance"] == 60.0 # month 6, cumulative
assert data[11]["balance"] == 60.0 # carries forward to December
def test_monthly_balance_respects_year_param_and_excludes_other_years(user, category):
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("50"), date=date(2023, 6, 15))
Income.objects.create(owner=user, account=acc, name="Extra", amount=Decimal("30"), date=date(2024, 3, 10))
data_2023 = acc.monthly_balance(2023)
assert data_2023[5]["balance"] == -50.0 # month 6
assert data_2023[11]["balance"] == -50.0
data_2024 = acc.monthly_balance(2024)
assert data_2024[1]["balance"] == -50.0 # month 2: prior year's expense folded into start
assert data_2024[2]["balance"] == -20.0 # month 3: 2024 income applied
def test_monthly_balance_current_month_patched_with_live_balance(user, category):
today = date.today()
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("30"), date=today)
data = acc.monthly_balance(today.year)
assert data[today.month - 1]["balance"] == float(acc.current_balance())
def test_monthly_net_returns_income_minus_expense_per_month(user, category):
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
Income.objects.create(owner=user, account=acc, name="Nomina", amount=Decimal("100"), date=date(2024, 4, 5))
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("40"), date=date(2024, 4, 20))
data = acc.monthly_net(2024)
assert len(data) == 12
assert data[3]["net"] == 60.0 # month 4
assert data[0]["net"] == 0.0
def test_balance_until_only_counts_entries_on_or_before_date(user, category):
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("20"), date=date(2024, 1, 10))
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("5"), date=date(2024, 1, 15))
Income.objects.create(owner=user, account=acc, name="Later", amount=Decimal("50"), date=date(2024, 1, 20))
assert acc.balance_until(date(2024, 1, 15)) == Decimal("-25")

View File

@ -1,101 +0,0 @@
import pytest
from datetime import date
from decimal import Decimal
from django.contrib.messages import get_messages
from django.urls import reverse
from django.utils.text import slugify
from expenses.models import Category, Expense
from expenses.forms import CategoryForm
pytestmark = pytest.mark.django_db
def test_slug_auto_generated_from_name_on_save(user):
cat = Category.objects.create(name="Ropa y Calzado", owner=user)
assert cat.slug == slugify("Ropa y Calzado")
def test_slug_not_overwritten_if_explicitly_set(user):
cat = Category(name="Comida", slug="mi-slug-personalizado", owner=user)
cat.save()
assert cat.slug == "mi-slug-personalizado"
def test_descendant_ids_includes_grandchildren(user):
parent = Category.objects.create(name="A", owner=user)
child = Category.objects.create(name="B", owner=user, parent=parent)
grandchild = Category.objects.create(name="C", owner=user, parent=child)
ids = set(parent.descendant_ids())
assert ids == {parent.pk, child.pk, grandchild.pk}
def test_descendant_ids_can_exclude_self(user):
parent = Category.objects.create(name="A", owner=user)
child = Category.objects.create(name="B", owner=user, parent=parent)
ids = parent.descendant_ids(include_self=False)
assert parent.pk not in ids
assert child.pk in ids
def test_categoryform_parent_persists_on_save(auth_client, user):
other = Category.objects.create(name="Otra", owner=user)
auth_client.post(reverse('category_list'), {'name': 'Nueva', 'parent': str(other.pk)})
created = Category.objects.get(name='Nueva', owner=user)
assert created.parent_id == other.pk
def test_categoryform_excludes_self_and_descendants_from_parent_queryset_when_editing(user):
parent = Category.objects.create(name="P", owner=user)
child = Category.objects.create(name="C", owner=user, parent=parent)
form = CategoryForm(instance=parent, user=user)
queryset_ids = set(form.fields['parent'].queryset.values_list('pk', flat=True))
assert parent.pk not in queryset_ids
assert child.pk not in queryset_ids
def test_categoryform_clean_parent_rejects_self_as_parent(user):
cat = Category.objects.create(name="X", owner=user)
form = CategoryForm(data={'name': 'X', 'parent': str(cat.pk)}, instance=cat, user=user)
assert not form.is_valid()
assert 'parent' in form.errors
def test_categoryform_rejects_descendant_as_parent(user):
parent = Category.objects.create(name="P", owner=user)
child = Category.objects.create(name="C", owner=user, parent=parent)
form = CategoryForm(data={'name': 'P', 'parent': str(child.pk)}, instance=parent, user=user)
assert not form.is_valid()
def test_category_delete_with_expenses_is_protected(auth_client, user, account, category):
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
response = auth_client.post(reverse('category_delete', args=[category.pk]))
assert response.status_code == 302
assert Category.objects.filter(pk=category.pk).exists()
messages = [str(m) for m in get_messages(response.wsgi_request)]
assert any("gastos asociados" in m for m in messages)
def test_category_delete_without_expenses_succeeds(auth_client, user):
cat = Category.objects.create(name="Vacia", owner=user)
response = auth_client.post(reverse('category_delete', args=[cat.pk]))
assert response.status_code == 302
assert not Category.objects.filter(pk=cat.pk).exists()

View File

@ -2,7 +2,7 @@ import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Expense, Category
from expenses.models import Account, Expense, Category
pytestmark = pytest.mark.django_db
@ -11,39 +11,53 @@ def test_dashboard_requires_login(client):
response = client.get(url)
assert response.status_code == 302
def test_dashboard_logged_user_can_access(auth_client):
def test_dashboard_logged_user_can_access(client, django_user_model):
user = django_user_model.objects.create_user(
username='test',
password='1234'
)
client.login(username='test', password='1234')
url = reverse('dashboard')
response = auth_client.get(url)
response = client.get(url)
assert response.status_code == 200
def test_dashboard_groups_by_category(auth_client, user, account):
def test_dashboard_groups_by_category(client, django_user_model):
user = django_user_model.objects.create_user(
username='test',
password='1234'
)
client.login(username='test', password='1234')
food = Category.objects.create(name='Food', owner=user)
rent = Category.objects.create(name='Rent', owner=user)
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
Expense.objects.create(
owner=user,
category=food,
amount=Decimal('10'),
date=date(2024, 1, 1),
account=account,
account=general_account,
)
Expense.objects.create(
owner=user,
category=food,
amount=Decimal('5'),
date=date(2024, 1, 2),
account=account,
account=general_account,
)
Expense.objects.create(
owner=user,
category=rent,
amount=Decimal('20'),
date=date(2024, 1, 3),
account=account,
account=general_account,
)
response = auth_client.get(
response = client.get(
reverse('dashboard'),
{'year': 2024}
)
@ -53,30 +67,39 @@ def test_dashboard_groups_by_category(auth_client, user, account):
assert {'category__name': 'Food', 'total': Decimal('15')} in data
assert {'category__name': 'Rent', 'total': Decimal('20')} in data
def test_dashboard_filters_by_year(auth_client, user, account, category):
def test_dashboard_filters_by_year(client, django_user_model):
user = django_user_model.objects.create_user(
username='test',
password='1234'
)
client.login(username='test', password='1234')
cat = Category.objects.create(name='General', owner=user)
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
Expense.objects.create(
owner=user,
category=category,
category=cat,
amount=10,
date=date(2023, 5, 1),
account=account,
account=general_account,
)
Expense.objects.create(
owner=user,
category=category,
category=cat,
amount=20,
date=date(2024, 5, 1),
account=account,
account=general_account,
)
response = auth_client.get(
response = client.get(
reverse('dashboard'),
{'year': 2024},
)
chart_data = response.context['chart_data']
by_month = list(response.context['by_month'])
assert len(chart_data) == 12
assert chart_data[4] == 20.0 # May = month 5 -> index 4
assert sum(chart_data) == 20.0 # only the 2024 expense contributes
totals = [row['total'] for row in by_month]
assert Decimal('20') in totals
assert Decimal('10') not in totals

View File

@ -1,61 +0,0 @@
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Goal, Tag, Income
pytestmark = pytest.mark.django_db
def test_goal_delete_get_does_not_delete(auth_client, user):
goal = Goal.objects.create(owner=user, name="Meta", target_amount=Decimal("100"))
response = auth_client.get(reverse('goal_delete', args=[goal.pk]))
assert response.status_code == 200
assert Goal.objects.filter(pk=goal.pk).exists()
def test_goal_delete_post_deletes(auth_client, user):
goal = Goal.objects.create(owner=user, name="Meta", target_amount=Decimal("100"))
response = auth_client.post(reverse('goal_delete', args=[goal.pk]))
assert response.status_code == 302
assert not Goal.objects.filter(pk=goal.pk).exists()
def test_tag_delete_get_does_not_delete(auth_client, user):
tag = Tag.objects.create(name="Food", owner=user)
response = auth_client.get(reverse('tag_delete', args=[tag.pk]))
assert response.status_code == 200
assert Tag.objects.filter(pk=tag.pk).exists()
def test_tag_delete_post_deletes(auth_client, user):
tag = Tag.objects.create(name="Food", owner=user)
response = auth_client.post(reverse('tag_delete', args=[tag.pk]))
assert response.status_code == 302
assert not Tag.objects.filter(pk=tag.pk).exists()
def test_income_delete_get_does_not_delete(auth_client, user, account):
income = Income.objects.create(owner=user, account=account, name="Nomina", amount=Decimal("500"), date=date.today())
response = auth_client.get(reverse('income_delete', args=[income.pk]))
assert response.status_code == 200
assert Income.objects.filter(pk=income.pk).exists()
def test_income_delete_post_deletes(auth_client, user, account):
income = Income.objects.create(owner=user, account=account, name="Nomina", amount=Decimal("500"), date=date.today())
response = auth_client.post(reverse('income_delete', args=[income.pk]))
assert response.status_code == 302
assert not Income.objects.filter(pk=income.pk).exists()

View File

@ -1,42 +0,0 @@
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Expense, Tag
pytestmark = pytest.mark.django_db
def test_tag_filter_with_non_numeric_value_returns_200(auth_client, user, account, category):
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
response = auth_client.get(reverse('expense_list'), {'tag': 'abc'})
assert response.status_code == 200
assert len(response.context['page_obj']) == 1
def test_tag_filter_with_valid_tag_returns_only_matching_expenses(auth_client, user, account, category):
tag = Tag.objects.create(name="Food", owner=user)
tagged = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
tagged.tags.add(tag)
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date.today())
response = auth_client.get(reverse('expense_list'), {'tag': str(tag.pk)})
results = list(response.context['page_obj'])
assert results == [tagged]
def test_tag_filter_multiple_tags_are_ORed(auth_client, user, account, category):
tag1 = Tag.objects.create(name="Food", owner=user)
tag2 = Tag.objects.create(name="Transport", owner=user)
expense1 = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
expense1.tags.add(tag1)
expense2 = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date.today())
expense2.tags.add(tag2)
response = auth_client.get(reverse('expense_list'), {'tag': [str(tag1.pk), str(tag2.pk)]})
results = set(response.context['page_obj'])
assert results == {expense1, expense2}

View File

@ -1,27 +1,39 @@
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Expense, Tag
from expenses.models import Expense, Category, Tag, Account
pytestmark = pytest.mark.django_db
def test_expense_can_have_tags(user, category, account):
def test_expense_can_have_tags(client, django_user_model):
user = django_user_model.objects.create_user(username='test', password='1234')
client.login(username='test', password='1234')
tag = Tag.objects.create(name='Food', owner=user)
category = Category.objects.create(name='General', owner=user)
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
expense = Expense.objects.create(
owner=user,
category=category,
amount=10,
date=date.today(),
account=account,
account=general_account,
)
expense.tags.add(tag)
assert expense.tags.count() == 1
assert tag in expense.tags.all()
def test_user_can_create_tag(auth_client, user):
response = auth_client.post(
def test_user_can_create_tag(client, django_user_model):
user = django_user_model.objects.create_user(
username='test', password='1234'
)
client.login(username='test', password='1234')
response = client.post(
reverse('tag_create'),
{'name': 'Food'}
)

View File

@ -1,69 +0,0 @@
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Expense, FuelEntry
pytestmark = pytest.mark.django_db
def _make_fuel_entry(user, account, category, odometer, liters, amount=Decimal("50")):
expense = Expense.objects.create(
owner=user, account=account, category=category, amount=amount, date=date.today()
)
return FuelEntry.objects.create(expense=expense, odometer=odometer, liters=liters)
def test_price_per_liter_divides_amount_by_liters(user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("25"), amount=Decimal("50"))
assert entry.price_per_liter() == Decimal("2")
def test_price_per_liter_zero_when_no_liters(user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("0"))
assert entry.price_per_liter() == 0
def test_km_since_previous_none_on_first_fillup(user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
assert entry.km_since_previous() is None
def test_km_since_previous_computes_delta_from_prior_entry(user, account, category):
_make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
second = _make_fuel_entry(user, account, category, odometer=1300, liters=Decimal("24"))
assert second.km_since_previous() == 300
def test_consumption_none_when_no_previous_entry(user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
assert entry.consumption() is None
def test_consumption_computes_liters_per_100km(user, account, category):
_make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
second = _make_fuel_entry(user, account, category, odometer=1300, liters=Decimal("24"))
assert second.consumption() == Decimal("8")
def test_fuel_create_invalid_post_returns_200_not_500(auth_client):
response = auth_client.post(reverse('fuel_create'), {})
assert response.status_code == 200
assert FuelEntry.objects.count() == 0
def test_fuel_delete_post_cascades_to_fuelentry(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
response = auth_client.post(reverse('fuel_delete', args=[expense.pk]))
assert not Expense.objects.filter(pk=expense.pk).exists()
assert not FuelEntry.objects.filter(pk=entry.pk).exists()

View File

@ -1,196 +0,0 @@
import pytest
from datetime import date
from decimal import Decimal
from expenses.models import Account, Category, Expense, Goal
from expenses.forms import GoalForm
pytestmark = pytest.mark.django_db
def _last_month_date():
today = date.today()
if today.month == 1:
return date(today.year - 1, 12, 15)
return date(today.year, today.month - 1, 15)
def test_payment_goal_sums_expenses_since_start_date(user, account, category):
start = date(2024, 6, 1)
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date(2024, 5, 1))
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date(2024, 6, 15))
goal = Goal.objects.create(
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
include_subcategories=False, start_date=start, target_amount=Decimal("100"),
)
assert goal.progress() == Decimal("20")
def test_payment_goal_include_subcategories_true_includes_child_expenses(user, account, category):
child = Category.objects.create(owner=user, name="Sub", parent=category)
Expense.objects.create(owner=user, account=account, category=child, amount=Decimal("15"), date=date.today())
goal = Goal.objects.create(
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
include_subcategories=True, target_amount=Decimal("100"),
)
assert goal.progress() == Decimal("15")
def test_payment_goal_include_subcategories_false_excludes_child_expenses(user, account, category):
child = Category.objects.create(owner=user, name="Sub", parent=category)
Expense.objects.create(owner=user, account=account, category=child, amount=Decimal("15"), date=date.today())
goal = Goal.objects.create(
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
include_subcategories=False, target_amount=Decimal("100"),
)
assert goal.progress() == Decimal("0")
def test_budget_goal_period_month_counts_only_current_month(user, account, category):
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("30"), date=date.today())
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("999"), date=_last_month_date())
goal = Goal.objects.create(
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
)
assert goal.progress() == Decimal("30")
def test_budget_goal_is_exceeded_true_over_target(user, account, category):
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("150"), date=date.today())
goal = Goal.objects.create(
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
)
assert goal.is_exceeded() is True
def test_budget_goal_is_exceeded_false_under_target(user, account, category):
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("50"), date=date.today())
goal = Goal.objects.create(
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
)
assert goal.is_exceeded() is False
def test_is_exceeded_always_false_for_non_budget_kind(user, account, category):
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("50"), date=date.today())
goal = Goal.objects.create(
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category, target_amount=Decimal("10"),
)
assert goal.progress() > goal.target_amount
assert goal.is_exceeded() is False
def test_saving_goal_progress_equals_account_balance(user):
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("500"))
goal = Goal.objects.create(
owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("1000"),
)
assert goal.progress() == acc.current_balance()
def test_bar_width_caps_at_100_above_target(user):
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("250"))
goal = Goal.objects.create(owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("100"))
assert goal.percentage() == Decimal("250")
assert goal.bar_width() == 100.0
def test_bar_width_uncapped_below_100(user):
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("40"))
goal = Goal.objects.create(owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("100"))
assert goal.bar_width() == 40.0
@pytest.mark.parametrize("amount,expected_state", [
(Decimal("50"), "ok"),
(Decimal("90"), "warning"),
(Decimal("150"), "danger"),
])
def test_progress_state_budget_thresholds(user, account, category, amount, expected_state):
Expense.objects.create(owner=user, account=account, category=category, amount=amount, date=date.today())
goal = Goal.objects.create(
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category, target_amount=Decimal("100"),
)
assert goal.progress_state() == expected_state
@pytest.mark.parametrize("amount,expected_state", [
(Decimal("50"), "ok"),
(Decimal("100"), "complete"),
(Decimal("150"), "complete"),
])
def test_progress_state_non_budget_thresholds(user, account, category, amount, expected_state):
Expense.objects.create(owner=user, account=account, category=category, amount=amount, date=date.today())
goal = Goal.objects.create(
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category, target_amount=Decimal("100"),
)
assert goal.progress_state() == expected_state
def test_goalform_budget_requires_period(user, category):
data = {"name": "Presupuesto", "kind": Goal.KIND_BUDGET, "target_amount": "100", "category": str(category.pk), "period": Goal.PERIOD_NONE}
form = GoalForm(data=data, user=user)
assert not form.is_valid()
assert "period" in form.errors
def test_goalform_saving_requires_account(user):
data = {"name": "Ahorro", "kind": Goal.KIND_SAVING, "target_amount": "500", "period": Goal.PERIOD_NONE}
form = GoalForm(data=data, user=user)
assert not form.is_valid()
assert "account" in form.errors
def test_goalform_payment_requires_category(user):
data = {"name": "Pago", "kind": Goal.KIND_PAYMENT, "target_amount": "50", "period": Goal.PERIOD_NONE}
form = GoalForm(data=data, user=user)
assert not form.is_valid()
assert "category" in form.errors
def test_goalform_budget_requires_category(user):
data = {"name": "Presupuesto", "kind": Goal.KIND_BUDGET, "target_amount": "50", "period": Goal.PERIOD_MONTH}
form = GoalForm(data=data, user=user)
assert not form.is_valid()
assert "category" in form.errors
def test_goalform_valid_payment_saves_goal(user, category):
data = {
"name": "Ahorro viaje", "kind": Goal.KIND_PAYMENT, "target_amount": "200",
"category": str(category.pk), "period": Goal.PERIOD_NONE,
}
form = GoalForm(data=data, user=user)
assert form.is_valid(), form.errors
goal = form.save(commit=False)
goal.owner = user
goal.save()
assert Goal.objects.filter(pk=goal.pk, name="Ahorro viaje", kind=Goal.KIND_PAYMENT, category=category).exists()

View File

@ -1,10 +1,18 @@
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Income, Account
pytestmark = pytest.mark.django_db
def test_income_increases_account_balance(user):
def test_income_increases_account_balance(client, django_user_model):
user = django_user_model.objects.create_user(
username='test',
password='1234'
)
client.login(username='test', password='1234')
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
Income.objects.create(

View File

@ -12,10 +12,10 @@ urlpatterns = [
path('tags/new/', views.tag_create, name='tag_create'),
path('tags/<int:pk>/edit/', views.tag_edit, name='tag_edit'),
path('tags/<int:pk>/delete/', views.tag_delete, name='tag_delete'),
path('finance-accounts/', views.account_list, name='account_list'),
path('finance-accounts/new/', views.account_create, name='account_create'),
path('finance-accounts/<int:pk>/edit/', views.account_edit, name='account_edit'),
path('finance-accounts/<int:pk>/delete/', views.account_delete, name='account_delete'),
path('accounts/', views.account_list, name='account_list'),
path('accounts/new/', views.account_create, name='account_create'),
path('accounts/<int:pk>/edit/', views.account_edit, name='account_edit'),
path('accounts/<int:pk>/delete/', views.account_delete, name='account_delete'),
path('incomes/', views.income_list, name='income_list'),
path('incomes/new/', views.income_create, name='income_create'),
path('incomes/<int:pk>/edit/', views.income_edit, name='income_edit'),
@ -23,10 +23,7 @@ urlpatterns = [
path('fuel/', views.fuel_list, name='fuel_list'),
path('fuel/create/', views.fuel_create, name='fuel_create'),
path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'),
path('fuel/<int:pk>/delete/', views.fuel_delete, name='fuel_delete'),
path('categories/', views.category_list, name='category_list'),
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
path('settings/', views.settings_index, name='settings_index'),
path('goals/', views.goal_list, name='goal_list'),
path('goals/new/', views.goal_create, name='goal_create'),

View File

@ -1,7 +1,7 @@
import logging
import calendar
from operator import truediv
from datetime import date, datetime
from django.contrib import messages
from django.template import context
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
from .forms import (
ExpenseForm,
@ -13,15 +13,32 @@ from .forms import (
GoalForm,
)
# from dateutli.relativedelta import relativedelta
from django.db.models import Sum
from django.contrib.auth import login
from django.core.paginator import Paginator
from django.db.models import Sum, ProtectedError
from django.utils.ipv6 import is_valid_ipv6_address
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
from django.contrib.auth.decorators import login_required
from django.utils.http import url_has_allowed_host_and_scheme
from django.shortcuts import get_object_or_404, render, redirect
logger = logging.getLogger(__name__)
MONTHS = {
1: "ENERO",
2: "FEBRERO",
3: "MARZO",
4: "ABRIL",
5: "MAYO",
6: "JUNIO",
7: "JULIO",
8: "AGOSTO",
9: "SEPTIEMBRE",
10: "OCTUBRE",
11: "NOVIEMBRE",
12: "DICIEMBRE",
}
def _get_int(value):
try:
@ -30,19 +47,6 @@ def _get_int(value):
return None
def _redirect_back(request, fallback):
next_url = request.POST.get("next") or request.GET.get("next")
if next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(fallback)
def sub_months(year, month, n):
month -= n
while month <= 0:
@ -123,11 +127,8 @@ def expense_list(request):
category = _get_int(request.GET.get("category"))
account_id = _get_int(request.GET.get("account"))
tag_ids = [
t_id
for t in request.GET.getlist("tag")
if (t_id:= _get_int(t)) is not None
]
tag_ids = request.GET.getlist("tag")
tag_ids = [int(t) for t in tag_ids]
if year:
expenses = expenses.filter(date__year=year)
@ -363,6 +364,7 @@ def dashboard(request):
)
day_totals = {row["day"]: float(row["total"]) for row in by_day_qs}
import calendar
num_days = calendar.monthrange(selected_year, selected_month)[1]
if selected_year == today.year and selected_month == today.month:
days_passed = today.day
@ -447,11 +449,7 @@ def dashboard(request):
try:
monthly_data = acc.monthly_balance(selected_year)
m_balance = [float(row["balance"]) for row in monthly_data]
except Exception:
logger.exception(
"Error calculando monthly_balance para la cuenta %s (año %s)",
acc.id, selected_year
)
except:
m_balance = [0] * 12
if selected_year == today.year:
@ -822,7 +820,7 @@ def fuel_edit(request, pk):
fuel.liters = form.cleaned_data["liters"]
fuel.save()
return _redirect_back(request, "expense_list")
return redirect("expense_list")
else:
fuel = expense.fuel_data
# Initialize manually
@ -837,39 +835,13 @@ def fuel_edit(request, pk):
user=request.user,
)
next_url = request.POST.get("next") or request.GET.get("next", "")
return render(
request,
"fuel/create.html",
{
"active_menu": "fuel" if next_url.startswith("/fuel") else "expenses",
"active_menu": "expenses",
"form": form,
"editing": True,
"next": next_url
},
)
@login_required
def fuel_delete(request, pk):
expense = get_object_or_404(Expense, pk=pk, owner=request.user)
fuel = get_object_or_404(FuelEntry, expense=expense)
if request.method == "POST":
expense.delete()
messages.success(request, "Repostaje eliminado.")
return _redirect_back(request, "fuel_list")
next_url = request.POST.get("next") or request.GET.get("next", "")
return render(
request,
"fuel/confirm_delete.html",
{
"active_menu":"fuel" if next_url.startswith("/fuel") else "expenses",
"fuel": fuel,
"next": next_url
},
)
@ -899,60 +871,6 @@ def category_list(request):
)
@login_required
def category_edit(request, pk):
category = get_object_or_404(Category, pk=pk, owner=request.user)
if request.method == "POST":
form = CategoryForm(request.POST, instance=category, user=request.user)
if form.is_valid():
form.save()
messages.success(request, "Categoría actualizada")
return redirect("category_list")
else:
form = CategoryForm(instance=category, user=request.user)
return render(
request,
"categories/form.html",
{
"active_menu": "settings",
"form":form,
"category": category,
}
)
@login_required
def category_delete(request, pk):
category = get_object_or_404(Category, pk=pk, owner=request.user)
if request.method == "POST":
try:
category.delete()
except ProtectedError:
messages.error(
request,
f"No se puede eliminar «{category.name}»; tiene gastos asociados. "
"Reasigna esos gastos a otra categoría antes de borrarla."
)
else:
messages.success(request, "Categoría eliminada.")
return redirect("category_list")
return render(
request,
"categories/confirm_delete.html",
{
"active_menu": "settings",
"category": category,
"children": category.children.all(),
"expense_count": category.expenses.count(),
"goal_count": category.goal_set.count(),
},
)
@login_required
def goal_list(request):
goals = Goal.objects.filter(owner=request.user)

View File

@ -158,8 +158,7 @@ DATETIME_INPUT_FORMATS = ["%d/%m/%Y %H:%M"]
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
if not DEBUG:
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@ -15,10 +15,12 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.contrib.auth import urls
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('django.contrib.auth.urls')),
path('accounts/', include(urls)),
path('', include('expenses.urls')),
path('accounts/', include('django.contrib.auth.urls')),
]