Merge pull request 'Modificaciones en filtrado y creada opción para marcar que un gasto/ingreso es un traspaso de cuentas' (#34) from dev into main
Reviewed-on: #34
This commit is contained in:
commit
bfe96e76f2
@ -27,6 +27,7 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
"category",
|
"category",
|
||||||
"account",
|
"account",
|
||||||
"tags",
|
"tags",
|
||||||
|
"is_transfer",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
|
"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
|
||||||
@ -50,7 +51,7 @@ class AccountForm(forms.ModelForm):
|
|||||||
class IncomeForm(forms.ModelForm):
|
class IncomeForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Income
|
model = Income
|
||||||
fields = ["account", "name", "amount", "date"]
|
fields = ["account", "name", "amount", "date", "is_transfer"]
|
||||||
widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})}
|
widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-09-15 10:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('expenses', '0012_alter_account_options_alter_category_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='expense',
|
||||||
|
name='is_transfer',
|
||||||
|
field=models.BooleanField(default=False, help_text='Márcalo si este movimiento solo mueve dinero entre cuentas propias. No contará como gasto ni como ingreso en los análisis, pero sí afectará al saldo de la cuenta.', verbose_name='Es un traspaso entre cuentas'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='income',
|
||||||
|
name='is_transfer',
|
||||||
|
field=models.BooleanField(default=False, help_text='Márcalo si este movimiento solo mueve dinero entre cuentas propias. No contará como gasto ni como ingreso en los análisis, pero sí afectará al saldo de la cuenta.', verbose_name='Es un traspaso entre cuentas'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -228,6 +228,16 @@ class Expense(models.Model):
|
|||||||
related_name="expenses",
|
related_name="expenses",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_transfer = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Es un traspaso entre cuentas",
|
||||||
|
help_text=(
|
||||||
|
"Márcalo si este movimiento solo mueve dinero entre cuentas propias. "
|
||||||
|
"No contará como gasto ni como ingreso en los análisis, pero sí "
|
||||||
|
"afectará al saldo de la cuenta."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
created_at = models.DateField(auto_now_add=True)
|
created_at = models.DateField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -246,6 +256,17 @@ class Income(models.Model):
|
|||||||
name = models.CharField(max_length=150)
|
name = models.CharField(max_length=150)
|
||||||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
|
|
||||||
|
is_transfer = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Es un traspaso entre cuentas",
|
||||||
|
help_text=(
|
||||||
|
"Márcalo si este movimiento solo mueve dinero entre cuentas propias. "
|
||||||
|
"No contará como gasto ni como ingreso en los análisis, pero sí "
|
||||||
|
"afectará al saldo de la cuenta."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -370,6 +391,9 @@ class Goal(models.Model):
|
|||||||
if self.kind == self.KIND_SAVING:
|
if self.kind == self.KIND_SAVING:
|
||||||
# Provisional: saldo de la cuenta asociada. Cuando exista el
|
# Provisional: saldo de la cuenta asociada. Cuando exista el
|
||||||
# módulo de inversiones, esta rama es lo único que hay que tocar.
|
# módulo de inversiones, esta rama es lo único que hay que tocar.
|
||||||
|
# Ojo: aquí los traspasos SÍ cuentan. Mandar dinero a la cuenta de
|
||||||
|
# ahorro es precisamente cómo se progresa en un objetivo de ahorro,
|
||||||
|
# así que no filtres is_transfer en esta rama.
|
||||||
return self.account.current_balance() if self.account else Decimal("0")
|
return self.account.current_balance() if self.account else Decimal("0")
|
||||||
|
|
||||||
if not self.category:
|
if not self.category:
|
||||||
@ -380,9 +404,11 @@ class Goal(models.Model):
|
|||||||
else:
|
else:
|
||||||
category_ids = [self.category_id]
|
category_ids = [self.category_id]
|
||||||
|
|
||||||
|
# Pago y presupuesto miden gasto real, así que los traspasos entre
|
||||||
|
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
|
||||||
expenses = Expense.objects.filter(
|
expenses = Expense.objects.filter(
|
||||||
owner=self.owner, category_id__in=category_ids
|
owner=self.owner, category_id__in=category_ids
|
||||||
)
|
).exclude(is_transfer=True)
|
||||||
|
|
||||||
start = self._period_start()
|
start = self._period_start()
|
||||||
if start:
|
if start:
|
||||||
|
|||||||
@ -97,6 +97,31 @@ a.danger:visited,
|
|||||||
color: var(--color-danger-accent);
|
color: var(--color-danger-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Escala tipográfica. Los márgenes van en rem a propósito: los del navegador
|
||||||
|
van en em y escalan con el tamaño de fuente, dejando huecos irregulares
|
||||||
|
entre secciones. */
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1.5rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================= */
|
/* ========================= */
|
||||||
/* Topbar / navigation */
|
/* Topbar / navigation */
|
||||||
/* ========================= */
|
/* ========================= */
|
||||||
@ -273,7 +298,7 @@ a.danger:visited,
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-context h2 {
|
.dashboard-context h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,7 +396,8 @@ button[type="submit"]:hover,
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-main select {
|
.filters-main select,
|
||||||
|
.filters-main input[type="date"] {
|
||||||
padding: 0.5rem 0.7rem;
|
padding: 0.5rem 0.7rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@ -381,11 +407,18 @@ button[type="submit"]:hover,
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-main select:focus {
|
.filters-main select:focus,
|
||||||
|
.filters-main input[type="date"]:focus {
|
||||||
outline: 2px solid var(--color-focus-ring);
|
outline: 2px solid var(--color-focus-ring);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.range-notice {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.filters-advanced {
|
.filters-advanced {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@ -707,6 +740,13 @@ tr:hover {
|
|||||||
color: var(--color-danger-text);
|
color: var(--color-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Etiqueta informativa, sin carga de error ni de éxito: marca un movimiento
|
||||||
|
como lo que es, no como un estado degradado. */
|
||||||
|
.badge-neutral {
|
||||||
|
background-color: var(--color-chip-bg);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.row-inactive {
|
.row-inactive {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
@ -898,7 +938,7 @@ tr:hover {
|
|||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card h3 {
|
.settings-card h2 {
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Editar categoría</h2>
|
<h1>Editar categoría</h1>
|
||||||
|
|
||||||
<form method="post" class="app-form">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<h1>Mis categorías</h1>
|
<h1>Mis categorías</h1>
|
||||||
|
|
||||||
<h3>Nueva categoría</h3>
|
<h2>Nueva categoría</h2>
|
||||||
<form method="post" class="app-form">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% include "expenses/_form_fields.html" %}
|
{% include "expenses/_form_fields.html" %}
|
||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<h3>Listado</h3>
|
<h2>Listado</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
{% comment %}
|
||||||
|
Campos del rango de fechas libre. Se incluye dentro de un <form method="get">.
|
||||||
|
Espera date_from y date_to en el contexto.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
<label class="sr-only" for="dateFrom">Desde</label>
|
||||||
|
<input type="date" name="date_from" id="dateFrom"
|
||||||
|
value="{% if date_from %}{{ date_from|date:'Y-m-d' }}{% endif %}">
|
||||||
|
|
||||||
|
<label class="sr-only" for="dateTo">Hasta</label>
|
||||||
|
<input type="date" name="date_to" id="dateTo"
|
||||||
|
value="{% if date_to %}{{ date_to|date:'Y-m-d' }}{% endif %}">
|
||||||
@ -7,16 +7,19 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<section class="dashboard-context">
|
<section class="dashboard-context">
|
||||||
<h2>
|
<h1>
|
||||||
{% if selected_account_obj %}
|
{% if selected_account_obj %}
|
||||||
{{ selected_account_obj.name }}
|
{{ selected_account_obj.name }}
|
||||||
{% else %}
|
{% else %}
|
||||||
Todas las cuentas
|
Todas las cuentas
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h1>
|
||||||
|
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
{% if selected_month %}
|
{% if range_active %}
|
||||||
|
Del {{ date_from|date:"d/m/Y"|default:"principio" }}
|
||||||
|
al {{ date_to|date:"d/m/Y"|default:"hoy" }}
|
||||||
|
{% elif selected_month %}
|
||||||
{{ selected_month }}/{{ selected_year}}
|
{{ selected_month }}/{{ selected_year}}
|
||||||
{% else %}
|
{% else %}
|
||||||
Año {{ selected_year }}
|
Año {{ selected_year }}
|
||||||
@ -66,6 +69,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{% include "expenses/_date_range.html" %}
|
||||||
|
|
||||||
{% if not period %}
|
{% if not period %}
|
||||||
<label class="sr-only" for="yearSelect">Año</label>
|
<label class="sr-only" for="yearSelect">Año</label>
|
||||||
<select name="year" id="yearSelect">
|
<select name="year" id="yearSelect">
|
||||||
@ -94,35 +99,16 @@
|
|||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Aplicar Filtros</button>
|
<button type="submit" class="btn btn-primary">Aplicar Filtros</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if range_active %}
|
||||||
|
<p class="range-notice">
|
||||||
|
Mostrando del {{ date_from|date:"d/m/Y"|default:"principio" }} al
|
||||||
|
{{ date_to|date:"d/m/Y"|default:"hoy" }}. Los filtros de año, mes
|
||||||
|
{% if compare_suppressed %}y la comparativa {% endif %}no se aplican.
|
||||||
|
<a href="{% url 'dashboard' %}">Quitar el rango</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
<script>
|
|
||||||
const yearSelect = document.getElementById('yearSelect');
|
|
||||||
const monthSelect = document.getElementById('monthSelect');
|
|
||||||
const hiddenPeriod = document.getElementById('hiddenPeriod');
|
|
||||||
|
|
||||||
function clearPeriodPreset() {
|
|
||||||
if (hiddenPeriod) {
|
|
||||||
hiddenPeriod.value = ""
|
|
||||||
}
|
|
||||||
document.querySelectorAll('.preset').forEach(btn => btn.classList.remove('active'));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<!-- <script>
|
|
||||||
document.querySelectorAll('.dashboard-presets a').forEach(link => {
|
|
||||||
link.addEventListener('click', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const period = link.dataset.period;
|
|
||||||
const isActive = link.classList.contains('active');
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
window.location.href = link.href;
|
|
||||||
} else {
|
|
||||||
window.location.href = `${link.href}?period=${period}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script> -->
|
|
||||||
|
|
||||||
<!-- ========================= -->
|
<!-- ========================= -->
|
||||||
<!-- KPIs -->
|
<!-- KPIs -->
|
||||||
@ -154,7 +140,7 @@
|
|||||||
<span class="kpi-value">{{ daily_average|floatformat:2 }}€ / día</span>
|
<span class="kpi-value">{{ daily_average|floatformat:2 }}€ / día</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if selected_month == today.month and selected_year == today.year %}
|
{% if selected_month == today.month and selected_year == today.year and not range_active %}
|
||||||
<div class="kpi-card" style="border-left: 4px solid var(--color-warning-accent);">
|
<div class="kpi-card" style="border-left: 4px solid var(--color-warning-accent);">
|
||||||
<span class="kpi-label">Proyección fin de mes</span>
|
<span class="kpi-label">Proyección fin de mes</span>
|
||||||
<span class="kpi-value">{{ projected_end_of_month|floatformat:2 }}€</span>
|
<span class="kpi-value">{{ projected_end_of_month|floatformat:2 }}€</span>
|
||||||
@ -165,7 +151,7 @@
|
|||||||
|
|
||||||
{% if compare_enabled %}
|
{% if compare_enabled %}
|
||||||
<section class="comparison">
|
<section class="comparison">
|
||||||
<h3>Resumen comparativo</h3>
|
<h2>Resumen comparativo</h2>
|
||||||
<p>
|
<p>
|
||||||
Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br>
|
Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br>
|
||||||
Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br>
|
Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br>
|
||||||
@ -178,7 +164,7 @@
|
|||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Desglose de cambios por categoría</h3>
|
<h2>Desglose de cambios por categoría</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -212,21 +198,21 @@
|
|||||||
|
|
||||||
<div class="charts-container">
|
<div class="charts-container">
|
||||||
<section>
|
<section>
|
||||||
<h3>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h3>
|
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
||||||
<div class="chart-box">
|
<div class="chart-box">
|
||||||
<canvas id="mainChart"></canvas>
|
<canvas id="mainChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>Distribución por Categorías</h3>
|
<h2>Distribución por Categorías</h2>
|
||||||
<div class="chart-box">
|
<div class="chart-box">
|
||||||
<canvas id="categoryChart"></canvas>
|
<canvas id="categoryChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>Gastos Recientes</h3>
|
<h2>Gastos Recientes</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -243,7 +229,12 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ exp.date|date:"d/m/Y" }}</td>
|
<td>{{ exp.date|date:"d/m/Y" }}</td>
|
||||||
<td>{{ exp.account.name }}</td>
|
<td>{{ exp.account.name }}</td>
|
||||||
<td>{{ exp.category.name }}</td>
|
<td>
|
||||||
|
{{ exp.category.name }}
|
||||||
|
{% if exp.is_transfer %}
|
||||||
|
<span class="badge badge-neutral">Traspaso</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ exp.description|default:"-" }}</td>
|
<td>{{ exp.description|default:"-" }}</td>
|
||||||
<td style="color: var(--color-danger-accent); font-weight: bold;">{{ exp.amount|floatformat:2 }}</td>
|
<td style="color: var(--color-danger-accent); font-weight: bold;">{{ exp.amount|floatformat:2 }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -290,11 +281,11 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h3>Evolución anual por cuenta ({{ selected_year }})</h3>
|
<h2>Evolución anual por cuenta ({{ selected_year }})</h2>
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
{% for acc in accounts_charts %}
|
{% for acc in accounts_charts %}
|
||||||
<div class="card card-chart">
|
<div class="card card-chart">
|
||||||
<h4>{{ acc.name }}</h4>
|
<h3>{{ acc.name }}</h3>
|
||||||
<p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }}€</p>
|
<p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }}€</p>
|
||||||
<div class="canvas-wrapper">
|
<div class="canvas-wrapper">
|
||||||
<canvas id="accountChart{{ acc.id }}"></canvas>
|
<canvas id="accountChart{{ acc.id }}"></canvas>
|
||||||
@ -393,28 +384,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
{% if compare_enabled %}
|
|
||||||
<section class="comparison">
|
|
||||||
<h3>Comparativa</h3>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Diferencia:
|
|
||||||
<strong class="{% if kpi_trend == 'up' %}positive{% endif %}">
|
|
||||||
{% if kpi_trend == "up" %}+{% endif %}
|
|
||||||
{{ kpi_difference_abs|floatformat:2 }} €
|
|
||||||
</strong>
|
|
||||||
{% if kpi_percentage %}
|
|
||||||
({{ kpi_percentage|floatformat:1 }}%)
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- ========================= -->
|
<!-- ========================= -->
|
||||||
<!-- Goals -->
|
<!-- Goals -->
|
||||||
<!-- ========================= -->
|
<!-- ========================= -->
|
||||||
|
|
||||||
<h3>Objetivos</h3>
|
<h2>Objetivos</h2>
|
||||||
{% if goals %}
|
{% if goals %}
|
||||||
<div class="goals-widget">
|
<div class="goals-widget">
|
||||||
{% for goal in goals %}
|
{% for goal in goals %}
|
||||||
|
|||||||
@ -47,10 +47,20 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{% include "expenses/_date_range.html" %}
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Filtrar</button>
|
<button type="submit" class="btn btn-primary">Filtrar</button>
|
||||||
<a href="{% url 'expense_list' %}" class="btn btn-secondary">Limpiar</a>
|
<a href="{% url 'expense_list' %}" class="btn btn-secondary">Limpiar</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if range_active %}
|
||||||
|
<p class="range-notice">
|
||||||
|
Mostrando el rango de fechas seleccionado; los filtros de año y mes no
|
||||||
|
se aplican.
|
||||||
|
<a href="{% url 'expense_list' %}">Quitar el rango</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<details class="filters-advanced"
|
<details class="filters-advanced"
|
||||||
@ -96,7 +106,7 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<strong>Total:</strong> {{ kpi_total|floatformat:2 }}€ |
|
<strong>Total (sin traspasos):</strong> {{ kpi_total|floatformat:2 }}€ |
|
||||||
<strong>Gastos:</strong> {{ kpi_count }} |
|
<strong>Gastos:</strong> {{ kpi_count }} |
|
||||||
<strong>Categorías:</strong> {{ kpi_categories }}
|
<strong>Categorías:</strong> {{ kpi_categories }}
|
||||||
</section>
|
</section>
|
||||||
@ -117,7 +127,12 @@
|
|||||||
{% for expense in page_obj %}
|
{% for expense in page_obj %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ expense.date }}</td>
|
<td>{{ expense.date }}</td>
|
||||||
<td>{{ expense.category.name }}</td>
|
<td>
|
||||||
|
{{ expense.category.name }}
|
||||||
|
{% if expense.is_transfer %}
|
||||||
|
<span class="badge badge-neutral">Traspaso</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ expense.amount }}</td>
|
<td>{{ expense.amount }}</td>
|
||||||
<td>{{ expense.account.name }}</td>
|
<td>{{ expense.account.name }}</td>
|
||||||
<td>
|
<td>
|
||||||
@ -142,8 +157,13 @@
|
|||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="empty-state">
|
<td colspan="6" class="empty-state">
|
||||||
<p>No hay gastos</p>
|
{% if filters_active %}
|
||||||
<a href="{% url 'expense_create' %}">Añade el primero</a>
|
<p>No hay gastos que coincidan con los filtros</p>
|
||||||
|
<a href="{% url 'expense_list' %}">Quitar los filtros</a>
|
||||||
|
{% else %}
|
||||||
|
<p>No hay gastos</p>
|
||||||
|
<a href="{% url 'expense_create' %}">Añade el primero</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -96,7 +96,12 @@
|
|||||||
{% for mov in movements %}
|
{% for mov in movements %}
|
||||||
<li class="movement">
|
<li class="movement">
|
||||||
<span class="movement-date">{{ mov.date }}</span>
|
<span class="movement-date">{{ mov.date }}</span>
|
||||||
<span class="movement-label">{{ mov.label }}</span>
|
<span class="movement-label">
|
||||||
|
{{ mov.label }}
|
||||||
|
{% if mov.is_transfer %}
|
||||||
|
<span class="badge badge-neutral">Traspaso</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
<span class="movement-account muted">{{ mov.account }}</span>
|
<span class="movement-account muted">{{ mov.account }}</span>
|
||||||
<span class="movement-amount {% if mov.kind == 'income' %}amount-positive{% else %}amount-negative{% endif %}">
|
<span class="movement-amount {% if mov.kind == 'income' %}amount-positive{% else %}amount-negative{% endif %}">
|
||||||
{% if mov.kind == 'income' %}+{% else %}−{% endif %}{{ mov.amount|floatformat:2 }}€
|
{% if mov.kind == 'income' %}+{% else %}−{% endif %}{{ mov.amount|floatformat:2 }}€
|
||||||
|
|||||||
@ -3,8 +3,72 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Mis ingresos</h1>
|
<h1>Mis ingresos</h1>
|
||||||
|
|
||||||
|
<form method="get" class="filters">
|
||||||
|
<div class="filters-main">
|
||||||
|
|
||||||
|
<label class="sr-only" for="filterYear">Año</label>
|
||||||
|
<select name="year" id="filterYear">
|
||||||
|
<option value="">Año</option>
|
||||||
|
{% for y in year_list %}
|
||||||
|
<option value="{{ y }}"
|
||||||
|
{% if selected_year == y %}selected{% endif %}
|
||||||
|
>
|
||||||
|
{{ y }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="sr-only" for="filterMonth">Mes</label>
|
||||||
|
<select name="month" id="filterMonth">
|
||||||
|
<option value="">Mes</option>
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ m }}"
|
||||||
|
{% if selected_month == m %}selected{% endif %}
|
||||||
|
>
|
||||||
|
{{ m }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="sr-only" for="filterAccount">Cuenta</label>
|
||||||
|
<select name="account" id="filterAccount">
|
||||||
|
<option value="">Cuenta</option>
|
||||||
|
{% for acc in accounts %}
|
||||||
|
<option value="{{ acc.id }}"
|
||||||
|
{% if selected_account == acc.id %}selected{% endif %}
|
||||||
|
>
|
||||||
|
{{ acc.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{% include "expenses/_date_range.html" %}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Filtrar</button>
|
||||||
|
<a href="{% url 'income_list' %}" class="btn btn-secondary">Limpiar</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if range_active %}
|
||||||
|
<p class="range-notice">
|
||||||
|
Mostrando el rango de fechas seleccionado; los filtros de año y mes no
|
||||||
|
se aplican.
|
||||||
|
<a href="{% url 'income_list' %}">Quitar el rango</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
<a class="btn" href="{% url 'income_create' %}">➕ Nuevo ingreso</a>
|
<a class="btn" href="{% url 'income_create' %}">➕ Nuevo ingreso</a>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<strong>Total (sin traspasos):</strong> {{ kpi_total|floatformat:2 }}€ |
|
||||||
|
<strong>Ingresos:</strong> {{ kpi_count }}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -17,10 +81,15 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for income in incomes %}
|
{% for income in page_obj %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ income.name }}</td>
|
<td>
|
||||||
<td>{{ income.account }}</td>
|
{{ income.name }}
|
||||||
|
{% if income.is_transfer %}
|
||||||
|
<span class="badge badge-neutral">Traspaso</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ income.account.name }}</td>
|
||||||
<td>{{ income.amount|floatformat:2 }}</td>
|
<td>{{ income.amount|floatformat:2 }}</td>
|
||||||
<td>{{ income.date }}</td>
|
<td>{{ income.date }}</td>
|
||||||
<td class="table-actions">
|
<td class="table-actions">
|
||||||
@ -31,8 +100,13 @@
|
|||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="empty-state">
|
<td colspan="5" class="empty-state">
|
||||||
<p>No hay ingresos</p>
|
{% if filters_active %}
|
||||||
<a href="{% url 'income_create' %}">Añade el primero</a>
|
<p>No hay ingresos que coincidan con los filtros</p>
|
||||||
|
<a href="{% url 'income_list' %}">Quitar los filtros</a>
|
||||||
|
{% else %}
|
||||||
|
<p>No hay ingresos</p>
|
||||||
|
<a href="{% url 'income_create' %}">Añade el primero</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -40,4 +114,20 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="pagination" aria-label="Paginación de ingresos">
|
||||||
|
<span class="step-links">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?page=1{% if query_params %}&{{ query_params }}{% endif %}">« Primero</a>
|
||||||
|
<a href="?page={{ page_obj.previous_page_number }}{% if query_params %}&{{ query_params }}{% endif %}">Anterior</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span>Página {{ page_obj.number }} de {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?page={{ page_obj.next_page_number }}{% if query_params %}&{{ query_params }}{% endif %}">Siguiente</a>
|
||||||
|
<a href="?page={{ page_obj.paginator.num_pages }}{% if query_params %}&{{ query_params }}{% endif %}">Último »</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -10,8 +10,6 @@
|
|||||||
|
|
||||||
<a class="btn" href="{% url 'fuel_create' %}">➕ Nuevo repostaje</a>
|
<a class="btn" href="{% url 'fuel_create' %}">➕ Nuevo repostaje</a>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h2>Iniciar sesión</h2>
|
<h1>Iniciar sesión</h1>
|
||||||
|
|
||||||
<form method="post" class="app-form">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h2>Recuperar contraseña</h2>
|
<h1>Recuperar contraseña</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Esta aplicación actualmente no envía correos de recuperación automáticos.
|
Esta aplicación actualmente no envía correos de recuperación automáticos.
|
||||||
|
|||||||
@ -5,23 +5,23 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Configuración</h2>
|
<h1>Configuración</h1>
|
||||||
|
|
||||||
<p>Gestiona las categorías, etiquetas y objetivos de tu cuenta</p>
|
<p>Gestiona las categorías, etiquetas y objetivos de tu cuenta</p>
|
||||||
|
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<a class="settings-card" href="{% url 'category_list' %}">
|
<a class="settings-card" href="{% url 'category_list' %}">
|
||||||
<h3>Categorías</h3>
|
<h2>Categorías</h2>
|
||||||
<p>Organiza tus gastos por tipo.</p>
|
<p>Organiza tus gastos por tipo.</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="settings-card" href="{% url 'tag_list' %}">
|
<a class="settings-card" href="{% url 'tag_list' %}">
|
||||||
<h3>Etiquetas</h3>
|
<h2>Etiquetas</h2>
|
||||||
<p>Añade etiquetas libres a tus gastos.</p>
|
<p>Añade etiquetas libres a tus gastos.</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="settings-card" href="{% url 'goal_list' %}">
|
<a class="settings-card" href="{% url 'goal_list' %}">
|
||||||
<h3>Objetivos</h3>
|
<h2>Objetivos</h2>
|
||||||
<p>Define y controla tus metas de gasto.</p>
|
<p>Define y controla tus metas de gasto.</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import pytest
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from expenses.models import Expense, Category
|
from expenses.models import Account, Expense, Category, Income
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@ -80,3 +80,146 @@ def test_dashboard_filters_by_year(auth_client, user, account, category):
|
|||||||
assert len(chart_data) == 12
|
assert len(chart_data) == 12
|
||||||
assert chart_data[4] == 20.0 # May = month 5 -> index 4
|
assert chart_data[4] == 20.0 # May = month 5 -> index 4
|
||||||
assert sum(chart_data) == 20.0 # only the 2024 expense contributes
|
assert sum(chart_data) == 20.0 # only the 2024 expense contributes
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_preset_this_month(auth_client):
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('dashboard'), {'period': 'this_month'})
|
||||||
|
|
||||||
|
assert response.context['selected_year'] == today.year
|
||||||
|
assert response.context['selected_month'] == today.month
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_preset_last_month(auth_client):
|
||||||
|
today = date.today()
|
||||||
|
expected_month = today.month - 1 or 12
|
||||||
|
expected_year = today.year if today.month > 1 else today.year - 1
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('dashboard'), {'period': 'last_month'})
|
||||||
|
|
||||||
|
assert response.context['selected_year'] == expected_year
|
||||||
|
assert response.context['selected_month'] == expected_month
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_preset_this_year_has_no_month(auth_client):
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('dashboard'), {'period': 'this_year'})
|
||||||
|
|
||||||
|
assert response.context['selected_year'] == today.year
|
||||||
|
assert response.context['selected_month'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_filters_by_account(auth_client, user, account, category):
|
||||||
|
other = Account.objects.create(
|
||||||
|
owner=user, name="Otra", initial_balance=Decimal("100"), active=True
|
||||||
|
)
|
||||||
|
Expense.objects.create(
|
||||||
|
owner=user, account=account, category=category,
|
||||||
|
amount=Decimal("10"), date=date(2024, 3, 1),
|
||||||
|
)
|
||||||
|
Expense.objects.create(
|
||||||
|
owner=user, account=other, category=category,
|
||||||
|
amount=Decimal("25"), date=date(2024, 3, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse('dashboard'),
|
||||||
|
{'year': 2024, 'account': other.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context['kpi_total'] == Decimal('25')
|
||||||
|
assert response.context['kpi_count'] == 1
|
||||||
|
assert response.context['kpi_balance'] == Decimal('75') # 100 - 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_without_compare_keeps_comparison_defaults(auth_client):
|
||||||
|
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||||
|
|
||||||
|
assert response.context['compare_enabled'] is False
|
||||||
|
assert response.context['kpi_previous_total'] == 0
|
||||||
|
assert response.context['kpi_difference'] == 0
|
||||||
|
assert response.context['kpi_percentage'] == 0
|
||||||
|
assert response.context['kpi_trend'] == 'equal'
|
||||||
|
assert response.context['category_comparison'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_compare_fills_difference_and_trend(auth_client, user, account, category):
|
||||||
|
Expense.objects.create(
|
||||||
|
owner=user, account=account, category=category,
|
||||||
|
amount=Decimal("20"), date=date(2024, 2, 15),
|
||||||
|
)
|
||||||
|
Expense.objects.create(
|
||||||
|
owner=user, account=account, category=category,
|
||||||
|
amount=Decimal("30"), date=date(2024, 3, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse('dashboard'),
|
||||||
|
{'year': 2024, 'month': 3, 'compare': '1'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context['compare_enabled'] is True
|
||||||
|
assert response.context['kpi_previous_total'] == Decimal('20')
|
||||||
|
assert response.context['kpi_difference'] == Decimal('10')
|
||||||
|
assert response.context['kpi_trend'] == 'up'
|
||||||
|
assert float(response.context['kpi_percentage']) == 50.0
|
||||||
|
|
||||||
|
rows = {row['category']: row for row in response.context['category_comparison']}
|
||||||
|
assert rows[category.name]['current'] == 30.0
|
||||||
|
assert rows[category.name]['previous'] == 20.0
|
||||||
|
assert rows[category.name]['difference'] == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_chart_type_is_day_when_a_month_is_selected(auth_client):
|
||||||
|
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
|
||||||
|
|
||||||
|
assert response.context['chart_type'] == 'day'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_chart_type_is_month_for_the_whole_year(auth_client):
|
||||||
|
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||||
|
|
||||||
|
assert response.context['chart_type'] == 'month'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_category_chart_keeps_only_top_ten(auth_client, user, account):
|
||||||
|
for i in range(12):
|
||||||
|
cat = Category.objects.create(name=f"Cat {i:02d}", owner=user)
|
||||||
|
Expense.objects.create(
|
||||||
|
owner=user, account=account, category=cat,
|
||||||
|
amount=Decimal(str(i + 1)), date=date(2024, 6, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||||
|
|
||||||
|
assert len(response.context['by_category_chart']) == 10
|
||||||
|
assert len(list(response.context['by_category'])) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user, account, category):
|
||||||
|
savings = Account.objects.create(
|
||||||
|
owner=user, name="Ahorro", initial_balance=Decimal("500"), active=True
|
||||||
|
)
|
||||||
|
Account.objects.create(
|
||||||
|
owner=user, name="Cerrada", initial_balance=Decimal("999"), active=False
|
||||||
|
)
|
||||||
|
|
||||||
|
Expense.objects.create(
|
||||||
|
owner=user, account=account, category=category,
|
||||||
|
amount=Decimal("40"), date=date(2024, 4, 1),
|
||||||
|
)
|
||||||
|
Income.objects.create(
|
||||||
|
owner=user, account=savings, name="Nomina",
|
||||||
|
amount=Decimal("60"), date=date(2024, 4, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||||
|
|
||||||
|
expected = sum(
|
||||||
|
acc.current_balance()
|
||||||
|
for acc in Account.objects.filter(owner=user, active=True)
|
||||||
|
)
|
||||||
|
assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva
|
||||||
|
assert response.context['kpi_balance'] == expected
|
||||||
|
|||||||
271
expenses_manager/expenses/tests/test_date_range.py
Normal file
271
expenses_manager/expenses/tests/test_date_range.py
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
"""Rango de fechas libre (date_from / date_to) en expense_list y dashboard.
|
||||||
|
|
||||||
|
La regla que estructura todo: si hay rango activo, manda el rango y los
|
||||||
|
selectores de año y mes no se aplican. Un solo criterio de periodo a la vez.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.urls import reverse
|
||||||
|
from expenses.models import Expense
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def make_expense(user, account, category, amount, on):
|
||||||
|
return Expense.objects.create(
|
||||||
|
owner=user,
|
||||||
|
account=account,
|
||||||
|
category=category,
|
||||||
|
amount=Decimal(amount),
|
||||||
|
date=on,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# expense_list
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_expense_list_filters_by_range(auth_client, user, account, category):
|
||||||
|
inside = make_expense(user, account, category, "10", date(2024, 3, 15))
|
||||||
|
before = make_expense(user, account, category, "20", date(2024, 2, 20))
|
||||||
|
after = make_expense(user, account, category, "30", date(2024, 4, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("expense_list"),
|
||||||
|
{"date_from": "2024-03-01", "date_to": "2024-03-31"},
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = list(response.context["page_obj"])
|
||||||
|
assert rows == [inside]
|
||||||
|
assert before not in rows and after not in rows
|
||||||
|
assert response.context["range_active"] is True
|
||||||
|
assert response.context["kpi_total"] == Decimal("10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_list_open_ended_range_reaches_the_end(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "10", date(2024, 2, 20))
|
||||||
|
march = make_expense(user, account, category, "20", date(2024, 3, 15))
|
||||||
|
december = make_expense(user, account, category, "30", date(2024, 12, 31))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("expense_list"), {"date_from": "2024-03-01"})
|
||||||
|
|
||||||
|
rows = list(response.context["page_obj"])
|
||||||
|
assert set(rows) == {march, december}
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_list_open_ended_range_reaches_the_start(auth_client, user, account, category):
|
||||||
|
old = make_expense(user, account, category, "10", date(2020, 1, 1))
|
||||||
|
february = make_expense(user, account, category, "20", date(2024, 2, 20))
|
||||||
|
make_expense(user, account, category, "30", date(2024, 4, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("expense_list"), {"date_to": "2024-03-01"})
|
||||||
|
|
||||||
|
rows = list(response.context["page_obj"])
|
||||||
|
assert set(rows) == {old, february}
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_list_inverted_range_is_ignored_entirely(auth_client, user, account, category):
|
||||||
|
"""Un rango invertido no debe devolver una lista vacía sin explicación."""
|
||||||
|
expenses = [
|
||||||
|
make_expense(user, account, category, "10", date(2024, 2, 20)),
|
||||||
|
make_expense(user, account, category, "20", date(2024, 3, 15)),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("expense_list"),
|
||||||
|
{"date_from": "2024-12-01", "date_to": "2024-01-01"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["range_active"] is False
|
||||||
|
assert set(response.context["page_obj"]) == set(expenses)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_list_malformed_date_is_ignored(auth_client, user, account, category):
|
||||||
|
expense = make_expense(user, account, category, "10", date(2024, 3, 15))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("expense_list"), {"date_from": "loquesea"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.context["range_active"] is False
|
||||||
|
assert list(response.context["page_obj"]) == [expense]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_list_range_overrides_year_and_month(auth_client, user, account, category):
|
||||||
|
"""El año viene en la URL pero no debe aplicarse: manda el rango."""
|
||||||
|
in_2023 = make_expense(user, account, category, "10", date(2023, 5, 10))
|
||||||
|
make_expense(user, account, category, "20", date(2024, 5, 10))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("expense_list"),
|
||||||
|
{"year": 2024, "month": 5, "date_from": "2023-01-01", "date_to": "2023-12-31"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list(response.context["page_obj"]) == [in_2023]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# dashboard
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_dashboard_filters_by_range(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "10", date(2024, 3, 15))
|
||||||
|
make_expense(user, account, category, "20", date(2024, 4, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"date_from": "2024-03-01", "date_to": "2024-03-31"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["range_active"] is True
|
||||||
|
assert response.context["kpi_total"] == Decimal("10")
|
||||||
|
assert response.context["kpi_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_range_overrides_year(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "10", date(2023, 5, 10))
|
||||||
|
make_expense(user, account, category, "20", date(2024, 5, 10))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"year": 2024, "date_from": "2023-01-01", "date_to": "2023-12-31"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["kpi_total"] == Decimal("10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_inverted_range_falls_back_to_the_normal_period(
|
||||||
|
auth_client, user, account, category
|
||||||
|
):
|
||||||
|
make_expense(user, account, category, "20", date(2024, 5, 10))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"year": 2024, "date_from": "2024-12-01", "date_to": "2024-01-01"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["range_active"] is False
|
||||||
|
assert response.context["kpi_total"] == Decimal("20")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_short_range_charts_by_day(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "10", date(2024, 3, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"date_from": "2024-03-01", "date_to": "2024-03-30"}, # 30 días
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["chart_type"] == "day"
|
||||||
|
labels = response.context["chart_labels"]
|
||||||
|
assert len(labels) == 30
|
||||||
|
assert labels[0] == "01/03"
|
||||||
|
assert labels[4] == "05/03"
|
||||||
|
# El eje es denso: el día con gasto lleva su importe, el resto va a cero.
|
||||||
|
assert response.context["chart_data"][4] == 10.0
|
||||||
|
assert response.context["chart_data"][0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_long_range_charts_by_month(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "10", date(2024, 2, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"date_from": "2024-01-01", "date_to": "2024-04-29"}, # 120 días
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["chart_type"] == "month"
|
||||||
|
labels = response.context["chart_labels"]
|
||||||
|
assert labels == ["01/2024", "02/2024", "03/2024", "04/2024"]
|
||||||
|
assert response.context["chart_data"][1] == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_range_threshold_is_62_days(auth_client, user, account, category):
|
||||||
|
start = date(2024, 3, 1)
|
||||||
|
|
||||||
|
at_limit = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"date_from": start.isoformat(), "date_to": (start + timedelta(days=61)).isoformat()},
|
||||||
|
)
|
||||||
|
over_limit = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"date_from": start.isoformat(), "date_to": (start + timedelta(days=62)).isoformat()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert at_limit.context["chart_type"] == "day" # 62 días justos
|
||||||
|
assert over_limit.context["chart_type"] == "month" # 63 días
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_range_disables_comparison(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "10", date(2024, 3, 15))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"compare": "1", "date_from": "2024-03-01", "date_to": "2024-03-31"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["compare_enabled"] is False
|
||||||
|
assert response.context["compare_suppressed"] is True
|
||||||
|
assert response.context["kpi_previous_total"] == 0
|
||||||
|
assert response.context["category_comparison"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_range_skips_the_end_of_month_projection(
|
||||||
|
auth_client, user, account, category
|
||||||
|
):
|
||||||
|
today = date.today()
|
||||||
|
make_expense(user, account, category, "10", today)
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"date_from": today.replace(day=1).isoformat()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["projected_end_of_month"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_range_daily_average_uses_the_range_length(
|
||||||
|
auth_client, user, account, category
|
||||||
|
):
|
||||||
|
make_expense(user, account, category, "100", date(2024, 3, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"),
|
||||||
|
{"date_from": "2024-03-01", "date_to": "2024-03-10"}, # 10 días
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["daily_average"] == Decimal("10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_empty_open_range_does_not_break(auth_client, user):
|
||||||
|
"""Sin date_from y sin gastos no hay longitud que medir: eje mensual vacío."""
|
||||||
|
response = auth_client.get(reverse("dashboard"), {"date_to": "2024-03-31"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.context["chart_type"] == "month"
|
||||||
|
assert response.context["chart_labels"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Sin rango no cambia nada
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_dashboard_without_range_keeps_the_old_day_axis(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "10", date(2024, 3, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("dashboard"), {"year": 2024, "month": 3})
|
||||||
|
|
||||||
|
assert response.context["range_active"] is False
|
||||||
|
assert response.context["chart_type"] == "day"
|
||||||
|
# Números de día, no fechas.
|
||||||
|
assert response.context["chart_labels"] == list(range(1, 32))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_without_range_keeps_the_old_month_axis(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "10", date(2024, 3, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("dashboard"), {"year": 2024})
|
||||||
|
|
||||||
|
assert response.context["chart_type"] == "month"
|
||||||
|
assert response.context["chart_labels"][0] == "Ene"
|
||||||
|
assert len(response.context["chart_labels"]) == 12
|
||||||
@ -1,9 +1,25 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
from django.db import connection
|
||||||
|
from django.urls import reverse
|
||||||
from expenses.models import Income, Account
|
from expenses.models import Income, Account
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def make_income(user, account, amount, on, name="Ingreso", is_transfer=False):
|
||||||
|
return Income.objects.create(
|
||||||
|
owner=user,
|
||||||
|
account=account,
|
||||||
|
name=name,
|
||||||
|
amount=Decimal(amount),
|
||||||
|
date=on,
|
||||||
|
is_transfer=is_transfer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_income_increases_account_balance(user):
|
def test_income_increases_account_balance(user):
|
||||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
|
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
|
||||||
|
|
||||||
@ -16,3 +32,140 @@ def test_income_increases_account_balance(user):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert general_account.current_balance() == 1500
|
assert general_account.current_balance() == 1500
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Listado: filtros, paginación y totales
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_income_list_filters_by_year(auth_client, user, account):
|
||||||
|
old = make_income(user, account, "10", date(2023, 5, 10))
|
||||||
|
new = make_income(user, account, "20", date(2024, 5, 10))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("income_list"), {"year": 2024})
|
||||||
|
|
||||||
|
assert list(response.context["page_obj"]) == [new]
|
||||||
|
assert old not in list(response.context["page_obj"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_filters_by_month(auth_client, user, account):
|
||||||
|
march = make_income(user, account, "10", date(2024, 3, 10))
|
||||||
|
make_income(user, account, "20", date(2024, 5, 10))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("income_list"), {"year": 2024, "month": 3})
|
||||||
|
|
||||||
|
assert list(response.context["page_obj"]) == [march]
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_filters_by_account(auth_client, user, account):
|
||||||
|
other = Account.objects.create(
|
||||||
|
owner=user, name="Otra", initial_balance=Decimal("0"), active=True
|
||||||
|
)
|
||||||
|
make_income(user, account, "10", date(2024, 3, 10))
|
||||||
|
from_other = make_income(user, other, "20", date(2024, 3, 11))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("income_list"), {"account": other.id})
|
||||||
|
|
||||||
|
assert list(response.context["page_obj"]) == [from_other]
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_filters_by_range(auth_client, user, account):
|
||||||
|
inside = make_income(user, account, "10", date(2024, 3, 15))
|
||||||
|
make_income(user, account, "20", date(2024, 4, 5))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("income_list"),
|
||||||
|
{"date_from": "2024-03-01", "date_to": "2024-03-31"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["range_active"] is True
|
||||||
|
assert list(response.context["page_obj"]) == [inside]
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_range_overrides_year(auth_client, user, account):
|
||||||
|
in_2023 = make_income(user, account, "10", date(2023, 5, 10))
|
||||||
|
make_income(user, account, "20", date(2024, 5, 10))
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("income_list"),
|
||||||
|
{"year": 2024, "date_from": "2023-01-01", "date_to": "2023-12-31"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list(response.context["page_obj"]) == [in_2023]
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_paginates_by_ten(auth_client, user, account):
|
||||||
|
for day in range(1, 15):
|
||||||
|
make_income(user, account, "10", date(2024, 3, day))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("income_list"))
|
||||||
|
|
||||||
|
assert len(response.context["page_obj"]) == 10
|
||||||
|
assert response.context["page_obj"].paginator.num_pages == 2
|
||||||
|
|
||||||
|
second = auth_client.get(reverse("income_list"), {"page": 2})
|
||||||
|
assert len(second.context["page_obj"]) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_pagination_links_keep_the_filters(auth_client, user, account):
|
||||||
|
for day in range(1, 15):
|
||||||
|
make_income(user, account, "10", date(2024, 3, day))
|
||||||
|
make_income(user, account, "99", date(2023, 1, 1))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("income_list"), {"year": 2024})
|
||||||
|
|
||||||
|
# query_params alimenta los enlaces de paginación: sin él, pasar de página
|
||||||
|
# perdería el filtro y aparecería el ingreso de 2023.
|
||||||
|
assert "year=2024" in response.context["query_params"]
|
||||||
|
assert "page" not in response.context["query_params"]
|
||||||
|
assert 'href="?page=2&year=2024"' in response.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_total_excludes_transfers_but_lists_them(auth_client, user, account):
|
||||||
|
make_income(user, account, "100", date(2024, 3, 10))
|
||||||
|
transfer = make_income(
|
||||||
|
user, account, "400", date(2024, 3, 11), is_transfer=True
|
||||||
|
)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("income_list"))
|
||||||
|
|
||||||
|
assert response.context["kpi_total"] == Decimal("100")
|
||||||
|
assert response.context["kpi_count"] == 1
|
||||||
|
assert transfer in list(response.context["page_obj"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_empty_state_when_filters_match_nothing(auth_client, user, account):
|
||||||
|
make_income(user, account, "10", date(2024, 3, 10))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("income_list"), {"year": 1999})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert list(response.context["page_obj"]) == []
|
||||||
|
assert response.context["filters_active"] is True
|
||||||
|
body = response.content.decode()
|
||||||
|
assert "No hay ingresos que coincidan con los filtros" in body
|
||||||
|
assert "Añade el primero" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_empty_state_without_filters(auth_client):
|
||||||
|
response = auth_client.get(reverse("income_list"))
|
||||||
|
|
||||||
|
assert response.context["filters_active"] is False
|
||||||
|
assert "Añade el primero" in response.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_list_query_count_does_not_grow_with_rows(auth_client, user, account):
|
||||||
|
"""select_related("account"): una consulta por página, no una por fila."""
|
||||||
|
for day in range(1, 4):
|
||||||
|
make_income(user, account, "10", date(2024, 3, day))
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as few:
|
||||||
|
auth_client.get(reverse("income_list"))
|
||||||
|
|
||||||
|
for day in range(4, 11):
|
||||||
|
make_income(user, account, "10", date(2024, 3, day))
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as many:
|
||||||
|
auth_client.get(reverse("income_list"))
|
||||||
|
|
||||||
|
assert len(many) == len(few)
|
||||||
|
|||||||
242
expenses_manager/expenses/tests/test_transfers.py
Normal file
242
expenses_manager/expenses/tests/test_transfers.py
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
"""Traspasos entre cuentas (is_transfer).
|
||||||
|
|
||||||
|
Un traspaso no es gasto ni ingreso real, pero sí mueve dinero entre cuentas.
|
||||||
|
De ahí los dos bloques de este fichero, que comprueban lo contrario el uno del
|
||||||
|
otro: los análisis excluyen los traspasos, los saldos los incluyen.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.urls import reverse
|
||||||
|
from expenses.models import Account, Category, Expense, Goal, Income
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def savings(user):
|
||||||
|
return Account.objects.create(
|
||||||
|
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_expense(user, account, category, amount, on, is_transfer=False):
|
||||||
|
return Expense.objects.create(
|
||||||
|
owner=user,
|
||||||
|
account=account,
|
||||||
|
category=category,
|
||||||
|
amount=Decimal(amount),
|
||||||
|
date=on,
|
||||||
|
is_transfer=is_transfer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_income(user, account, amount, on, name="Ingreso", is_transfer=False):
|
||||||
|
return Income.objects.create(
|
||||||
|
owner=user,
|
||||||
|
account=account,
|
||||||
|
name=name,
|
||||||
|
amount=Decimal(amount),
|
||||||
|
date=on,
|
||||||
|
is_transfer=is_transfer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Análisis: los traspasos NO cuentan
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_dashboard_kpi_total_ignores_transfer(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "100", date(2024, 5, 10))
|
||||||
|
make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("dashboard"), {"year": 2024})
|
||||||
|
|
||||||
|
assert response.context["kpi_total"] == Decimal("100")
|
||||||
|
assert response.context["kpi_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_by_category_ignores_transfer(auth_client, user, account, category):
|
||||||
|
other = Category.objects.create(owner=user, name="Traspasos")
|
||||||
|
make_expense(user, account, category, "100", date(2024, 5, 10))
|
||||||
|
make_expense(user, account, other, "400", date(2024, 5, 11), is_transfer=True)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("dashboard"), {"year": 2024})
|
||||||
|
|
||||||
|
rows = {row["category__name"]: row["total"] for row in response.context["by_category"]}
|
||||||
|
assert rows == {category.name: Decimal("100")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_comparison_ignores_transfer(auth_client, user, account, category):
|
||||||
|
make_expense(user, account, category, "30", date(2024, 3, 10))
|
||||||
|
make_expense(user, account, category, "20", date(2024, 2, 15))
|
||||||
|
make_expense(user, account, category, "500", date(2024, 2, 16), is_transfer=True)
|
||||||
|
|
||||||
|
response = auth_client.get(
|
||||||
|
reverse("dashboard"), {"year": 2024, "month": 3, "compare": "1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.context["kpi_previous_total"] == Decimal("20")
|
||||||
|
assert response.context["kpi_difference"] == Decimal("10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_kpi_total_ignores_transfer(auth_client, user, account, category):
|
||||||
|
today = date.today()
|
||||||
|
make_expense(user, account, category, "100", today)
|
||||||
|
make_expense(user, account, category, "400", today, is_transfer=True)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("home"))
|
||||||
|
|
||||||
|
assert response.context["kpi_total"] == Decimal("100")
|
||||||
|
assert response.context["kpi_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_previous_month_comparison_ignores_transfer(
|
||||||
|
auth_client, user, account, category
|
||||||
|
):
|
||||||
|
today = date.today()
|
||||||
|
# Día 1 para que reste un mes sin caer en un día inexistente.
|
||||||
|
first = date(today.year, today.month, 1)
|
||||||
|
prev = date(first.year - 1, 12, 1) if first.month == 1 else date(
|
||||||
|
first.year, first.month - 1, 1
|
||||||
|
)
|
||||||
|
|
||||||
|
make_expense(user, account, category, "10", prev)
|
||||||
|
make_expense(user, account, category, "900", prev, is_transfer=True)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("home"))
|
||||||
|
|
||||||
|
assert response.context["prev_total"] == Decimal("10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_list_total_ignores_transfer_but_row_is_listed(
|
||||||
|
auth_client, user, account, category
|
||||||
|
):
|
||||||
|
make_expense(user, account, category, "100", date(2024, 5, 10))
|
||||||
|
transfer = make_expense(
|
||||||
|
user, account, category, "400", date(2024, 5, 11), is_transfer=True
|
||||||
|
)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("expense_list"), {"year": 2024})
|
||||||
|
|
||||||
|
assert response.context["kpi_total"] == Decimal("100")
|
||||||
|
assert response.context["kpi_count"] == 1
|
||||||
|
# El traspaso sigue en la tabla aunque no sume en el total.
|
||||||
|
assert transfer in list(response.context["page_obj"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_goal_progress_ignores_transfer(user, account, category):
|
||||||
|
today = date.today()
|
||||||
|
make_expense(user, account, category, "50", today)
|
||||||
|
make_expense(user, account, category, "300", today, is_transfer=True)
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user,
|
||||||
|
name="Presupuesto mensual",
|
||||||
|
target_amount=Decimal("200"),
|
||||||
|
kind=Goal.KIND_BUDGET,
|
||||||
|
category=category,
|
||||||
|
period=Goal.PERIOD_MONTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress == Decimal("50")
|
||||||
|
assert not goal.is_exceeded()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Saldos: los traspasos SÍ cuentan
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_transfer_expense_still_lowers_source_balance(user, account, category):
|
||||||
|
account.initial_balance = Decimal("1000")
|
||||||
|
account.save()
|
||||||
|
make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True)
|
||||||
|
|
||||||
|
assert account.current_balance() == Decimal("600")
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_income_still_raises_target_balance(user, savings):
|
||||||
|
make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True)
|
||||||
|
|
||||||
|
assert savings.current_balance() == Decimal("400")
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_keeps_total_balance_unchanged(auth_client, user, account, category, savings):
|
||||||
|
account.initial_balance = Decimal("1000")
|
||||||
|
account.save()
|
||||||
|
make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True)
|
||||||
|
make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("home"))
|
||||||
|
|
||||||
|
# El dinero cambia de sitio, el patrimonio no se mueve.
|
||||||
|
assert response.context["total_balance"] == Decimal("1000")
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_goal_progress_counts_the_transfer(user, savings):
|
||||||
|
"""El punto más delicado: un traspaso a la cuenta de ahorro ES el progreso."""
|
||||||
|
make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True)
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user,
|
||||||
|
name="Fondo de emergencia",
|
||||||
|
target_amount=Decimal("1000"),
|
||||||
|
kind=Goal.KIND_SAVING,
|
||||||
|
account=savings,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress == Decimal("400")
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_list_balance_counts_the_transfer(auth_client, user, account, category):
|
||||||
|
account.initial_balance = Decimal("1000")
|
||||||
|
account.save()
|
||||||
|
make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse("account_list"))
|
||||||
|
|
||||||
|
rows = {row["account"].id: row["balance"] for row in response.context["account_rows"]}
|
||||||
|
assert rows[account.id] == Decimal("600")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Formularios
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_expense_form_can_mark_a_transfer(auth_client, user, account, category):
|
||||||
|
response = auth_client.post(
|
||||||
|
reverse("expense_create"),
|
||||||
|
{
|
||||||
|
"date": "2024-05-11",
|
||||||
|
"amount": "400",
|
||||||
|
"description": "",
|
||||||
|
"category": category.id,
|
||||||
|
"account": account.id,
|
||||||
|
"is_transfer": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert Expense.objects.get(owner=user).is_transfer is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_form_can_mark_a_transfer(auth_client, user, account):
|
||||||
|
response = auth_client.post(
|
||||||
|
reverse("income_create"),
|
||||||
|
{
|
||||||
|
"account": account.id,
|
||||||
|
"name": "Traspaso desde nómina",
|
||||||
|
"amount": "400",
|
||||||
|
"date": "2024-05-11",
|
||||||
|
"is_transfer": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert Income.objects.get(owner=user).is_transfer is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_defaults_to_not_a_transfer(user, account, category):
|
||||||
|
expense = make_expense(user, account, category, "10", date(2024, 5, 11))
|
||||||
|
|
||||||
|
assert expense.is_transfer is False
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import calendar
|
import calendar
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
|
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
|
||||||
@ -15,8 +15,14 @@ from .forms import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Sum, Count, ProtectedError
|
from django.db.models import Sum, Count, Min, ProtectedError
|
||||||
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower
|
from django.db.models.functions import (
|
||||||
|
ExtractMonth,
|
||||||
|
ExtractYear,
|
||||||
|
ExtractDay,
|
||||||
|
Lower,
|
||||||
|
TruncMonth,
|
||||||
|
)
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
@ -31,6 +37,37 @@ def _get_int(value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Umbral del eje del gráfico con rango libre: dos meses es donde un gráfico
|
||||||
|
# diario deja de leerse, así que por encima se agrupa por mes.
|
||||||
|
RANGE_DAY_CHART_MAX_DAYS = 62
|
||||||
|
|
||||||
|
|
||||||
|
def _get_date(value):
|
||||||
|
"""Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida."""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_date_range(request):
|
||||||
|
"""Lee el rango de fechas libre de la URL.
|
||||||
|
|
||||||
|
Devuelve (date_from, date_to); cualquiera de los dos puede ser None, porque
|
||||||
|
"desde el 1 de marzo" sin fecha final es un filtro válido.
|
||||||
|
|
||||||
|
Si el rango está invertido (desde > hasta) se ignoran LAS DOS fechas: devolver
|
||||||
|
una lista vacía sin explicación es peor que ignorar un filtro mal puesto.
|
||||||
|
"""
|
||||||
|
date_from = _get_date(request.GET.get("date_from"))
|
||||||
|
date_to = _get_date(request.GET.get("date_to"))
|
||||||
|
|
||||||
|
if date_from and date_to and date_from > date_to:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
return date_from, date_to
|
||||||
|
|
||||||
|
|
||||||
def _safe_next(request):
|
def _safe_next(request):
|
||||||
next_url = request.POST.get("next") or request.GET.get("next")
|
next_url = request.POST.get("next") or request.GET.get("next")
|
||||||
|
|
||||||
@ -77,9 +114,26 @@ def _category_tree(categories):
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _analysis_expenses(user):
|
||||||
|
"""Gastos para análisis: excluye los traspasos entre cuentas propias.
|
||||||
|
|
||||||
|
Un traspaso mueve dinero entre cuentas del usuario, así que suma al saldo de
|
||||||
|
cada cuenta pero NO es gasto real. Usa este helper en KPIs, gráficos y
|
||||||
|
comparativas.
|
||||||
|
|
||||||
|
NO lo uses en cálculos de saldo (_account_balances, current_balance,
|
||||||
|
monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan,
|
||||||
|
y filtrarlos descuadraría todos los saldos.
|
||||||
|
"""
|
||||||
|
return Expense.objects.filter(owner=user).exclude(is_transfer=True)
|
||||||
|
|
||||||
|
|
||||||
def _account_balances(accounts):
|
def _account_balances(accounts):
|
||||||
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
|
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
|
||||||
account.current_balance() en bucle, que dispararía N+1)."""
|
account.current_balance() en bucle, que dispararía N+1).
|
||||||
|
|
||||||
|
Los traspasos cuentan aquí a propósito: son movimientos reales de las
|
||||||
|
cuentas. No metas _analysis_expenses en esta función."""
|
||||||
account_ids = [a.id for a in accounts]
|
account_ids = [a.id for a in accounts]
|
||||||
|
|
||||||
expense_totals = {
|
expense_totals = {
|
||||||
@ -112,10 +166,73 @@ def _account_balances(accounts):
|
|||||||
return account_balances, negative_accounts, total_balance
|
return account_balances, negative_accounts, total_balance
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_period(request, today):
|
||||||
|
"""Traduce los parámetros de la URL en el periodo mirado por el dashboard.
|
||||||
|
|
||||||
|
Devuelve (period, selected_year, selected_month), donde selected_month es
|
||||||
|
None cuando se está mirando el año completo."""
|
||||||
|
period = request.GET.get("period", "")
|
||||||
|
|
||||||
|
if period == "this_month":
|
||||||
|
return period, today.year, today.month
|
||||||
|
|
||||||
|
if period == "last_month":
|
||||||
|
selected_year, selected_month = sub_months(today.year, today.month, 1)
|
||||||
|
return period, selected_year, selected_month
|
||||||
|
|
||||||
|
if period == "this_year":
|
||||||
|
return period, today.year, None
|
||||||
|
|
||||||
|
selected_year = _get_int(request.GET.get("year")) or today.year
|
||||||
|
selected_month = _get_int(request.GET.get("month")) or None
|
||||||
|
return period, selected_year, selected_month
|
||||||
|
|
||||||
|
|
||||||
|
def _build_account_charts(accounts, balance_by_account, selected_year, today):
|
||||||
|
"""Serie mensual de saldo de cada cuenta para las gráficas del dashboard.
|
||||||
|
|
||||||
|
En el año en curso la serie se corta en el mes actual y ese último punto
|
||||||
|
se sustituye por el saldo real de la cuenta, que llega ya calculado en
|
||||||
|
balance_by_account para no repetir current_balance() por cuenta."""
|
||||||
|
accounts_charts = []
|
||||||
|
|
||||||
|
for acc in accounts:
|
||||||
|
try:
|
||||||
|
monthly_data = acc.monthly_balance(selected_year)
|
||||||
|
m_balance = [float(row["balance"]) for row in monthly_data]
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Error calculando monthly_balance para la cuenta %s (año %s)",
|
||||||
|
acc.id, selected_year
|
||||||
|
)
|
||||||
|
m_balance = [0] * 12
|
||||||
|
|
||||||
|
current_balance = balance_by_account[acc.id]
|
||||||
|
|
||||||
|
if selected_year == today.year:
|
||||||
|
current_month_index = today.month - 1
|
||||||
|
|
||||||
|
if current_month_index < len(m_balance):
|
||||||
|
m_balance[current_month_index] = float(current_balance)
|
||||||
|
|
||||||
|
m_balance = m_balance[:today.month]
|
||||||
|
|
||||||
|
accounts_charts.append({
|
||||||
|
"id": acc.id,
|
||||||
|
"name": acc.name,
|
||||||
|
"data": m_balance,
|
||||||
|
"current_balance": current_balance,
|
||||||
|
})
|
||||||
|
|
||||||
|
return accounts_charts
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def home(request):
|
def home(request):
|
||||||
today = date.today()
|
today = date.today()
|
||||||
expenses = Expense.objects.filter(owner=request.user)
|
# Base de los KPIs, la comparativa y el mini-gráfico: sin traspasos.
|
||||||
|
# Los últimos movimientos usan su propia query, que sí los incluye.
|
||||||
|
expenses = _analysis_expenses(request.user)
|
||||||
|
|
||||||
# ---- KPIs del mes en curso ----
|
# ---- KPIs del mes en curso ----
|
||||||
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
|
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
|
||||||
@ -153,9 +270,14 @@ def home(request):
|
|||||||
has_alerts = bool(exceeded_goals or negative_accounts)
|
has_alerts = bool(exceeded_goals or negative_accounts)
|
||||||
|
|
||||||
# ---- Últimos movimientos (gastos + ingresos mezclados) ----
|
# ---- Últimos movimientos (gastos + ingresos mezclados) ----
|
||||||
recent_expenses = expenses.select_related("category", "account").order_by(
|
# Es un listado, no un análisis: los traspasos son movimientos reales de las
|
||||||
"-date", "-id"
|
# cuentas y salen marcados con un distintivo, así que no se cuelga de
|
||||||
)[:8]
|
# "expenses" (que ya los ha filtrado) sino de la query sin filtrar.
|
||||||
|
recent_expenses = (
|
||||||
|
Expense.objects.filter(owner=request.user)
|
||||||
|
.select_related("category", "account")
|
||||||
|
.order_by("-date", "-id")[:8]
|
||||||
|
)
|
||||||
recent_incomes = (
|
recent_incomes = (
|
||||||
Income.objects.filter(owner=request.user)
|
Income.objects.filter(owner=request.user)
|
||||||
.select_related("account")
|
.select_related("account")
|
||||||
@ -170,6 +292,7 @@ def home(request):
|
|||||||
"label": e.category.name,
|
"label": e.category.name,
|
||||||
"account": e.account.name,
|
"account": e.account.name,
|
||||||
"amount": e.amount,
|
"amount": e.amount,
|
||||||
|
"is_transfer": e.is_transfer,
|
||||||
}
|
}
|
||||||
for e in recent_expenses
|
for e in recent_expenses
|
||||||
]
|
]
|
||||||
@ -180,6 +303,7 @@ def home(request):
|
|||||||
"label": i.name,
|
"label": i.name,
|
||||||
"account": i.account.name,
|
"account": i.account.name,
|
||||||
"amount": i.amount,
|
"amount": i.amount,
|
||||||
|
"is_transfer": i.is_transfer,
|
||||||
}
|
}
|
||||||
for i in recent_incomes
|
for i in recent_incomes
|
||||||
],
|
],
|
||||||
@ -249,11 +373,21 @@ def expense_list(request):
|
|||||||
if (t_id:= _get_int(t)) is not None
|
if (t_id:= _get_int(t)) is not None
|
||||||
]
|
]
|
||||||
|
|
||||||
if year:
|
date_from, date_to = _resolve_date_range(request)
|
||||||
expenses = expenses.filter(date__year=year)
|
range_active = bool(date_from or date_to)
|
||||||
|
|
||||||
if month:
|
# Un solo criterio de periodo a la vez: si hay rango, manda el rango y los
|
||||||
expenses = expenses.filter(date__month=month)
|
# selectores de año y mes no se aplican (la plantilla lo avisa).
|
||||||
|
if range_active:
|
||||||
|
if date_from:
|
||||||
|
expenses = expenses.filter(date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
expenses = expenses.filter(date__lte=date_to)
|
||||||
|
else:
|
||||||
|
if year:
|
||||||
|
expenses = expenses.filter(date__year=year)
|
||||||
|
if month:
|
||||||
|
expenses = expenses.filter(date__month=month)
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
expenses = expenses.filter(category_id=category)
|
expenses = expenses.filter(category_id=category)
|
||||||
@ -268,11 +402,16 @@ def expense_list(request):
|
|||||||
|
|
||||||
expenses = expenses.order_by("-date")
|
expenses = expenses.order_by("-date")
|
||||||
|
|
||||||
total_amount = expenses.aggregate(total=Sum("amount"))["total"] or 0
|
# La tabla sigue listando los traspasos (hay que poder verlos y corregirlos),
|
||||||
|
# pero el total y los contadores miden gasto real, así que los excluyen. Por
|
||||||
|
# eso la etiqueta del total dice "sin traspasos" en la plantilla.
|
||||||
|
analysis_expenses = expenses.exclude(is_transfer=True)
|
||||||
|
|
||||||
expense_count = expenses.count()
|
total_amount = analysis_expenses.aggregate(total=Sum("amount"))["total"] or 0
|
||||||
|
|
||||||
category_count = expenses.values("category").distinct().count()
|
expense_count = analysis_expenses.count()
|
||||||
|
|
||||||
|
category_count = analysis_expenses.values("category").distinct().count()
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
paginator = Paginator(expenses, 10)
|
paginator = Paginator(expenses, 10)
|
||||||
@ -306,6 +445,10 @@ def expense_list(request):
|
|||||||
|
|
||||||
advanced_filters_open = bool(category or selected_tags)
|
advanced_filters_open = bool(category or selected_tags)
|
||||||
|
|
||||||
|
filters_active = bool(
|
||||||
|
year or month or category or selected_tags or account_id or range_active
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"expenses/expense_list.html",
|
"expenses/expense_list.html",
|
||||||
@ -315,6 +458,9 @@ def expense_list(request):
|
|||||||
"page_obj": page_obj,
|
"page_obj": page_obj,
|
||||||
"selected_year": year,
|
"selected_year": year,
|
||||||
"selected_month": month,
|
"selected_month": month,
|
||||||
|
"date_from": date_from,
|
||||||
|
"date_to": date_to,
|
||||||
|
"range_active": range_active,
|
||||||
"selected_category": category,
|
"selected_category": category,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"year_list": [y.year for y in year_list],
|
"year_list": [y.year for y in year_list],
|
||||||
@ -328,6 +474,7 @@ def expense_list(request):
|
|||||||
"accounts": Account.objects.filter(owner=request.user),
|
"accounts": Account.objects.filter(owner=request.user),
|
||||||
"selected_account": account_id,
|
"selected_account": account_id,
|
||||||
"advanced_filters_open": advanced_filters_open,
|
"advanced_filters_open": advanced_filters_open,
|
||||||
|
"filters_active": filters_active,
|
||||||
"query_params": query_params.urlencode(),
|
"query_params": query_params.urlencode(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -407,42 +554,53 @@ def expense_delete(request, pk):
|
|||||||
def dashboard(request):
|
def dashboard(request):
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|
||||||
period = request.GET.get("period", "")
|
|
||||||
account_id = _get_int(request.GET.get("account"))
|
account_id = _get_int(request.GET.get("account"))
|
||||||
compare_enabled = request.GET.get("compare") == "1"
|
compare_requested = request.GET.get("compare") == "1"
|
||||||
|
|
||||||
# Time presets
|
# Time presets
|
||||||
if period == "this_month":
|
period, selected_year, selected_month = _resolve_period(request, today)
|
||||||
selected_year, selected_month = today.year, today.month
|
|
||||||
elif period == "last_month":
|
# Rango de fechas libre. Si está activo manda sobre año y mes, y además
|
||||||
selected_year, selected_month = sub_months(today.year, today.month, 1)
|
# desactiva la comparativa: no hay un "periodo anterior" obvio de un rango
|
||||||
elif period == "this_year":
|
# arbitrario. compare_suppressed sirve para decírselo al usuario.
|
||||||
selected_year, selected_month = today.year, None
|
date_from, date_to = _resolve_date_range(request)
|
||||||
else:
|
range_active = bool(date_from or date_to)
|
||||||
selected_year = _get_int(request.GET.get("year")) or _get_int(today.year)
|
compare_enabled = compare_requested and not range_active
|
||||||
selected_month = _get_int(request.GET.get("month"))
|
compare_suppressed = compare_requested and range_active
|
||||||
selected_month = _get_int(selected_month) if selected_month else None
|
|
||||||
|
|
||||||
# Accounts
|
# Accounts
|
||||||
accounts = Account.objects.filter(owner=request.user, active=True)
|
accounts = list(Account.objects.filter(owner=request.user, active=True))
|
||||||
selected_account_obj = None
|
selected_account_obj = None
|
||||||
if account_id:
|
if account_id:
|
||||||
selected_account_obj = accounts.filter(id=account_id).first()
|
selected_account_obj = next((a for a in accounts if a.id == account_id), None)
|
||||||
|
|
||||||
# Calculate the KPI
|
# Calculate the KPI
|
||||||
|
account_balances, _, total_balance = _account_balances(accounts)
|
||||||
|
balance_by_account = {row["account"].id: row["balance"] for row in account_balances}
|
||||||
|
|
||||||
if selected_account_obj:
|
if selected_account_obj:
|
||||||
kpi_balance = selected_account_obj.current_balance()
|
kpi_balance = balance_by_account[selected_account_obj.id]
|
||||||
else:
|
else:
|
||||||
kpi_balance = sum(account.current_balance() for account in accounts)
|
kpi_balance = total_balance
|
||||||
|
|
||||||
# Filter by base expenses
|
# Filter by base expenses
|
||||||
expenses = Expense.objects.filter(owner=request.user)
|
# Todo lo que cuelga de aquí es análisis (KPIs, gráficos, comparativa), así
|
||||||
|
# que va sin traspasos. Los saldos de arriba sí los incluyen.
|
||||||
|
expenses = _analysis_expenses(request.user)
|
||||||
if account_id:
|
if account_id:
|
||||||
expenses = expenses.filter(account_id=account_id)
|
expenses = expenses.filter(account_id=account_id)
|
||||||
|
|
||||||
expenses_filtered = expenses.filter(date__year=selected_year)
|
if range_active:
|
||||||
if selected_month:
|
expenses_filtered = expenses
|
||||||
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
|
if date_from:
|
||||||
|
expenses_filtered = expenses_filtered.filter(date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
expenses_filtered = expenses_filtered.filter(date__lte=date_to)
|
||||||
|
else:
|
||||||
|
expenses_filtered = expenses.filter(date__year=selected_year)
|
||||||
|
if selected_month:
|
||||||
|
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
|
||||||
|
|
||||||
|
|
||||||
# Basic KPIs
|
# Basic KPIs
|
||||||
total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0
|
total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0
|
||||||
@ -474,7 +632,60 @@ def dashboard(request):
|
|||||||
#Graphic
|
#Graphic
|
||||||
daily_average = 0
|
daily_average = 0
|
||||||
projected_end_of_month = 0
|
projected_end_of_month = 0
|
||||||
if selected_month:
|
if range_active:
|
||||||
|
# Con rango libre el eje no lo decide el mes sino la longitud del rango.
|
||||||
|
# Si falta un extremo se acota para poder medirlo: por abajo con el gasto
|
||||||
|
# más antiguo del conjunto, por arriba con hoy.
|
||||||
|
span_from = date_from or expenses_filtered.aggregate(first=Min("date"))["first"]
|
||||||
|
span_to = date_to or today
|
||||||
|
|
||||||
|
if span_from is None:
|
||||||
|
# Sin gastos y sin fecha inicial no hay longitud que medir.
|
||||||
|
chart_labels = []
|
||||||
|
chart_totals = []
|
||||||
|
chart_type = "month"
|
||||||
|
else:
|
||||||
|
span_days = (span_to - span_from).days + 1
|
||||||
|
daily_average = total_amount / span_days if span_days > 0 else 0
|
||||||
|
# projected_end_of_month se queda en 0: proyectar a fin de mes desde
|
||||||
|
# un rango arbitrario no significa nada.
|
||||||
|
|
||||||
|
if span_days <= RANGE_DAY_CHART_MAX_DAYS:
|
||||||
|
# Agrupar por el propio campo, no con TruncDate: "date" ya es un
|
||||||
|
# DateField y TruncDate le aplicaría una conversión de zona
|
||||||
|
# horaria que en SQLite falla si no está la base de datos tz.
|
||||||
|
by_day_qs = expenses_filtered.values("date").annotate(
|
||||||
|
total=Sum("amount")
|
||||||
|
)
|
||||||
|
day_totals = {row["date"]: float(row["total"]) for row in by_day_qs}
|
||||||
|
|
||||||
|
axis = [span_from + timedelta(days=i) for i in range(span_days)]
|
||||||
|
chart_labels = [d.strftime("%d/%m") for d in axis]
|
||||||
|
chart_totals = [day_totals.get(d, 0) for d in axis]
|
||||||
|
chart_type = "day"
|
||||||
|
else:
|
||||||
|
by_month_qs = (
|
||||||
|
expenses_filtered.annotate(bucket=TruncMonth("date"))
|
||||||
|
.values("bucket")
|
||||||
|
.annotate(total=Sum("amount"))
|
||||||
|
)
|
||||||
|
month_totals = {
|
||||||
|
(row["bucket"].year, row["bucket"].month): float(row["total"])
|
||||||
|
for row in by_month_qs
|
||||||
|
}
|
||||||
|
|
||||||
|
axis = []
|
||||||
|
cursor = (span_from.year, span_from.month)
|
||||||
|
last = (span_to.year, span_to.month)
|
||||||
|
while cursor <= last:
|
||||||
|
axis.append(cursor)
|
||||||
|
y, m = cursor
|
||||||
|
cursor = (y + 1, 1) if m == 12 else (y, m + 1)
|
||||||
|
|
||||||
|
chart_labels = [f"{m:02d}/{y}" for y, m in axis]
|
||||||
|
chart_totals = [month_totals.get(key, 0) for key in axis]
|
||||||
|
chart_type = "month"
|
||||||
|
elif selected_month:
|
||||||
by_day_qs = (
|
by_day_qs = (
|
||||||
expenses_filtered.annotate(day=ExtractDay("date"))
|
expenses_filtered.annotate(day=ExtractDay("date"))
|
||||||
.values("day")
|
.values("day")
|
||||||
@ -522,7 +733,7 @@ def dashboard(request):
|
|||||||
category_comparison = []
|
category_comparison = []
|
||||||
|
|
||||||
if compare_enabled:
|
if compare_enabled:
|
||||||
previous_expenses = Expense.objects.filter(owner=request.user)
|
previous_expenses = _analysis_expenses(request.user)
|
||||||
if account_id:
|
if account_id:
|
||||||
previous_expenses = previous_expenses.filter(account_id=account_id)
|
previous_expenses = previous_expenses.filter(account_id=account_id)
|
||||||
|
|
||||||
@ -561,32 +772,9 @@ def dashboard(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Anual evolution by accounts
|
# Anual evolution by accounts
|
||||||
accounts_charts = []
|
accounts_charts = _build_account_charts(
|
||||||
for acc in accounts:
|
accounts, balance_by_account, selected_year, today
|
||||||
try:
|
)
|
||||||
monthly_data = acc.monthly_balance(selected_year)
|
|
||||||
m_balance = [float(row["balance"]) for row in monthly_data]
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Error calculando monthly_balance para la cuenta %s (año %s)",
|
|
||||||
acc.id, selected_year
|
|
||||||
)
|
|
||||||
m_balance = [0] * 12
|
|
||||||
|
|
||||||
if selected_year == today.year:
|
|
||||||
current_month_index = today.month - 1
|
|
||||||
|
|
||||||
if current_month_index < len(m_balance):
|
|
||||||
m_balance[current_month_index] = float(acc.current_balance())
|
|
||||||
|
|
||||||
m_balance = m_balance[:today.month]
|
|
||||||
|
|
||||||
accounts_charts.append({
|
|
||||||
"id": acc.id,
|
|
||||||
"name": acc.name,
|
|
||||||
"data": m_balance,
|
|
||||||
"current_balance": acc.current_balance(),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Goals
|
# Goals
|
||||||
goals = Goal.objects.filter(owner=request.user)
|
goals = Goal.objects.filter(owner=request.user)
|
||||||
@ -605,10 +793,15 @@ def dashboard(request):
|
|||||||
"by_category_chart": by_category_chart,
|
"by_category_chart": by_category_chart,
|
||||||
"chart_labels": chart_labels,
|
"chart_labels": chart_labels,
|
||||||
"chart_data": chart_totals,
|
"chart_data": chart_totals,
|
||||||
|
"chart_type": chart_type,
|
||||||
"year_list": year_list,
|
"year_list": year_list,
|
||||||
"months": list(range(1, 13)),
|
"months": list(range(1, 13)),
|
||||||
"selected_year": selected_year,
|
"selected_year": selected_year,
|
||||||
"selected_month": selected_month,
|
"selected_month": selected_month,
|
||||||
|
"date_from": date_from,
|
||||||
|
"date_to": date_to,
|
||||||
|
"range_active": range_active,
|
||||||
|
"compare_suppressed": compare_suppressed,
|
||||||
"kpi_total": total_amount,
|
"kpi_total": total_amount,
|
||||||
"kpi_count": expense_count,
|
"kpi_count": expense_count,
|
||||||
"kpi_categories": category_count,
|
"kpi_categories": category_count,
|
||||||
@ -791,12 +984,79 @@ def income_create(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def income_list(request):
|
def income_list(request):
|
||||||
incomes = Income.objects.filter(owner=request.user)
|
incomes = Income.objects.filter(owner=request.user).select_related("account")
|
||||||
|
|
||||||
|
year_list = Income.objects.filter(owner=request.user).dates("date", "year")
|
||||||
|
|
||||||
|
months = list(range(1, 13))
|
||||||
|
|
||||||
|
# Filters
|
||||||
|
year = _get_int(request.GET.get("year"))
|
||||||
|
month = _get_int(request.GET.get("month"))
|
||||||
|
account_id = _get_int(request.GET.get("account"))
|
||||||
|
|
||||||
|
date_from, date_to = _resolve_date_range(request)
|
||||||
|
range_active = bool(date_from or date_to)
|
||||||
|
|
||||||
|
# Misma precedencia que en expense_list: si hay rango, manda el rango y los
|
||||||
|
# selectores de año y mes no se aplican (la plantilla lo avisa).
|
||||||
|
if range_active:
|
||||||
|
if date_from:
|
||||||
|
incomes = incomes.filter(date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
incomes = incomes.filter(date__lte=date_to)
|
||||||
|
else:
|
||||||
|
if year:
|
||||||
|
incomes = incomes.filter(date__year=year)
|
||||||
|
if month:
|
||||||
|
incomes = incomes.filter(date__month=month)
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
incomes = incomes.filter(account_id=account_id)
|
||||||
|
|
||||||
|
incomes = incomes.order_by("-date")
|
||||||
|
|
||||||
|
# La tabla sigue listando los traspasos, pero el total mide ingreso real y
|
||||||
|
# los excluye. Por eso la etiqueta dice "sin traspasos" en la plantilla.
|
||||||
|
totals = incomes.exclude(is_transfer=True).aggregate(
|
||||||
|
total=Sum("amount"), count=Count("id")
|
||||||
|
)
|
||||||
|
total_amount = totals["total"] or 0
|
||||||
|
income_count = totals["count"]
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
paginator = Paginator(incomes, 10)
|
||||||
|
page_number = request.GET.get("page")
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
query_params = request.GET.copy()
|
||||||
|
query_params.pop("page", None)
|
||||||
|
|
||||||
|
filters_active = bool(
|
||||||
|
year or month or account_id or range_active
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"expenses/income_list.html",
|
"expenses/income_list.html",
|
||||||
{"active_menu": "incomes", "incomes": incomes},
|
{
|
||||||
|
"active_menu": "incomes",
|
||||||
|
"incomes": page_obj,
|
||||||
|
"page_obj": page_obj,
|
||||||
|
"year_list": [y.year for y in year_list],
|
||||||
|
"months": months,
|
||||||
|
"accounts": Account.objects.filter(owner=request.user),
|
||||||
|
"selected_year": year,
|
||||||
|
"selected_month": month,
|
||||||
|
"selected_account": account_id,
|
||||||
|
"date_from": date_from,
|
||||||
|
"date_to": date_to,
|
||||||
|
"range_active": range_active,
|
||||||
|
"kpi_total": total_amount,
|
||||||
|
"kpi_count": income_count,
|
||||||
|
"filters_active": filters_active,
|
||||||
|
"query_params": query_params.urlencode(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user