From 8d1f477940d7089816d8cc435f7bc23d839d52e5 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 22 Jul 2026 15:22:36 +0200 Subject: [PATCH 01/12] Fix bugs --- expenses_manager/expenses/views.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index bbb139e..dcd384f 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -1,3 +1,4 @@ +import logging from operator import truediv from datetime import date, datetime from django.contrib import messages @@ -39,6 +40,7 @@ MONTHS = { 12: "DICIEMBRE", } +logger = logging.getLogger(__name__) def _get_int(value): try: @@ -127,8 +129,11 @@ def expense_list(request): category = _get_int(request.GET.get("category")) account_id = _get_int(request.GET.get("account")) - tag_ids = request.GET.getlist("tag") - tag_ids = [int(t) for t in tag_ids] + tag_ids = [ + t_id + for t in request.GET.getlist("tag") + if (t_id:= _get_int(t)) is not None + ] if year: expenses = expenses.filter(date__year=year) @@ -449,7 +454,11 @@ def dashboard(request): try: monthly_data = acc.monthly_balance(selected_year) m_balance = [float(row["balance"]) for row in monthly_data] - except: + except Exception: + logger.exception( + "Error calculando monthly_balance para la cuenta %s (año %s)", + acc.id, selected_year + ) m_balance = [0] * 12 if selected_year == today.year: From e1b4a6eef781aeee5f7480b4a93f25b287c2fcdb Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 23 Jul 2026 09:16:33 +0200 Subject: [PATCH 02/12] Clean code --- expenses_manager/expenses/views.py | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index dcd384f..f316466 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -1,8 +1,7 @@ import logging -from operator import truediv +import calendar 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, @@ -14,32 +13,13 @@ 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.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.shortcuts import get_object_or_404, render, redirect -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", -} - logger = logging.getLogger(__name__) def _get_int(value): @@ -337,7 +317,7 @@ def dashboard(request): category_count = expenses_filtered.values("category").distinct().count() # Total by category - by_category_qs = by_category = ( + by_category_qs = ( expenses_filtered.values("category__name") .annotate(total=Sum("amount")) .order_by("-total") @@ -368,8 +348,7 @@ def dashboard(request): .annotate(total=Sum("amount")) ) 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 From c29ed0d8aeeb7b6ba03e10b548901d74af88517a Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 23 Jul 2026 09:19:31 +0200 Subject: [PATCH 03/12] Fixed bug --- expenses_manager/expenses/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index f316466..ecd3f04 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -317,7 +317,7 @@ def dashboard(request): category_count = expenses_filtered.values("category").distinct().count() # Total by category - by_category_qs = ( + by_category_qs = by_category = ( expenses_filtered.values("category__name") .annotate(total=Sum("amount")) .order_by("-total") From 0dd8c5bb497c1ae230b176b193361d5cc9f0ea5c Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 23 Jul 2026 09:28:04 +0200 Subject: [PATCH 04/12] Removed duplicated functionality --- expenses_manager/expenses_manager/urls.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/expenses_manager/expenses_manager/urls.py b/expenses_manager/expenses_manager/urls.py index 5e847d7..e483cdb 100644 --- a/expenses_manager/expenses_manager/urls.py +++ b/expenses_manager/expenses_manager/urls.py @@ -15,12 +15,10 @@ 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(urls)), - path('', include('expenses.urls')), path('accounts/', include('django.contrib.auth.urls')), + path('', include('expenses.urls')), ] From 2267146d0d4d5dc0d976a136cc83f247996eb3df Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 23 Jul 2026 09:34:55 +0200 Subject: [PATCH 05/12] Renamed "accounts" to "finance-accounts" --- expenses_manager/expenses/urls.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py index cce5358..47e1f56 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//edit/', views.tag_edit, name='tag_edit'), path('tags//delete/', views.tag_delete, name='tag_delete'), - path('accounts/', views.account_list, name='account_list'), - path('accounts/new/', views.account_create, name='account_create'), - path('accounts//edit/', views.account_edit, name='account_edit'), - path('accounts//delete/', views.account_delete, name='account_delete'), + path('finance-accounts/', views.account_list, name='account_list'), + path('finance-accounts/new/', views.account_create, name='account_create'), + path('finance-accounts//edit/', views.account_edit, name='account_edit'), + path('finance-accounts//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//edit/', views.income_edit, name='income_edit'), From 03016209b0e5d0e9c682fa4e6a2925665c47d1b1 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 23 Jul 2026 11:42:11 +0200 Subject: [PATCH 06/12] Added "edit" and "delete" options for categories --- .../templates/categories/confirm_delete.html | 38 +++++++++++++ .../expenses/templates/categories/form.html | 16 ++++++ .../expenses/templates/categories/list.html | 4 ++ expenses_manager/expenses/urls.py | 2 + expenses_manager/expenses/views.py | 56 ++++++++++++++++++- 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 expenses_manager/expenses/templates/categories/confirm_delete.html create mode 100644 expenses_manager/expenses/templates/categories/form.html 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 %} +

Eliminar categoría

+ +

¿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 %} + +
+ {% csrf_token %} + {{ form.as_p }} + + Cancelar +
+{% 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 %} +

Editar categoría

+ +
+ {% csrf_token %} + {{ form.as_p }} + + Cancelar +
+{% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/templates/categories/list.html b/expenses_manager/expenses/templates/categories/list.html index f89d645..24826ab 100644 --- a/expenses_manager/expenses/templates/categories/list.html +++ b/expenses_manager/expenses/templates/categories/list.html @@ -30,6 +30,10 @@ {{ category.name }} {% if category.parent %}{{ category.parent.name }}{% endif %} + + Editar + Eliminar + {% endfor %} diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py index 47e1f56..ad395e9 100644 --- a/expenses_manager/expenses/urls.py +++ b/expenses_manager/expenses/urls.py @@ -24,6 +24,8 @@ urlpatterns = [ path('fuel/create/', views.fuel_create, name='fuel_create'), path('fuel//edit/', views.fuel_edit, name='fuel_edit'), path('categories/', views.category_list, name='category_list'), + path('categories//edit/', views.category_edit, name='category_edit'), + path('categories//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'), diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index ecd3f04..69817c0 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -13,8 +13,8 @@ from .forms import ( GoalForm, ) -from django.db.models import Sum from django.core.paginator import Paginator +from django.db.models import Sum, ProtectedError from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay from django.contrib.auth.decorators import login_required @@ -859,6 +859,60 @@ 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) From a2eec6749166ca9530350550228e4a1ea8c8813c Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 23 Jul 2026 13:43:53 +0200 Subject: [PATCH 07/12] Now, the user can't select as category parent itself or childrens --- expenses_manager/expenses/forms.py | 37 ++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 89bc673..01cdaff 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -92,9 +92,42 @@ 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._self_and_descendant_ids(self.instance) + ) + + self.fields["parent"].queryset = queryset + + @staticmethod + def _self_and_descendant_ids(category): + ids = [category.pk] + pending = [category.pk] + + while pending: + children = list( + Category.objects.filter(parent_id__in=pending) + .exclude(pk__in=ids) + .values_list("pk", flat=True) ) + ids.extend(children) + pending = children + + return ids + + def clean_parent(self): + parent = self.cleaned_data.get("parent") + + if parent and self.instance.pk: + if parent.pk in self._self_and_descendant_ids(self.instance): + 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): From 61047df4bf8a8fd5903520ce354b1c4866a645fe Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 23 Jul 2026 15:40:04 +0200 Subject: [PATCH 08/12] Added the Edit and Delete options to fuel --- .../templates/expenses/expense_list.html | 9 ++-- .../templates/fuel/confirm_delete.html | 26 +++++++++++ .../expenses/templates/fuel/create.html | 9 ++-- .../expenses/templates/fuel/list.html | 5 +++ expenses_manager/expenses/urls.py | 1 + expenses_manager/expenses/views.py | 44 ++++++++++++++++++- 6 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 expenses_manager/expenses/templates/fuel/confirm_delete.html diff --git a/expenses_manager/expenses/templates/expenses/expense_list.html b/expenses_manager/expenses/templates/expenses/expense_list.html index 3a88c9e..4fc13ab 100644 --- a/expenses_manager/expenses/templates/expenses/expense_list.html +++ b/expenses_manager/expenses/templates/expenses/expense_list.html @@ -128,12 +128,13 @@ {% endfor %} - {% if expense.category.slug == 'gasolina' %} - Editar + {% if expense.fuel_data %} + Editar + Eliminar {% else %} Editar - {% endif %} - Eliminar + Eliminar + {% endif %} {% empty %} diff --git a/expenses_manager/expenses/templates/fuel/confirm_delete.html b/expenses_manager/expenses/templates/fuel/confirm_delete.html new file mode 100644 index 0000000..1b7ee74 --- /dev/null +++ b/expenses_manager/expenses/templates/fuel/confirm_delete.html @@ -0,0 +1,26 @@ +{% extends "expenses/base.html" %} + +{% block title %} + Repostajes +{% endblock %} + +{% block content %} +

Eliminar repostaje.

+ +

+ ¿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. +

+ +
+ {% csrf_token %} + {% if next %}{% endif %} + + Cancelar +
+{% 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 @@
{% csrf_token %} + {% if next %}{% endif %} {{ form.as_p }}
- {% 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 @@ Gasto €/L Km desde anterior + @@ -32,6 +33,10 @@ {{ fuel.expense.amount }} {{ fuel.price_per_liter|floatformat:2 }} {{ fuel.km_since_previous }} + + Editar + Eliminar + {% endfor %} diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py index ad395e9..f4e6907 100644 --- a/expenses_manager/expenses/urls.py +++ b/expenses_manager/expenses/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('fuel/', views.fuel_list, name='fuel_list'), path('fuel/create/', views.fuel_create, name='fuel_create'), path('fuel//edit/', views.fuel_edit, name='fuel_edit'), + path('fuel//delete/', views.fuel_delete, name='fuel_delete'), path('categories/', views.category_list, name='category_list'), path('categories//edit/', views.category_edit, name='category_edit'), path('categories//delete/', views.category_delete, name='category_delete'), diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 69817c0..3b5cd08 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -18,6 +18,7 @@ from django.db.models import Sum, ProtectedError 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__) @@ -29,6 +30,19 @@ 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: @@ -808,7 +822,7 @@ def fuel_edit(request, pk): fuel.liters = form.cleaned_data["liters"] fuel.save() - return redirect("expense_list") + return _redirect_back(request, "expense_list") else: fuel = expense.fuel_data # Initialize manually @@ -823,13 +837,39 @@ 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": "expenses", + "active_menu": "fuel" if next_url.startswith("/fuel") else "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 }, ) From c7ba7a970f0685e0d890b3dad223a7dc3435698b Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 23 Jul 2026 18:02:05 +0200 Subject: [PATCH 09/12] Modified the goal functionality --- expenses_manager/expenses/forms.py | 83 +++++++---- ...nclude_subcategories_goal_kind_and_more.py | 44 ++++++ expenses_manager/expenses/models.py | 136 +++++++++++++++--- .../expenses/static/expenses/css/base.css | 13 ++ .../expenses/templates/goals/list.html | 20 ++- 5 files changed, 243 insertions(+), 53 deletions(-) create mode 100644 expenses_manager/expenses/migrations/0010_goal_account_goal_include_subcategories_goal_kind_and_more.py diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 01cdaff..6f012d6 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -93,35 +93,19 @@ class CategoryForm(forms.ModelForm): if user: queryset = Category.objects.filter(owner=user) - + if self.instance.pk: queryset = queryset.exclude( - pk__in=self._self_and_descendant_ids(self.instance) + pk__in=self.instance.descendant_ids() ) - + self.fields["parent"].queryset = queryset - - @staticmethod - def _self_and_descendant_ids(category): - ids = [category.pk] - pending = [category.pk] - - while pending: - children = list( - Category.objects.filter(parent_id__in=pending) - .exclude(pk__in=ids) - .values_list("pk", flat=True) - ) - ids.extend(children) - pending = children - - return ids - + def clean_parent(self): parent = self.cleaned_data.get("parent") if parent and self.instance.pk: - if parent.pk in self._self_and_descendant_ids(self.instance): + if parent.pk in self.instance.descendant_ids(): raise forms.ValidationError( "Una categoría no puede ser su propio padre ni depender " "de una de sus subcategorías." @@ -133,12 +117,61 @@ class CategoryForm(forms.ModelForm): class GoalForm(forms.ModelForm): class Meta: model = Goal - fields = ['name', 'target_amount', 'category', "show_on_home"] + fields = [ + 'name', + 'kind', + 'target_amount', + 'category', + "include_subcategories", + "account", + "start_date", + "period", + "show_on_home" + ] + widgets = { + "start_date": forms.DateInput( + format="%Y-%m-%d", attrs={"type": "date"} + ), + } def __init__(self, *args, **kwargs): user = kwargs.pop('user') super().__init__(*args, **kwargs) - self.fields['category'].queryset = ( - user.categories.all() - ) \ 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..3f8c995 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( @@ -264,41 +280,119 @@ class FuelEntry(models.Model): class Goal(models.Model): + KIND_PAYMENT = "payment" + KIND_BUDGET = "budget" + KIND_SAVING = "saving" + + KIND_CHOICES = [ + (KIND_PAYMENT, "Pago / deuda"), + (KIND_BUDGET, "Presupuesto"), + (KIND_SAVING, "Ahorro"), + ] + + PERIOD_NONE = "none" + PERIOD_MONTH = "month" + PERIOD_YEAR = "year" + + PERIOD_CHOICES = [ + (PERIOD_NONE, "Sin reinicio"), + (PERIOD_MONTH, "Mensual"), + (PERIOD_YEAR, "Anual"), + ] + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) name = models.CharField(max_length=100) target_amount = models.DecimalField(max_digits=12, decimal_places=2) - category = models.ForeignKey("Category", on_delete=models.CASCADE) + + kind = models.CharField( + max_length=10, choices=KIND_CHOICES, default=KIND_PAYMENT + ) + + # Para pago y presupuesto + category = models.ForeignKey( + "Category", on_delete=models.CASCADE, null=True, blank=True + ) + include_subcategories = models.BooleanField(default=True) + + # Para ahorro (de momento se mide con el saldo de la cuenta) + account = models.ForeignKey( + "Account", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="goals", + ) + + # Desde cuándo cuenta (pago) / cada cuánto se reinicia (presupuesto) + start_date = models.DateField(null=True, blank=True) + period = models.CharField( + max_length=6, choices=PERIOD_CHOICES, default=PERIOD_NONE + ) + created_at = models.DateTimeField(auto_now_add=True) show_on_home = models.BooleanField(default=False) + def _period_start(self): + """Fecha desde la que se cuenta el progreso, o None si es todo.""" + if self.kind == self.KIND_BUDGET: + today = date.today() + if self.period == self.PERIOD_MONTH: + return date(today.year, today.month, 1) + if self.period == self.PERIOD_YEAR: + return date(today.year, 1, 1) + + return self.start_date + def progress(self): - """ - Calculate the accumulated spending for the goal category. - This method returns the sum of all expenses of the owner in the goal's category. + if self.kind == self.KIND_SAVING: + # Provisional: saldo de la cuenta asociada. Cuando exista el + # módulo de inversiones, esta rama es lo único que hay que tocar. + return self.account.current_balance() if self.account else Decimal("0") - Returns: - Decimal: Total amount spent in the goal category, or Decimal('0') when there is no spending. + if not self.category: + return Decimal("0") - """ - total = Expense.objects.filter( - owner=self.owner, - category=self.category, - ).aggregate(total=Sum("amount"))["total"] + if self.include_subcategories: + category_ids = self.category.descendant_ids() + else: + category_ids = [self.category_id] - return total or 0 + expenses = Expense.objects.filter( + owner=self.owner, category_id__in=category_ids + ) + + start = self._period_start() + if start: + expenses = expenses.filter(date__gte=start) + + return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0") def percentage(self): - """ - Calculate the completion percentage of the goal. - This method returns how much of the target amount has been reached as a percentage. - - Returns: - Decimal: Percentage of the goal that has been reached, or Decimal('0') when the target amount is zero. - - """ - if self.target_amount == 0: + if not self.target_amount: return 0 return (self.progress() / self.target_amount) * 100 + def bar_width(self): + return min(float(self.percentage()), 100) + + def progress_state(self): + pct = self.percentage() + + if self.kind == self.KIND_BUDGET: + if pct > 100: + return "danger" + if pct >= 80: + return "warning" + return "ok" + + return "complete" if pct >= 100 else "ok" + + def is_exceeded(self): + """True si es un presupuesto que ya se ha pasado del límite.""" + return ( + self.kind == self.KIND_BUDGET + and self.progress() > self.target_amount + ) + def __str__(self): return self.name 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/goals/list.html b/expenses_manager/expenses/templates/goals/list.html index c544323..861d6a4 100644 --- a/expenses_manager/expenses/templates/goals/list.html +++ b/expenses_manager/expenses/templates/goals/list.html @@ -14,6 +14,7 @@ + @@ -21,22 +22,27 @@ {% for goal in goals %} + From 06e61ed1bfa605df39544c54ec2b7c859d35b95c Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Fri, 24 Jul 2026 08:06:22 +0200 Subject: [PATCH 10/12] Minor changes --- expenses_manager/expenses/models.py | 34 +++++++++++++------ .../expenses/templates/expenses/home.html | 11 +++--- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index 3f8c995..95339b9 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -151,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 diff --git a/expenses_manager/expenses/templates/expenses/home.html b/expenses_manager/expenses/templates/expenses/home.html index 579718b..90a45da 100644 --- a/expenses_manager/expenses/templates/expenses/home.html +++ b/expenses_manager/expenses/templates/expenses/home.html @@ -68,14 +68,13 @@ {% for goal in goals %}
{{ goal.name }} + {% if goal.is_exceeded %} + Excedido + {% endif %}
-
+
From ed17d27457fc5355c7f1563b2dcf2a4ae010e64f Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Fri, 24 Jul 2026 09:32:11 +0200 Subject: [PATCH 11/12] Updated the tests for the project --- expenses_manager/expenses/tests/conftest.py | 31 +++ .../expenses/tests/test_accounts.py | 86 ++++++++ .../expenses/tests/test_categories.py | 101 +++++++++ .../expenses/tests/test_dashboard.py | 81 +++----- .../expenses/tests/test_deletes.py | 61 ++++++ .../expenses/tests/test_expense_list.py | 42 ++++ .../expenses/tests/test_expenses.py | 28 +-- expenses_manager/expenses/tests/test_fuel.py | 69 ++++++ expenses_manager/expenses/tests/test_goals.py | 196 ++++++++++++++++++ .../expenses/tests/test_income.py | 16 +- 10 files changed, 627 insertions(+), 84 deletions(-) create mode 100644 expenses_manager/expenses/tests/conftest.py create mode 100644 expenses_manager/expenses/tests/test_accounts.py create mode 100644 expenses_manager/expenses/tests/test_categories.py create mode 100644 expenses_manager/expenses/tests/test_deletes.py create mode 100644 expenses_manager/expenses/tests/test_expense_list.py create mode 100644 expenses_manager/expenses/tests/test_fuel.py create mode 100644 expenses_manager/expenses/tests/test_goals.py 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 From e621a6f9f0ebbf51a8467768ad1dfa403869bcdb Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Fri, 24 Jul 2026 10:04:28 +0200 Subject: [PATCH 12/12] Fixed test and jenkins file --- expenses_manager/Jenkinsfile | 2 +- expenses_manager/expenses_manager/settings.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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_manager/settings.py b/expenses_manager/expenses_manager/settings.py index d1b1d8a..b85bcb2 100644 --- a/expenses_manager/expenses_manager/settings.py +++ b/expenses_manager/expenses_manager/settings.py @@ -158,7 +158,8 @@ DATETIME_INPUT_FORMATS = ["%d/%m/%Y %H:%M"] STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / 'staticfiles' -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" +if not DEBUG: + STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
NombreTipo 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 %}