Exportado de gastos #40

Merged
jkuijperm merged 4 commits from dev into main 2026-10-01 13:28:47 +00:00
Showing only changes of commit 68fdb5a644 - Show all commits

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