Compare commits
No commits in common. "9e43af1bb3389acd3c7323905ce0822e43f42b5c" and "e99af6f03fb3d65fd5372fa99ffbbfcdae47d88c" have entirely different histories.
9e43af1bb3
...
e99af6f03f
@ -186,7 +186,7 @@ class FuelEntryForm(forms.ModelForm):
|
|||||||
class CategoryForm(forms.ModelForm):
|
class CategoryForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
fields = ["name", "parent", "expense_type"]
|
fields = ["name", "parent"]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
user = kwargs.pop("user")
|
user = kwargs.pop("user")
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.10 on 2026-10-14 16:10
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('expenses', '0017_goal_linked_expenses_goal_linked_incomes_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='category',
|
|
||||||
name='expense_type',
|
|
||||||
field=models.CharField(blank=True, choices=[('fixed_essential', 'Fijo esencial'), ('fixed_discretionary', 'Fijo prescindible'), ('variable_essential', 'Variable esencial'), ('variable_discretionary', 'Variable prescindible')], help_text='Si lo dejas vacío, hereda el de la categoría padre.', max_length=30, verbose_name='Tipo de gasto'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -10,22 +10,6 @@ from django.db.models.functions import ExtractMonth, Lower
|
|||||||
|
|
||||||
|
|
||||||
class Category(models.Model):
|
class Category(models.Model):
|
||||||
EXPENSE_TYPE_FIXED_ESSENTIAL = "fixed_essential"
|
|
||||||
EXPENSE_TYPE_FIXED_DISCRETIONARY = "fixed_discretionary"
|
|
||||||
EXPENSE_TYPE_VARIABLE_ESSENTIAL = "variable_essential"
|
|
||||||
EXPENSE_TYPE_VARIABLE_DISCRETIONARY = "variable_discretionary"
|
|
||||||
|
|
||||||
EXPENSE_TYPE_CHOICES = [
|
|
||||||
(EXPENSE_TYPE_FIXED_ESSENTIAL, "Fijo esencial"),
|
|
||||||
(EXPENSE_TYPE_FIXED_DISCRETIONARY, "Fijo prescindible"),
|
|
||||||
(EXPENSE_TYPE_VARIABLE_ESSENTIAL, "Variable esencial"),
|
|
||||||
(EXPENSE_TYPE_VARIABLE_DISCRETIONARY, "Variable prescindible"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Saltos máximos al heredar el tipo de gasto. Los ciclos los impide
|
|
||||||
# CategoryForm, pero uno colado por shell o migración no debe colgar nada.
|
|
||||||
EXPENSE_TYPE_MAX_DEPTH = 10
|
|
||||||
|
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
slug = models.SlugField(blank=True)
|
slug = models.SlugField(blank=True)
|
||||||
|
|
||||||
@ -48,14 +32,6 @@ class Category(models.Model):
|
|||||||
help_text="Categoría creada por la app (traspasos, comisiones). No borrable.",
|
help_text="Categoría creada por la app (traspasos, comisiones). No borrable.",
|
||||||
)
|
)
|
||||||
|
|
||||||
expense_type = models.CharField(
|
|
||||||
max_length=30,
|
|
||||||
choices=EXPENSE_TYPE_CHOICES,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Tipo de gasto",
|
|
||||||
help_text="Si lo dejas vacío, hereda el de la categoría padre.",
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("name", "parent", "owner", "slug")
|
unique_together = ("name", "parent", "owner", "slug")
|
||||||
verbose_name_plural = "categories"
|
verbose_name_plural = "categories"
|
||||||
@ -87,22 +63,6 @@ class Category(models.Model):
|
|||||||
|
|
||||||
return list(seen) if include_self else [i for i in seen if i != self.pk]
|
return list(seen) if include_self else [i for i in seen if i != self.pk]
|
||||||
|
|
||||||
@property
|
|
||||||
def effective_expense_type(self):
|
|
||||||
"""Tipo de gasto propio o, si está vacío, el del ancestro más cercano
|
|
||||||
que lo tenga. Cadena vacía si nadie en la cadena lo tiene.
|
|
||||||
|
|
||||||
Cada salto lee self.parent: no lo uses por fila en un listado (N+1).
|
|
||||||
El listado de categorías lo resuelve en _category_tree."""
|
|
||||||
category = self
|
|
||||||
for _ in range(self.EXPENSE_TYPE_MAX_DEPTH):
|
|
||||||
if category is None:
|
|
||||||
return ""
|
|
||||||
if category.expense_type:
|
|
||||||
return category.expense_type
|
|
||||||
category = category.parent
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
class Account(models.Model):
|
class Account(models.Model):
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
|
|||||||
@ -23,7 +23,6 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Categoría</th>
|
<th>Categoría</th>
|
||||||
<th>Tipo de gasto</th>
|
|
||||||
<th>Gastos</th>
|
<th>Gastos</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -37,13 +36,6 @@
|
|||||||
<span class="badge badge-neutral">Del sistema</span>
|
<span class="badge badge-neutral">Del sistema</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
{% if row.expense_type_inherited %}
|
|
||||||
<span class="muted">{{ row.expense_type_label }}{% if row.expense_type_label %} (heredado){% endif %}</span>
|
|
||||||
{% else %}
|
|
||||||
{{ row.expense_type_label }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ row.category.expense_count }}</td>
|
<td>{{ row.category.expense_count }}</td>
|
||||||
<td class="table-actions">
|
<td class="table-actions">
|
||||||
<a href="{% url 'category_edit' row.category.id %}">Editar</a>
|
<a href="{% url 'category_edit' row.category.id %}">Editar</a>
|
||||||
@ -54,7 +46,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="empty-state">
|
<td colspan="3" class="empty-state">
|
||||||
<p>No hay categorías</p>
|
<p>No hay categorías</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import pytest
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.contrib.messages import get_messages
|
from django.contrib.messages import get_messages
|
||||||
from django.db import connection
|
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from expenses.models import Category, Expense
|
from expenses.models import Category, Expense
|
||||||
@ -161,110 +159,3 @@ def test_category_list_counts_only_direct_expenses(auth_client, user, account):
|
|||||||
for row in response.context["category_rows"]
|
for row in response.context["category_rows"]
|
||||||
}
|
}
|
||||||
assert counts == {"Padre": 1, "Hija": 2}
|
assert counts == {"Padre": 1, "Hija": 2}
|
||||||
|
|
||||||
|
|
||||||
FIXED = Category.EXPENSE_TYPE_FIXED_ESSENTIAL
|
|
||||||
VARIABLE = Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY
|
|
||||||
|
|
||||||
|
|
||||||
def test_effective_expense_type_returns_own_value(user):
|
|
||||||
cat = Category.objects.create(name="Alquiler", owner=user, expense_type=FIXED)
|
|
||||||
|
|
||||||
assert cat.effective_expense_type == FIXED
|
|
||||||
|
|
||||||
|
|
||||||
def test_effective_expense_type_inherits_from_grandparent(user):
|
|
||||||
grandparent = Category.objects.create(name="Casa", owner=user, expense_type=FIXED)
|
|
||||||
parent = Category.objects.create(name="Suministros", owner=user, parent=grandparent)
|
|
||||||
child = Category.objects.create(name="Luz", owner=user, parent=parent)
|
|
||||||
|
|
||||||
assert parent.effective_expense_type == FIXED
|
|
||||||
assert child.effective_expense_type == FIXED
|
|
||||||
|
|
||||||
|
|
||||||
def test_effective_expense_type_own_value_wins_over_parent(user):
|
|
||||||
parent = Category.objects.create(name="Casa", owner=user, expense_type=FIXED)
|
|
||||||
child = Category.objects.create(name="Decoración", owner=user, parent=parent, expense_type=VARIABLE)
|
|
||||||
|
|
||||||
assert child.effective_expense_type == VARIABLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_effective_expense_type_empty_without_value_in_chain(user):
|
|
||||||
orphan = Category.objects.create(name="Suelta", owner=user)
|
|
||||||
parent = Category.objects.create(name="Padre", owner=user)
|
|
||||||
child = Category.objects.create(name="Hija", owner=user, parent=parent)
|
|
||||||
|
|
||||||
assert orphan.effective_expense_type == ""
|
|
||||||
assert child.effective_expense_type == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_effective_expense_type_stops_on_cycle(user):
|
|
||||||
a = Category.objects.create(name="A", owner=user)
|
|
||||||
b = Category.objects.create(name="B", owner=user, parent=a)
|
|
||||||
# Ciclo colado saltándose CategoryForm, como podría pasar desde el shell.
|
|
||||||
Category.objects.filter(pk=a.pk).update(parent=b)
|
|
||||||
a.refresh_from_db()
|
|
||||||
|
|
||||||
assert a.effective_expense_type == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_category_list_shows_own_and_inherited_expense_type(auth_client, user):
|
|
||||||
parent = Category.objects.create(name="Casa", owner=user, expense_type=FIXED)
|
|
||||||
child = Category.objects.create(name="Luz", owner=user, parent=parent)
|
|
||||||
Category.objects.create(name="Nieto", owner=user, parent=child, expense_type=VARIABLE)
|
|
||||||
Category.objects.create(name="Suelta", owner=user)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse('category_list'))
|
|
||||||
|
|
||||||
rows = {
|
|
||||||
row["category"].name: (row["expense_type_label"], row["expense_type_inherited"])
|
|
||||||
for row in response.context["category_rows"]
|
|
||||||
}
|
|
||||||
assert rows == {
|
|
||||||
"Casa": ("Fijo esencial", False),
|
|
||||||
"Luz": ("Fijo esencial", True),
|
|
||||||
"Nieto": ("Variable prescindible", False),
|
|
||||||
"Suelta": ("", True),
|
|
||||||
}
|
|
||||||
assert "(heredado)" in response.content.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def _category_list_queries(client):
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
client.get(reverse('category_list'))
|
|
||||||
return len(ctx.captured_queries)
|
|
||||||
|
|
||||||
|
|
||||||
def test_category_list_query_count_does_not_grow_with_rows(auth_client, user):
|
|
||||||
root = Category.objects.create(name="Raíz", owner=user, expense_type=FIXED)
|
|
||||||
Category.objects.create(name="Hija", owner=user, parent=root)
|
|
||||||
baseline = _category_list_queries(auth_client)
|
|
||||||
|
|
||||||
parent = root
|
|
||||||
for i in range(5):
|
|
||||||
parent = Category.objects.create(name=f"Nivel {i}", owner=user, parent=parent)
|
|
||||||
Category.objects.create(name=f"Hoja {i}", owner=user, parent=parent)
|
|
||||||
|
|
||||||
assert _category_list_queries(auth_client) == baseline
|
|
||||||
|
|
||||||
|
|
||||||
def test_categoryform_saves_expense_type(user):
|
|
||||||
form = CategoryForm(data={'name': 'Netflix', 'expense_type': Category.EXPENSE_TYPE_FIXED_DISCRETIONARY}, user=user)
|
|
||||||
assert form.is_valid(), form.errors
|
|
||||||
cat = form.save(commit=False)
|
|
||||||
cat.owner = user
|
|
||||||
cat.save()
|
|
||||||
|
|
||||||
cat.refresh_from_db()
|
|
||||||
assert cat.expense_type == Category.EXPENSE_TYPE_FIXED_DISCRETIONARY
|
|
||||||
|
|
||||||
|
|
||||||
def test_categoryform_leaves_expense_type_empty_when_not_chosen(user):
|
|
||||||
form = CategoryForm(data={'name': 'Varios', 'expense_type': ''}, user=user)
|
|
||||||
assert form.is_valid(), form.errors
|
|
||||||
cat = form.save(commit=False)
|
|
||||||
cat.owner = user
|
|
||||||
cat.save()
|
|
||||||
|
|
||||||
cat.refresh_from_db()
|
|
||||||
assert cat.expense_type == ""
|
|
||||||
|
|||||||
@ -1,202 +0,0 @@
|
|||||||
"""Red de seguridad: que todas las vistas rendericen y que el contexto no pierda claves.
|
|
||||||
|
|
||||||
Dos agujeros que ni `manage.py check` ni el resto de la suite cubren:
|
|
||||||
|
|
||||||
1. Un `{% url %}` mal escrito o una plantilla que falta solo revienta al pedir
|
|
||||||
la página. Es lo que pasó con `settings/index.html`, que estuvo roto sin que
|
|
||||||
nada avisara.
|
|
||||||
2. Un refactor que se lleva una clave del contexto por delante. Ya pasó dos
|
|
||||||
veces: `by_month` renombrado a `chart_data`, y `last_expenses` sustituido por
|
|
||||||
`movements`. Las claves que usan muchos tests están cubiertas de rebote; las
|
|
||||||
que se usan en un solo sitio se pierden en silencio.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from expenses import urls as expenses_urls
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 1. Smoke test de renderizado
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Rutas que a propósito NO devuelven 200. Van una por línea con su motivo: la
|
|
||||||
# alternativa (aceptar cualquier 2xx/3xx) haría pasar el test aunque la vista
|
|
||||||
# estuviera rota, que es justo lo contrario de lo que se busca aquí.
|
|
||||||
NOT_RENDERABLE = {
|
|
||||||
# Endpoint de descarga, no página: valida ExportForm(request.GET) y sin
|
|
||||||
# `kind` ni `fmt` responde 400 por diseño. Su cobertura está en test_export.
|
|
||||||
"export_download",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _argument_free_route_names():
|
|
||||||
"""Rutas del URLconf que se pueden pedir sin argumentos.
|
|
||||||
|
|
||||||
Se saca del URLconf y no de una lista escrita a mano: así una vista nueva
|
|
||||||
entra en el smoke test el día que se añade, sin que nadie se acuerde. Una
|
|
||||||
lista a mano se queda desfasada y el test deja de servir en silencio.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
pattern.name
|
|
||||||
for pattern in expenses_urls.urlpatterns
|
|
||||||
if pattern.pattern.regex.groups == 0
|
|
||||||
and pattern.name
|
|
||||||
and pattern.name not in NOT_RENDERABLE
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
ROUTE_NAMES = _argument_free_route_names()
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_route_list_is_not_empty():
|
|
||||||
# Si el recorrido del URLconf se rompiera, el parametrize saldría vacío y
|
|
||||||
# los tests de abajo "pasarían" sin pedir una sola página.
|
|
||||||
assert len(ROUTE_NAMES) > 15
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("route_name", ROUTE_NAMES)
|
|
||||||
def test_every_view_renders(auth_client, route_name):
|
|
||||||
# Una ruta por caso para que el fallo diga qué vista se rompió.
|
|
||||||
response = auth_client.get(reverse(route_name))
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("route_name", sorted(NOT_RENDERABLE))
|
|
||||||
def test_the_excluded_routes_are_still_excluded_for_a_reason(auth_client, route_name):
|
|
||||||
# Si una excluida empieza a devolver 200, la exclusión sobra y hay que
|
|
||||||
# quitarla: así la lista no se queda con motivos caducados dentro.
|
|
||||||
response = auth_client.get(reverse(route_name))
|
|
||||||
|
|
||||||
assert response.status_code != 200
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 2. Claves del contexto
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
DASHBOARD_CONTEXT_KEYS = {
|
|
||||||
# Navegación y fecha de referencia
|
|
||||||
"active_menu",
|
|
||||||
"today",
|
|
||||||
# KPIs
|
|
||||||
"kpi_total",
|
|
||||||
"kpi_count",
|
|
||||||
"kpi_categories",
|
|
||||||
"kpi_balance",
|
|
||||||
# Comparativa con el periodo anterior
|
|
||||||
"compare_enabled",
|
|
||||||
"compare_suppressed",
|
|
||||||
"kpi_previous_total",
|
|
||||||
"kpi_difference",
|
|
||||||
"kpi_difference_abs",
|
|
||||||
"kpi_percentage",
|
|
||||||
"kpi_trend",
|
|
||||||
"category_comparison",
|
|
||||||
# Gráficos: eje compartido, serie de gastos, serie de ingresos y desglose
|
|
||||||
"chart_labels",
|
|
||||||
"chart_data",
|
|
||||||
"chart_income_data",
|
|
||||||
"chart_type",
|
|
||||||
"by_category",
|
|
||||||
"by_category_chart",
|
|
||||||
"accounts_charts",
|
|
||||||
# Filtros de periodo, cuenta y rango libre
|
|
||||||
"period",
|
|
||||||
"year_list",
|
|
||||||
"months",
|
|
||||||
"selected_year",
|
|
||||||
"selected_month",
|
|
||||||
"date_from",
|
|
||||||
"date_to",
|
|
||||||
"range_active",
|
|
||||||
"accounts",
|
|
||||||
"selected_account",
|
|
||||||
"selected_account_obj",
|
|
||||||
# Objetivos y listado reciente
|
|
||||||
"goals",
|
|
||||||
"recent_expenses",
|
|
||||||
# Medias y proyección
|
|
||||||
"daily_average",
|
|
||||||
"projected_end_of_month",
|
|
||||||
}
|
|
||||||
|
|
||||||
HOME_CONTEXT_KEYS = {
|
|
||||||
"active_menu",
|
|
||||||
# KPIs del mes y su comparación con el anterior
|
|
||||||
"kpi_total",
|
|
||||||
"kpi_count",
|
|
||||||
"kpi_categories",
|
|
||||||
"prev_total",
|
|
||||||
"diff_amount",
|
|
||||||
"diff_pct",
|
|
||||||
# Saldos y alertas
|
|
||||||
"total_balance",
|
|
||||||
"account_balances",
|
|
||||||
"negative_accounts",
|
|
||||||
"has_alerts",
|
|
||||||
# Objetivos
|
|
||||||
"goals",
|
|
||||||
"exceeded_goals",
|
|
||||||
# Movimientos recientes y la mini gráfica
|
|
||||||
"movements",
|
|
||||||
"mini_chart_labels",
|
|
||||||
"mini_chart_data",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_context_has(response, expected_keys):
|
|
||||||
"""Inclusión, nunca igualdad.
|
|
||||||
|
|
||||||
Lo que hay que detectar es que falte una clave. Que aparezca una nueva es lo
|
|
||||||
normal al añadir una funcionalidad, así que endurecer esto a igualdad exacta
|
|
||||||
daría un test que falla cada vez que alguien amplía la vista, y acabaría
|
|
||||||
borrado por pesado. No lo cambies a ==.
|
|
||||||
"""
|
|
||||||
missing = sorted(key for key in expected_keys if key not in response.context)
|
|
||||||
|
|
||||||
assert not missing, "faltan claves en el contexto: %s" % ", ".join(missing)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_context_keeps_all_its_keys(auth_client):
|
|
||||||
response = _dashboard(auth_client)
|
|
||||||
|
|
||||||
_assert_context_has(response, DASHBOARD_CONTEXT_KEYS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_context_keeps_its_keys_with_comparison_on(auth_client):
|
|
||||||
# La comparativa rellena claves que en el camino normal se quedan a cero.
|
|
||||||
response = _dashboard(auth_client, compare="1")
|
|
||||||
|
|
||||||
_assert_context_has(response, DASHBOARD_CONTEXT_KEYS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_context_keeps_its_keys_with_a_free_range(auth_client):
|
|
||||||
# El rango libre entra por otra rama del eje, y es la que trae
|
|
||||||
# compara_suppressed y deja projected_end_of_month a cero.
|
|
||||||
response = _dashboard(auth_client, date_from="2024-03-01", date_to="2024-03-30")
|
|
||||||
|
|
||||||
_assert_context_has(response, DASHBOARD_CONTEXT_KEYS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_context_keeps_its_keys_with_a_preset(auth_client):
|
|
||||||
response = _dashboard(auth_client, period="this_month")
|
|
||||||
|
|
||||||
_assert_context_has(response, DASHBOARD_CONTEXT_KEYS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_home_context_keeps_all_its_keys(auth_client):
|
|
||||||
response = auth_client.get(reverse("home"))
|
|
||||||
|
|
||||||
_assert_context_has(response, HOME_CONTEXT_KEYS)
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard(client, **params):
|
|
||||||
response = client.get(reverse("dashboard"), params)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
return response
|
|
||||||
@ -125,31 +125,21 @@ def sub_months(year, month, n):
|
|||||||
|
|
||||||
def _category_tree(categories):
|
def _category_tree(categories):
|
||||||
"""Aplana las categorías en preorden (padres antes que hijos, cada nivel
|
"""Aplana las categorías en preorden (padres antes que hijos, cada nivel
|
||||||
ordenado por nombre) junto con su profundidad, para poder indentarlas.
|
ordenado por nombre) junto con su profundidad, para poder indentarlas."""
|
||||||
|
|
||||||
También resuelve el tipo de gasto heredado bajando por el árbol ya
|
|
||||||
cargado, en vez de Category.effective_expense_type por fila (N+1)."""
|
|
||||||
by_parent = {}
|
by_parent = {}
|
||||||
for category in categories:
|
for category in categories:
|
||||||
by_parent.setdefault(category.parent_id, []).append(category)
|
by_parent.setdefault(category.parent_id, []).append(category)
|
||||||
for children in by_parent.values():
|
for children in by_parent.values():
|
||||||
children.sort(key=lambda c: c.name.lower())
|
children.sort(key=lambda c: c.name.lower())
|
||||||
|
|
||||||
labels = dict(Category.EXPENSE_TYPE_CHOICES)
|
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
def walk(parent_id, depth, parent_type):
|
def walk(parent_id, depth):
|
||||||
for category in by_parent.get(parent_id, []):
|
for category in by_parent.get(parent_id, []):
|
||||||
expense_type = category.expense_type or parent_type
|
rows.append({"category": category, "depth": depth})
|
||||||
rows.append({
|
walk(category.id, depth + 1)
|
||||||
"category": category,
|
|
||||||
"depth": depth,
|
|
||||||
"expense_type_label": labels.get(expense_type, ""),
|
|
||||||
"expense_type_inherited": not category.expense_type,
|
|
||||||
})
|
|
||||||
walk(category.id, depth + 1, expense_type)
|
|
||||||
|
|
||||||
walk(None, 0, "")
|
walk(None, 0)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user