Exportado de gastos #40

Merged
jkuijperm merged 4 commits from dev into main 2026-10-01 13:28:47 +00:00
6 changed files with 142 additions and 1 deletions
Showing only changes of commit dfebee1a09 - Show all commits

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from .csv_export import DEFAULT_DIALECT
from .models import ( from .models import (
Expense, Expense,
Category, Category,
@ -233,5 +234,66 @@ class GoalForm(forms.ModelForm):
) )
else: else:
cleaned["period"] = Goal.PERIOD_NONE cleaned["period"] = Goal.PERIOD_NONE
return cleaned
class ExportForm(forms.Form):
"""Filtros de la exportación a CSV. Valida la pantalla y la descarga.
Los filtros son a propósito solo rango de fechas y cuenta: la gracia de un
CSV es filtrar fuera, y duplicar aquí la fila de filtros avanzados de
expense_list serían dos UIs que divergen.
"""
KIND_CHOICES = [
("expenses", "Gastos"),
("incomes", "Ingresos"),
("transfers", "Traspasos"),
]
FMT_CHOICES = [
("excel", "Excel español — separador ; y decimales con coma"),
("std", "CSV estándar — separador , y decimales con punto"),
]
kind = forms.ChoiceField(choices=KIND_CHOICES, label="Qué exportar")
date_from = forms.DateField(
required=False,
label="Desde",
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
)
date_to = forms.DateField(
required=False,
label="Hasta",
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
)
account = forms.ModelChoiceField(
queryset=Account.objects.none(),
required=False,
label="Cuenta",
empty_label="Todas las cuentas",
)
fmt = forms.ChoiceField(
choices=FMT_CHOICES, label="Formato", initial=DEFAULT_DIALECT
)
def __init__(self, *args, **kwargs):
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
# Activas e inactivas: se exporta histórico, y una cuenta cerrada tiene
# movimientos que interesan.
self.fields["account"].queryset = Account.objects.filter(owner=user)
self.fields["date_from"].input_formats = ["%Y-%m-%d"]
self.fields["date_to"].input_formats = ["%Y-%m-%d"]
def clean(self):
cleaned = super().clean()
date_from, date_to = cleaned.get("date_from"), cleaned.get("date_to")
# En un listado un rango invertido se ignora; aquí no, porque el
# resultado sería un fichero vacío descargado sin explicación.
if date_from and date_to and date_from > date_to:
self.add_error("date_to", "La fecha final es anterior a la inicial.")
return cleaned return cleaned

View File

@ -43,6 +43,7 @@
<a href="{% url 'category_list' %}">Categorías</a> <a href="{% url 'category_list' %}">Categorías</a>
<a href="{% url 'tag_list' %}">Etiquetas</a> <a href="{% url 'tag_list' %}">Etiquetas</a>
<a href="{% url 'goal_list' %}">Objetivos</a> <a href="{% url 'goal_list' %}">Objetivos</a>
<a href="{% url 'export_index' %}">Exportar datos</a>
</div> </div>
</div> </div>

View File

@ -0,0 +1,30 @@
{% extends "expenses/base.html" %}
{% block title %}Exportar datos{% endblock %}
{% block content %}
<h1>Exportar datos</h1>
<p>Descarga tus movimientos en CSV para abrirlos en Excel o tratarlos aparte.</p>
<div class="messages">
<div class="message warning">
Los dos movimientos de cada traspaso también salen en el CSV de gastos y
en el de ingresos, marcados con «Traspaso = Sí». Sumar los tres ficheros
cuenta el mismo dinero dos veces.
</div>
<div class="message warning">
La comisión de un traspaso sale en el CSV de gastos como gasto normal
(«Traspaso = No»), porque es dinero que sale de verdad del patrimonio.
</div>
</div>
<form method="get" action="{% url 'export_download' %}" class="app-form">
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Descargar CSV</button>
<a class="btn btn-secondary" href="{% url 'settings_index' %}">Volver</a>
</div>
</form>
{% endblock %}

View File

@ -24,6 +24,11 @@
<h2>Objetivos</h2> <h2>Objetivos</h2>
<p>Define y controla tus metas de gasto.</p> <p>Define y controla tus metas de gasto.</p>
</a> </a>
<a class="settings-card" href="{% url 'export_index' %}">
<h2>Exportar datos</h2>
<p>Descarga tus gastos, ingresos o traspasos en CSV.</p>
</a>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -33,6 +33,8 @@ urlpatterns = [
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'), path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'), path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
path('settings/', views.settings_index, name='settings_index'), path('settings/', views.settings_index, name='settings_index'),
path('export/', views.export_index, name='export_index'),
path('export/download/', views.export_download, name='export_download'),
path('goals/', views.goal_list, name='goal_list'), path('goals/', views.goal_list, name='goal_list'),
path('goals/new/', views.goal_create, name='goal_create'), path('goals/new/', views.goal_create, name='goal_create'),
path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'), path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'),

View File

@ -23,7 +23,9 @@ from .forms import (
CategoryForm, CategoryForm,
GoalForm, GoalForm,
TransferForm, TransferForm,
ExportForm,
) )
from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Sum, Count, Min, ProtectedError from django.db.models import Sum, Count, Min, ProtectedError
@ -36,6 +38,7 @@ from django.db.models.functions import (
) )
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest, StreamingHttpResponse
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.shortcuts import get_object_or_404, render, redirect from django.shortcuts import get_object_or_404, render, redirect
from django.urls import reverse from django.urls import reverse
@ -1667,3 +1670,41 @@ def transfer_delete(request, pk):
"transfers/confirm_delete.html", "transfers/confirm_delete.html",
{"active_menu": "transfers", "transfer": transfer}, {"active_menu": "transfers", "transfer": transfer},
) )
@login_required
def export_index(request):
# Se enlaza el form si la URL trae algo para que, al volver de una descarga
# con parámetros mal puestos, se vean los errores en la pantalla.
form = ExportForm(request.GET or None, user=request.user)
return render(
request,
"expenses/export_form.html",
{"active_menu": "settings", "form": form},
)
@login_required
def export_download(request):
form = ExportForm(request.GET, user=request.user)
if not form.is_valid():
return HttpResponseBadRequest("Parámetros de exportación no válidos.")
data = form.cleaned_data
dialect = DIALECTS[data["fmt"]]
stem, build_export = EXPORTS[data["kind"]]
header, rows = build_export(
request.user, data["date_from"], data["date_to"], data["account"], dialect
)
# Streaming y no HttpResponse: con los volúmenes de hoy da igual en memoria,
# pero el coste de código es el mismo.
response = StreamingHttpResponse(
stream_csv(header, rows, dialect),
content_type="text/csv; charset=utf-8",
)
filename = csv_filename(stem, data["date_from"], data["date_to"])
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response