diff --git a/expenses_manager/expenses/csv_export.py b/expenses_manager/expenses/csv_export.py new file mode 100644 index 0000000..abb4032 --- /dev/null +++ b/expenses_manager/expenses/csv_export.py @@ -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), +} 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 %} +
Descarga tus movimientos en CSV para abrirlos en Excel o tratarlos aparte.
+ + + + +{% 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 @@Define y controla tus metas de gasto.
+ + +Descarga tus gastos, ingresos o traspasos en CSV.
+ {% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/tests/test_export.py b/expenses_manager/expenses/tests/test_export.py new file mode 100644 index 0000000..ce4de43 --- /dev/null +++ b/expenses_manager/expenses/tests/test_export.py @@ -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 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/