diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index d5f6ae9..0cfdb77 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -1,4 +1,5 @@ from django import forms +from .csv_export import DEFAULT_DIALECT from .models import ( Expense, Category, @@ -233,5 +234,66 @@ class GoalForm(forms.ModelForm): ) else: 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 \ No newline at end of file diff --git a/expenses_manager/expenses/templates/expenses/base.html b/expenses_manager/expenses/templates/expenses/base.html index 0da53aa..6c24746 100644 --- a/expenses_manager/expenses/templates/expenses/base.html +++ b/expenses_manager/expenses/templates/expenses/base.html @@ -43,6 +43,7 @@ Categorías Etiquetas Objetivos + Exportar datos diff --git a/expenses_manager/expenses/templates/expenses/export_form.html b/expenses_manager/expenses/templates/expenses/export_form.html new file mode 100644 index 0000000..d1efec2 --- /dev/null +++ b/expenses_manager/expenses/templates/expenses/export_form.html @@ -0,0 +1,30 @@ +{% extends "expenses/base.html" %} + +{% block title %}Exportar datos{% endblock %} + +{% block content %} +

Exportar datos

+ +

Descarga tus movimientos en CSV para abrirlos en Excel o tratarlos aparte.

+ +
+
+ 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. +
+
+ 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. +
+
+ +
+ {% include "expenses/_form_fields.html" %} + +
+ + Volver +
+
+{% endblock %} diff --git a/expenses_manager/expenses/templates/settings/index.html b/expenses_manager/expenses/templates/settings/index.html index 9b9e81e..29eaf4e 100644 --- a/expenses_manager/expenses/templates/settings/index.html +++ b/expenses_manager/expenses/templates/settings/index.html @@ -24,6 +24,11 @@

Objetivos

Define y controla tus metas de gasto.

+ + +

Exportar datos

+

Descarga tus gastos, ingresos o traspasos en CSV.

+
{% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py index fbb07fa..252b3d7 100644 --- a/expenses_manager/expenses/urls.py +++ b/expenses_manager/expenses/urls.py @@ -33,6 +33,8 @@ urlpatterns = [ path('categories//edit/', views.category_edit, name='category_edit'), path('categories//delete/', views.category_delete, name='category_delete'), path('settings/', views.settings_index, name='settings_index'), + path('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/new/', views.goal_create, name='goal_create'), path('goals//edit/', views.goal_edit, name='goal_edit'), diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 5e2a7c0..560d191 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -23,7 +23,9 @@ from .forms import ( CategoryForm, GoalForm, TransferForm, + ExportForm, ) +from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv from django.core.paginator import Paginator 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.http import HttpResponseBadRequest, StreamingHttpResponse from django.utils.http import url_has_allowed_host_and_scheme from django.shortcuts import get_object_or_404, render, redirect from django.urls import reverse @@ -1667,3 +1670,41 @@ def transfer_delete(request, pk): "transfers/confirm_delete.html", {"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