diff --git a/expenses_manager/Jenkinsfile b/expenses_manager/Jenkinsfile index 48fe957..e756ba9 100644 --- a/expenses_manager/Jenkinsfile +++ b/expenses_manager/Jenkinsfile @@ -2,6 +2,7 @@ pipeline{ agent any environment { + DEBUG = 'TRUE' DJANGO_SECRET_KEY = 'test-secret-key-for-ci' DJANGO_SETTINGS_MODULE = "expenses_manager.settings" PYTHONUNBUFFERED = "1" @@ -33,7 +34,6 @@ pipeline{ cd expenses_manager . venv/bin/activate - export SECRET_KEY=$DJANGO_SECRET_KEY venv/bin/pytest --cov ''' } diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 89bc673..6f012d6 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -92,20 +92,86 @@ class CategoryForm(forms.ModelForm): super().__init__(*args, **kwargs) if user: - self.fields["parent"].queryset = Category.objects.filter( - owner=user, - ) + queryset = Category.objects.filter(owner=user) + + if self.instance.pk: + queryset = queryset.exclude( + pk__in=self.instance.descendant_ids() + ) + + 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', '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() - ) \ No newline at end of file + 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 \ No newline at end of file diff --git a/expenses_manager/expenses/migrations/0010_goal_account_goal_include_subcategories_goal_kind_and_more.py b/expenses_manager/expenses/migrations/0010_goal_account_goal_include_subcategories_goal_kind_and_more.py new file mode 100644 index 0000000..29402d0 --- /dev/null +++ b/expenses_manager/expenses/migrations/0010_goal_account_goal_include_subcategories_goal_kind_and_more.py @@ -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'), + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index 33b7b57..95339b9 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -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( @@ -135,19 +151,33 @@ class Account(models.Model): def monthly_net(self, year=None): year = year or date.today().year - data = [] + incomes = ( + self.incomes.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") + expenses = ( + self.expenses.filter(date__year=year) + .annotate(month=ExtractMonth("date")) + .values("month") + .annotate(total=Sum("amount")) + ) - data.append({"month": month, "net": float(income - expense)}) + 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 data + 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 @@ -264,41 +294,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 diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 60255bc..5e47648 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -518,4 +518,17 @@ tbody tr:hover { background-color: #e6f4ea; 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; } \ No newline at end of file diff --git a/expenses_manager/expenses/templates/categories/confirm_delete.html b/expenses_manager/expenses/templates/categories/confirm_delete.html new file mode 100644 index 0000000..87b4140 --- /dev/null +++ b/expenses_manager/expenses/templates/categories/confirm_delete.html @@ -0,0 +1,38 @@ +{% extends "expenses/base.html" %} + +{% block title %} + Categorías +{% endblock %} + +{% block content %} +
¿Seguro que quieres eliminar la categoría {{ category.name }}?
+ + {% if expense_count %} ++ 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. +
+ {% else %} + {% if children %} ++ Atención: también se eliminarán sus subcategorías: + {% for child in children %}{{child.name}}{% if not forloop.last %}, {% endif %}{% endfor %}. +
+ {% endif %} + + {% if goal_count %} ++ Atención: se eliminarán también {{ goal_count }} objetivo{{ goal_count|pluralize }} asociado{{ goal_count|pluralize }} a esta categoría. +
+ {% endif %} + {% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/templates/categories/form.html b/expenses_manager/expenses/templates/categories/form.html new file mode 100644 index 0000000..e45d9c1 --- /dev/null +++ b/expenses_manager/expenses/templates/categories/form.html @@ -0,0 +1,16 @@ +{% extends "expenses/base.html" %} + +{% block title %} + Categorías +{% endblock %} + +{% block content %} ++ ¿Seguro que quieres eliminar el repostaje del + {{ fuel.expense.date}} + ({{ fuel.liters }}L por {{fuel.expense.amount}}€)? +
+ ++ Se eliminará también el gasto asociado en el listado de gastos. +
+ + +{% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/templates/fuel/create.html b/expenses_manager/expenses/templates/fuel/create.html index 1f6985e..411b638 100644 --- a/expenses_manager/expenses/templates/fuel/create.html +++ b/expenses_manager/expenses/templates/fuel/create.html @@ -19,6 +19,7 @@ - {% if editing %} - Volver + {% if next %} + Volver + {% elif editing %} + Volver {% else %} - Volver + Volver {% endif %} {% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/templates/fuel/list.html b/expenses_manager/expenses/templates/fuel/list.html index d6e39bd..040afee 100644 --- a/expenses_manager/expenses/templates/fuel/list.html +++ b/expenses_manager/expenses/templates/fuel/list.html @@ -21,6 +21,7 @@| Nombre | +Tipo | Progreso | |
|---|---|---|---|
| {{ goal.name }} | ++ {{ goal.get_kind_display }} + {% if goal.kind == "budget" %} + ({{ goal.get_period_display|lower }}) + {% endif %} + |
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
- {{ goal.percentage|floatformat:1}}%
+ {{ goal.percentage|floatformat:1 }}%
+ {% if goal.is_exceeded %}
+ Excedido
+ {% endif %}
|
diff --git a/expenses_manager/expenses/tests/conftest.py b/expenses_manager/expenses/tests/conftest.py
new file mode 100644
index 0000000..c2a1eff
--- /dev/null
+++ b/expenses_manager/expenses/tests/conftest.py
@@ -0,0 +1,31 @@
+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")
diff --git a/expenses_manager/expenses/tests/test_accounts.py b/expenses_manager/expenses/tests/test_accounts.py
new file mode 100644
index 0000000..d025ad6
--- /dev/null
+++ b/expenses_manager/expenses/tests/test_accounts.py
@@ -0,0 +1,86 @@
+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")
diff --git a/expenses_manager/expenses/tests/test_categories.py b/expenses_manager/expenses/tests/test_categories.py
new file mode 100644
index 0000000..a8622b0
--- /dev/null
+++ b/expenses_manager/expenses/tests/test_categories.py
@@ -0,0 +1,101 @@
+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()
diff --git a/expenses_manager/expenses/tests/test_dashboard.py b/expenses_manager/expenses/tests/test_dashboard.py
index 2d19d89..3e0d2b7 100644
--- a/expenses_manager/expenses/tests/test_dashboard.py
+++ b/expenses_manager/expenses/tests/test_dashboard.py
@@ -2,7 +2,7 @@ import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
-from expenses.models import Account, Expense, Category
+from expenses.models import Expense, Category
pytestmark = pytest.mark.django_db
@@ -10,96 +10,73 @@ def test_dashboard_requires_login(client):
url = reverse('dashboard')
response = client.get(url)
assert response.status_code == 302
-
-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')
-
+
+def test_dashboard_logged_user_can_access(auth_client):
url = reverse('dashboard')
- response = client.get(url)
-
+ response = auth_client.get(url)
+
assert response.status_code == 200
-
-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')
-
+
+def test_dashboard_groups_by_category(auth_client, user, account):
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=general_account,
+ account=account,
)
Expense.objects.create(
owner=user,
category=food,
amount=Decimal('5'),
date=date(2024, 1, 2),
- account=general_account,
+ account=account,
)
Expense.objects.create(
owner=user,
category=rent,
amount=Decimal('20'),
date=date(2024, 1, 3),
- account=general_account,
+ account=account,
)
-
- response = client.get(
+
+ response = auth_client.get(
reverse('dashboard'),
{'year': 2024}
)
-
+
data = list(response.context['by_category'])
-
+
assert {'category__name': 'Food', 'total': Decimal('15')} in data
assert {'category__name': 'Rent', 'total': Decimal('20')} in data
-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)
-
+def test_dashboard_filters_by_year(auth_client, user, account, category):
Expense.objects.create(
owner=user,
- category=cat,
+ category=category,
amount=10,
date=date(2023, 5, 1),
- account=general_account,
+ account=account,
)
-
+
Expense.objects.create(
owner=user,
- category=cat,
+ category=category,
amount=20,
date=date(2024, 5, 1),
- account=general_account,
+ account=account,
)
-
- response = client.get(
+
+ response = auth_client.get(
reverse('dashboard'),
{'year': 2024},
)
-
- by_month = list(response.context['by_month'])
-
- totals = [row['total'] for row in by_month]
- assert Decimal('20') in totals
- assert Decimal('10') not in totals
\ No newline at end of file
+
+ chart_data = response.context['chart_data']
+
+ 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
diff --git a/expenses_manager/expenses/tests/test_deletes.py b/expenses_manager/expenses/tests/test_deletes.py
new file mode 100644
index 0000000..b4d8cf6
--- /dev/null
+++ b/expenses_manager/expenses/tests/test_deletes.py
@@ -0,0 +1,61 @@
+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()
diff --git a/expenses_manager/expenses/tests/test_expense_list.py b/expenses_manager/expenses/tests/test_expense_list.py
new file mode 100644
index 0000000..4023a42
--- /dev/null
+++ b/expenses_manager/expenses/tests/test_expense_list.py
@@ -0,0 +1,42 @@
+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}
diff --git a/expenses_manager/expenses/tests/test_expenses.py b/expenses_manager/expenses/tests/test_expenses.py
index 9109c14..5f180c1 100644
--- a/expenses_manager/expenses/tests/test_expenses.py
+++ b/expenses_manager/expenses/tests/test_expenses.py
@@ -1,41 +1,29 @@
import pytest
from datetime import date
-from decimal import Decimal
from django.urls import reverse
-from expenses.models import Expense, Category, Tag, Account
+from expenses.models import Expense, Tag
pytestmark = pytest.mark.django_db
-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')
-
+def test_expense_can_have_tags(user, category, account):
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=general_account,
+ account=account,
)
expense.tags.add(tag)
-
+
assert expense.tags.count() == 1
assert tag in expense.tags.all()
-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(
+def test_user_can_create_tag(auth_client, user):
+ response = auth_client.post(
reverse('tag_create'),
{'name': 'Food'}
)
-
+
assert Tag.objects.filter(owner=user, name='Food').exists()
diff --git a/expenses_manager/expenses/tests/test_fuel.py b/expenses_manager/expenses/tests/test_fuel.py
new file mode 100644
index 0000000..72be9a4
--- /dev/null
+++ b/expenses_manager/expenses/tests/test_fuel.py
@@ -0,0 +1,69 @@
+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()
diff --git a/expenses_manager/expenses/tests/test_goals.py b/expenses_manager/expenses/tests/test_goals.py
new file mode 100644
index 0000000..2411c8e
--- /dev/null
+++ b/expenses_manager/expenses/tests/test_goals.py
@@ -0,0 +1,196 @@
+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()
diff --git a/expenses_manager/expenses/tests/test_income.py b/expenses_manager/expenses/tests/test_income.py
index c99f85e..e0e351d 100644
--- a/expenses_manager/expenses/tests/test_income.py
+++ b/expenses_manager/expenses/tests/test_income.py
@@ -1,20 +1,12 @@
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(client, django_user_model):
- user = django_user_model.objects.create_user(
- username='test',
- password='1234'
- )
- client.login(username='test', password='1234')
-
+def test_income_increases_account_balance(user):
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
-
+
Income.objects.create(
owner=user,
account=general_account,
@@ -22,5 +14,5 @@ def test_income_increases_account_balance(client, django_user_model):
name='Nomina enero',
date=date.today(),
)
-
- assert general_account.current_balance() == 1500
\ No newline at end of file
+
+ assert general_account.current_balance() == 1500
diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py
index cce5358..f4e6907 100644
--- a/expenses_manager/expenses/urls.py
+++ b/expenses_manager/expenses/urls.py
@@ -12,10 +12,10 @@ urlpatterns = [
path('tags/new/', views.tag_create, name='tag_create'),
path('tags/