Add CSV export screen for expenses, incomes and transfers
This commit is contained in:
parent
119b70f939
commit
dfebee1a09
@ -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
|
||||
@ -43,6 +43,7 @@
|
||||
<a href="{% url 'category_list' %}">Categorías</a>
|
||||
<a href="{% url 'tag_list' %}">Etiquetas</a>
|
||||
<a href="{% url 'goal_list' %}">Objetivos</a>
|
||||
<a href="{% url 'export_index' %}">Exportar datos</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 %}
|
||||
@ -24,6 +24,11 @@
|
||||
<h2>Objetivos</h2>
|
||||
<p>Define y controla tus metas de gasto.</p>
|
||||
</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>
|
||||
|
||||
{% endblock %}
|
||||
@ -33,6 +33,8 @@ urlpatterns = [
|
||||
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
|
||||
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
||||
path('settings/', views.settings_index, name='settings_index'),
|
||||
path('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/<int:pk>/edit/', views.goal_edit, name='goal_edit'),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user