Compare commits

...

5 Commits

Author SHA1 Message Date
4077a443d1 Merge pull request 'Exportado de gastos' (#40) from dev into main
All checks were successful
Tests / CI / Run tests passed: 247
expenses_manager/pipeline/head This commit looks good
Reviewed-on: #40
2026-10-01 13:28:46 +00:00
d0c257da87 Merge branch 'main' into dev
All checks were successful
Tests / CI / Run tests passed: 247
expenses_manager/pipeline/head This commit looks good
2026-10-01 13:28:33 +00:00
68fdb5a644 Add tests for CSV export
All checks were successful
Tests / CI / Run tests passed: 247
expenses_manager/pipeline/head This commit looks good
2026-10-01 15:10:38 +02:00
dfebee1a09 Add CSV export screen for expenses, incomes and transfers 2026-10-01 15:10:27 +02:00
119b70f939 Add CSV export dialects and streaming helpers 2026-10-01 15:10:16 +02:00
8 changed files with 890 additions and 1 deletions

View File

@ -0,0 +1,201 @@
"""Dialectos CSV, formateo de celdas y volcado de filas en streaming.
Va en módulo propio y no en views.py (ya anotado como deuda por tamaño): así el
dialecto y el formateo se prueban sin levantar un cliente HTTP.
"""
import csv
from dataclasses import dataclass
from datetime import date as date_cls
from django.db.models import Q
from .models import Expense, Income, Transfer
class _Echo:
"""Objeto tipo fichero que devuelve lo que le escriben, para csv.writer."""
def write(self, value):
return value
@dataclass(frozen=True)
class CsvDialect:
delimiter: str
decimal_sep: str
date_format: str
bool_true: str
bool_false: str
bom: bool
# Abre con doble clic en el Excel español.
EXCEL_ES = CsvDialect(";", ",", "%d/%m/%Y", "Sí", "No", bom=True)
# Salida RFC-4180 para pandas o para reimportar.
STANDARD = CsvDialect(",", ".", "%Y-%m-%d", "true", "false", bom=False)
DIALECTS = {"excel": EXCEL_ES, "std": STANDARD}
DEFAULT_DIALECT = "excel"
def fmt_amount(value, dialect):
"""Decimal -> texto plano, sin separador de miles a propósito.
El formateo por locale de Django metería "1.234,56" y partiría la fila en
dos en el dialecto de Excel. Aquí no se usa floatformat ni
django.utils.formats. None sale como celda vacía: "no hay comisión" no es
lo mismo que una comisión de cero.
"""
if value is None:
return ""
return f"{value:.2f}".replace(".", dialect.decimal_sep)
def fmt_date(value, dialect):
return value.strftime(dialect.date_format) if value else ""
def fmt_bool(value, dialect):
return dialect.bool_true if value else dialect.bool_false
def stream_csv(header, rows, dialect):
"""Va soltando el CSV fila a fila, para StreamingHttpResponse."""
writer = csv.writer(_Echo(), delimiter=dialect.delimiter, quoting=csv.QUOTE_MINIMAL)
# El BOM se emite como texto y lo codifica la respuesta con su charset.
if dialect.bom:
yield ""
yield writer.writerow(header)
for row in rows:
yield writer.writerow(row)
def csv_filename(stem, date_from, date_to):
"""gastos_2026-01-01_2026-10-01.csv, o con la fecha de hoy si no hay rango."""
span = [d.isoformat() for d in (date_from, date_to) if d]
return "_".join([stem, *(span or [date_cls.today().isoformat()])]) + ".csv"
def _by_date(queryset, date_from, date_to):
"""Recorta el rango y ordena.
El desempate por id no es adorno: un order_by solo por fecha puede salir en
distinto orden en SQLite local y en el PostgreSQL del NAS.
"""
if date_from:
queryset = queryset.filter(date__gte=date_from)
if date_to:
queryset = queryset.filter(date__lte=date_to)
return queryset.order_by("date", "id")
# chunk_size es obligatorio al combinar iterator() con prefetch_related: sin él
# el prefetch se ignora y las etiquetas saldrían vacías o con un N+1.
CHUNK = 2000
def expenses_export(user, date_from, date_to, account, dialect):
queryset = (
Expense.objects.filter(owner=user)
.select_related("account", "category", "category__parent")
.prefetch_related("tags")
)
if account:
queryset = queryset.filter(account=account)
header = [
"Fecha",
"Importe",
"Categoría",
"Categoría padre",
"Cuenta",
"Concepto",
"Etiquetas",
"Traspaso",
]
def rows():
for expense in _by_date(queryset, date_from, date_to).iterator(
chunk_size=CHUNK
):
category = expense.category
yield [
fmt_date(expense.date, dialect),
fmt_amount(expense.amount, dialect),
category.name,
# La categoría padre sale del JOIN, no recorriendo la jerarquía
# por fila. Si algún día se quiere la ruta completa a cualquier
# profundidad, eso es un dict en memoria, nunca más SQL.
category.parent.name if category.parent else "",
expense.account.name,
expense.description,
# Unidas con | porque la coma y el punto y coma son
# delimitadores en uno u otro dialecto.
"|".join(tag.name for tag in expense.tags.all()),
fmt_bool(expense.is_transfer, dialect),
]
return header, rows()
def incomes_export(user, date_from, date_to, account, dialect):
queryset = Income.objects.filter(owner=user).select_related("account")
if account:
queryset = queryset.filter(account=account)
header = ["Fecha", "Importe", "Cuenta", "Concepto", "Traspaso"]
def rows():
for income in _by_date(queryset, date_from, date_to).iterator(
chunk_size=CHUNK
):
yield [
fmt_date(income.date, dialect),
fmt_amount(income.amount, dialect),
income.account.name,
income.name,
fmt_bool(income.is_transfer, dialect),
]
return header, rows()
def transfers_export(user, date_from, date_to, account, dialect):
queryset = Transfer.objects.filter(owner=user).select_related(
"from_account", "to_account"
)
# Filtrar por cuenta es "traspasos en los que participa": da igual si entra
# o sale, el movimiento está en el extracto de esa cuenta.
if account:
queryset = queryset.filter(Q(from_account=account) | Q(to_account=account))
header = ["Fecha", "Importe", "Cuenta origen", "Cuenta destino", "Comisión"]
def rows():
for transfer in _by_date(queryset, date_from, date_to).iterator(
chunk_size=CHUNK
):
yield [
fmt_date(transfer.date, dialect),
fmt_amount(transfer.amount, dialect),
transfer.from_account.name,
transfer.to_account.name,
fmt_amount(transfer.fee_amount, dialect),
]
return header, rows()
# kind -> (nombre de fichero, constructor). Los nombres salen en Descargas, así
# que van en español.
EXPORTS = {
"expenses": ("gastos", expenses_export),
"incomes": ("ingresos", incomes_export),
"transfers": ("traspasos", transfers_export),
}

View File

@ -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

View File

@ -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>

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>
<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 %}

View File

@ -0,0 +1,547 @@
"""Exportación a CSV: dialectos, filtros y aislamiento por usuario.
Lo que más se mira aquí es el dialecto de Excel: el BOM y la coma decimal son
justo lo que hace que el fichero se abra con doble clic en vez de salir todo en
una columna. Las filas se leen siempre con csv.reader y nunca con split(), que
partiría por la mitad cualquier concepto con el delimitador dentro.
"""
import csv
import io
from datetime import date
from decimal import Decimal
import pytest
from django.urls import reverse
from expenses.csv_export import (
EXCEL_ES,
STANDARD,
csv_filename,
fmt_amount,
fmt_bool,
fmt_date,
)
from expenses.models import Account, Category, Expense, Income, Tag, Transfer
pytestmark = pytest.mark.django_db
# --------------------------------------------------------------------------
# Utilidades
# --------------------------------------------------------------------------
def body(response):
"""El cuerpo completo de una StreamingHttpResponse, como texto."""
return b"".join(response.streaming_content).decode("utf-8")
def read_rows(response, delimiter=";"):
text = body(response).lstrip("")
return list(csv.reader(io.StringIO(text), delimiter=delimiter))
def download(client, **params):
params.setdefault("fmt", "excel")
return client.get(reverse("export_download"), params)
@pytest.fixture
def savings(user):
return Account.objects.create(
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
)
def make_transfer(user, from_account, to_account, amount="400", on=None, fee=None):
transfer = Transfer(
owner=user,
date=on or date(2024, 5, 11),
amount=Decimal(amount),
from_account=from_account,
to_account=to_account,
fee_amount=Decimal(fee) if fee is not None else None,
)
transfer.sync()
return transfer
# --------------------------------------------------------------------------
# Formateo (sin base de datos ni HTTP)
# --------------------------------------------------------------------------
def test_excel_dialect_formats_amounts_with_a_comma():
assert fmt_amount(Decimal("1234.56"), EXCEL_ES) == "1234,56"
def test_standard_dialect_formats_amounts_with_a_dot():
assert fmt_amount(Decimal("1234.56"), STANDARD) == "1234.56"
def test_amounts_never_carry_a_thousands_separator():
# Un "1.234,56" partiría la fila en dos en el dialecto de Excel.
assert fmt_amount(Decimal("1234567.80"), EXCEL_ES) == "1234567,80"
def test_amounts_always_keep_two_decimals():
assert fmt_amount(Decimal("5"), EXCEL_ES) == "5,00"
def test_a_missing_amount_is_an_empty_cell_not_a_zero():
assert fmt_amount(None, EXCEL_ES) == ""
def test_dates_follow_the_dialect():
assert fmt_date(date(2026, 1, 31), EXCEL_ES) == "31/01/2026"
assert fmt_date(date(2026, 1, 31), STANDARD) == "2026-01-31"
def test_booleans_follow_the_dialect():
assert fmt_bool(True, EXCEL_ES) == "Sí"
assert fmt_bool(False, EXCEL_ES) == "No"
assert fmt_bool(True, STANDARD) == "true"
assert fmt_bool(False, STANDARD) == "false"
def test_filename_carries_the_range():
name = csv_filename("gastos", date(2026, 1, 1), date(2026, 10, 1))
assert name == "gastos_2026-01-01_2026-10-01.csv"
def test_filename_without_a_range_carries_todays_date():
assert csv_filename("gastos", None, None) == f"gastos_{date.today().isoformat()}.csv"
# --------------------------------------------------------------------------
# Las tres descargas responden
# --------------------------------------------------------------------------
@pytest.mark.parametrize("kind", ["expenses", "incomes", "transfers"])
def test_every_kind_downloads_a_csv(auth_client, kind):
response = download(auth_client, kind=kind)
assert response.status_code == 200
assert response["Content-Type"] == "text/csv; charset=utf-8"
assert ".csv" in response["Content-Disposition"]
def test_the_download_is_an_attachment_with_a_spanish_filename(auth_client):
response = download(auth_client, kind="transfers")
assert "attachment" in response["Content-Disposition"]
assert "traspasos_" in response["Content-Disposition"]
def test_the_export_screen_renders(auth_client):
response = auth_client.get(reverse("export_index"))
assert response.status_code == 200
assert "form" in response.context
def test_the_settings_page_links_to_the_export_screen(auth_client):
# Un {% url %} mal escrito ahí no lo ve manage.py check: revienta en runtime.
response = auth_client.get(reverse("settings_index"))
assert response.status_code == 200
assert reverse("export_index") in response.content.decode()
def test_the_export_screen_needs_a_login(client):
response = client.get(reverse("export_index"))
assert response.status_code == 302
def test_the_download_needs_a_login(client):
response = client.get(reverse("export_download"), {"kind": "expenses"})
assert response.status_code == 302
# --------------------------------------------------------------------------
# Dialectos en la salida real
# --------------------------------------------------------------------------
def test_excel_csv_starts_with_a_bom(auth_client, account, category):
Expense.objects.create(
owner=account.owner,
account=account,
category=category,
amount=Decimal("1234.56"),
date=date(2024, 5, 11),
)
response = download(auth_client, kind="expenses", fmt="excel")
assert body(response).startswith("")
def test_standard_csv_has_no_bom(auth_client):
response = download(auth_client, kind="expenses", fmt="std")
assert not body(response).startswith("")
def test_excel_csv_uses_semicolons_and_decimal_commas(auth_client, account, category):
Expense.objects.create(
owner=account.owner,
account=account,
category=category,
amount=Decimal("1234.56"),
date=date(2024, 5, 11),
)
rows = read_rows(download(auth_client, kind="expenses", fmt="excel"))
assert rows[0][0] == "Fecha"
assert rows[1][0] == "11/05/2024"
assert rows[1][1] == "1234,56"
def test_standard_csv_uses_commas_and_decimal_dots(auth_client, account, category):
Expense.objects.create(
owner=account.owner,
account=account,
category=category,
amount=Decimal("1234.56"),
date=date(2024, 5, 11),
)
rows = read_rows(download(auth_client, kind="expenses", fmt="std"), delimiter=",")
assert rows[1][0] == "2024-05-11"
assert rows[1][1] == "1234.56"
def test_a_concept_with_the_delimiter_inside_stays_in_one_cell(
auth_client, account, category
):
Expense.objects.create(
owner=account.owner,
account=account,
category=category,
amount=Decimal("10"),
date=date(2024, 5, 11),
description="Cena; con punto y coma, y coma",
)
rows = read_rows(download(auth_client, kind="expenses"))
assert rows[1][5] == "Cena; con punto y coma, y coma"
# --------------------------------------------------------------------------
# Columnas
# --------------------------------------------------------------------------
def test_expense_rows_carry_parent_category_and_tags(auth_client, user, account):
parent = Category.objects.create(owner=user, name="Casa")
child = Category.objects.create(owner=user, name="Luz", parent=parent)
expense = Expense.objects.create(
owner=user,
account=account,
category=child,
amount=Decimal("60"),
date=date(2024, 5, 11),
description="Factura",
)
expense.tags.add(
Tag.objects.create(owner=user, name="fijo"),
Tag.objects.create(owner=user, name="hogar"),
)
rows = read_rows(download(auth_client, kind="expenses"))
assert rows[1][2] == "Luz"
assert rows[1][3] == "Casa"
assert rows[1][4] == account.name
# Unidas con | porque la coma y el ; son delimitadores en uno u otro dialecto.
assert sorted(rows[1][6].split("|")) == ["fijo", "hogar"]
def test_a_top_level_category_leaves_the_parent_cell_empty(
auth_client, user, account, category
):
Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("10"),
date=date(2024, 5, 11),
)
rows = read_rows(download(auth_client, kind="expenses"))
assert rows[1][3] == ""
def test_income_rows_carry_account_and_concept(auth_client, user, account):
Income.objects.create(
owner=user,
account=account,
name="Nómina",
amount=Decimal("1500"),
date=date(2024, 5, 11),
)
rows = read_rows(download(auth_client, kind="incomes"))
assert rows[0] == ["Fecha", "Importe", "Cuenta", "Concepto", "Traspaso"]
assert rows[1][2] == account.name
assert rows[1][3] == "Nómina"
def test_transfer_rows_carry_both_accounts(auth_client, user, account, savings):
make_transfer(user, account, savings, amount="400")
rows = read_rows(download(auth_client, kind="transfers"))
assert rows[0] == [
"Fecha",
"Importe",
"Cuenta origen",
"Cuenta destino",
"Comisión",
]
assert rows[1][1] == "400,00"
assert rows[1][2] == account.name
assert rows[1][3] == savings.name
def test_a_transfer_without_a_fee_leaves_the_fee_cell_empty(
auth_client, user, account, savings
):
make_transfer(user, account, savings)
rows = read_rows(download(auth_client, kind="transfers"))
# Vacío y no "0,00": un cero es un dato, y aquí el dato es "no hubo".
assert rows[1][4] == ""
def test_a_transfer_with_a_fee_carries_it(auth_client, user, account, savings):
make_transfer(user, account, savings, fee="1.50")
rows = read_rows(download(auth_client, kind="transfers"))
assert rows[1][4] == "1,50"
# --------------------------------------------------------------------------
# Las patas del traspaso y la comisión: los dos avisos de la pantalla
# --------------------------------------------------------------------------
def test_transfer_legs_also_show_up_flagged_in_the_movement_csvs(
auth_client, user, account, savings
):
make_transfer(user, account, savings, amount="400")
expense_rows = read_rows(download(auth_client, kind="expenses"))
income_rows = read_rows(download(auth_client, kind="incomes"))
# Sumar los tres ficheros duplica: esto es lo que avisa la pantalla.
assert [r[7] for r in expense_rows[1:]] == ["Sí"]
assert [r[4] for r in income_rows[1:]] == ["Sí"]
def test_the_fee_is_exported_as_a_normal_expense(auth_client, user, account, savings):
make_transfer(user, account, savings, amount="400", fee="1.50")
rows = read_rows(download(auth_client, kind="expenses"))
by_amount = {row[1]: row for row in rows[1:]}
# La pata va marcada como traspaso; la comisión no, porque es dinero que
# sale de verdad del patrimonio.
assert by_amount["400,00"][7] == "Sí"
assert by_amount["1,50"][7] == "No"
# --------------------------------------------------------------------------
# Filtros
# --------------------------------------------------------------------------
def test_the_date_range_filters_both_ends(auth_client, user, account, category):
for day in (1, 15, 28):
Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal(day),
date=date(2024, 5, day),
)
rows = read_rows(
download(
auth_client,
kind="expenses",
date_from="2024-05-10",
date_to="2024-05-20",
)
)
assert [row[0] for row in rows[1:]] == ["15/05/2024"]
def test_the_account_filter_narrows_expenses(auth_client, user, account, savings, category):
for target in (account, savings):
Expense.objects.create(
owner=user,
account=target,
category=category,
amount=Decimal("10"),
date=date(2024, 5, 11),
)
rows = read_rows(download(auth_client, kind="expenses", account=account.pk))
assert [row[4] for row in rows[1:]] == [account.name]
def test_the_account_filter_catches_transfers_on_either_side(
auth_client, user, account, savings
):
third = Account.objects.create(
owner=user, name="Bolsillo", initial_balance=Decimal("0"), active=True
)
make_transfer(user, account, savings, amount="100")
make_transfer(user, savings, account, amount="200")
make_transfer(user, savings, third, amount="300")
rows = read_rows(download(auth_client, kind="transfers", account=account.pk))
assert sorted(row[1] for row in rows[1:]) == ["100,00", "200,00"]
def test_rows_come_out_ordered_by_date(auth_client, user, account, category):
for day in (28, 1, 15):
Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("10"),
date=date(2024, 5, day),
)
rows = read_rows(download(auth_client, kind="expenses"))
assert [row[0] for row in rows[1:]] == [
"01/05/2024",
"15/05/2024",
"28/05/2024",
]
def test_an_inactive_account_can_still_be_chosen(auth_client, user, account, category):
# Se exporta histórico: una cuenta cerrada tiene movimientos que interesan.
account.active = False
account.save()
Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("10"),
date=date(2024, 5, 11),
)
rows = read_rows(download(auth_client, kind="expenses", account=account.pk))
assert len(rows) == 2
# --------------------------------------------------------------------------
# Aislamiento por usuario
# --------------------------------------------------------------------------
def test_another_users_data_never_reaches_the_csv(
auth_client, user, account, category, django_user_model
):
intruder = django_user_model.objects.create_user(username="otro", password="x")
their_account = Account.objects.create(
owner=intruder, name="Cuenta ajena", initial_balance=Decimal("0"), active=True
)
their_category = Category.objects.create(owner=intruder, name="Ajena")
Expense.objects.create(
owner=intruder,
account=their_account,
category=their_category,
amount=Decimal("999"),
date=date(2024, 5, 11),
description="No es mío",
)
Income.objects.create(
owner=intruder,
account=their_account,
name="Nómina ajena",
amount=Decimal("888"),
date=date(2024, 5, 11),
)
Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("10"),
date=date(2024, 5, 11),
description="Mío",
)
expenses_csv = body(download(auth_client, kind="expenses"))
incomes_csv = body(download(auth_client, kind="incomes"))
assert "Mío" in expenses_csv
assert "No es mío" not in expenses_csv
assert "999" not in expenses_csv
assert "Nómina ajena" not in incomes_csv
def test_another_users_account_cannot_be_used_as_a_filter(
auth_client, django_user_model
):
intruder = django_user_model.objects.create_user(username="otro", password="x")
their_account = Account.objects.create(
owner=intruder, name="Cuenta ajena", initial_balance=Decimal("0"), active=True
)
response = download(auth_client, kind="expenses", account=their_account.pk)
assert response.status_code == 400
# --------------------------------------------------------------------------
# Parámetros inválidos
# --------------------------------------------------------------------------
def test_an_unknown_kind_is_rejected(auth_client):
assert download(auth_client, kind="everything").status_code == 400
def test_an_unknown_format_is_rejected(auth_client):
assert download(auth_client, kind="expenses", fmt="pdf").status_code == 400
def test_a_broken_date_is_rejected(auth_client):
response = download(auth_client, kind="expenses", date_from="ayer")
assert response.status_code == 400
def test_an_inverted_range_is_rejected(auth_client):
response = download(
auth_client, kind="expenses", date_from="2024-05-20", date_to="2024-05-10"
)
# En un listado un rango invertido se ignora; aquí descargaría un fichero
# vacío sin explicación, así que se corta.
assert response.status_code == 400

View File

@ -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'),

View File

@ -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