Compare commits
3 Commits
c11b8a8589
...
68fdb5a644
| Author | SHA1 | Date | |
|---|---|---|---|
| 68fdb5a644 | |||
| dfebee1a09 | |||
| 119b70f939 |
201
expenses_manager/expenses/csv_export.py
Normal file
201
expenses_manager/expenses/csv_export.py
Normal 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),
|
||||
}
|
||||
@ -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 %}
|
||||
547
expenses_manager/expenses/tests/test_export.py
Normal file
547
expenses_manager/expenses/tests/test_export.py
Normal 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
|
||||
@ -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