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 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
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
<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 %}
|
||||||
@ -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'),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user