Compare commits

...

2 Commits

6 changed files with 150 additions and 3 deletions

View File

@ -92,9 +92,42 @@ class CategoryForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if user: if user:
self.fields["parent"].queryset = Category.objects.filter( queryset = Category.objects.filter(owner=user)
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): class GoalForm(forms.ModelForm):

View File

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

View File

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

View File

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

View File

@ -24,6 +24,8 @@ urlpatterns = [
path('fuel/create/', views.fuel_create, name='fuel_create'), path('fuel/create/', views.fuel_create, name='fuel_create'),
path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'), path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'),
path('categories/', views.category_list, name='category_list'), path('categories/', views.category_list, name='category_list'),
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
path('settings/', views.settings_index, name='settings_index'), path('settings/', views.settings_index, name='settings_index'),
path('goals/', views.goal_list, name='goal_list'), path('goals/', views.goal_list, name='goal_list'),
path('goals/new/', views.goal_create, name='goal_create'), path('goals/new/', views.goal_create, name='goal_create'),

View File

@ -13,8 +13,8 @@ from .forms import (
GoalForm, GoalForm,
) )
from django.db.models import Sum
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Sum, ProtectedError
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
from django.contrib.auth.decorators import login_required 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 @login_required
def goal_list(request): def goal_list(request):
goals = Goal.objects.filter(owner=request.user) goals = Goal.objects.filter(owner=request.user)