Exportado de gastos #40
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 django import forms
|
||||||
|
from .csv_export import DEFAULT_DIALECT
|
||||||
from .models import (
|
from .models import (
|
||||||
Expense,
|
Expense,
|
||||||
Category,
|
Category,
|
||||||
@ -233,5 +234,66 @@ class GoalForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cleaned["period"] = Goal.PERIOD_NONE
|
cleaned["period"] = Goal.PERIOD_NONE
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class ExportForm(forms.Form):
|
||||||
|
"""Filtros de la exportación a CSV. Valida la pantalla y la descarga.
|
||||||
|
|
||||||
|
Los filtros son a propósito solo rango de fechas y cuenta: la gracia de un
|
||||||
|
CSV es filtrar fuera, y duplicar aquí la fila de filtros avanzados de
|
||||||
|
expense_list serían dos UIs que divergen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
KIND_CHOICES = [
|
||||||
|
("expenses", "Gastos"),
|
||||||
|
("incomes", "Ingresos"),
|
||||||
|
("transfers", "Traspasos"),
|
||||||
|
]
|
||||||
|
FMT_CHOICES = [
|
||||||
|
("excel", "Excel español — separador ; y decimales con coma"),
|
||||||
|
("std", "CSV estándar — separador , y decimales con punto"),
|
||||||
|
]
|
||||||
|
|
||||||
|
kind = forms.ChoiceField(choices=KIND_CHOICES, label="Qué exportar")
|
||||||
|
date_from = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
label="Desde",
|
||||||
|
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
|
||||||
|
)
|
||||||
|
date_to = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
label="Hasta",
|
||||||
|
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
|
||||||
|
)
|
||||||
|
account = forms.ModelChoiceField(
|
||||||
|
queryset=Account.objects.none(),
|
||||||
|
required=False,
|
||||||
|
label="Cuenta",
|
||||||
|
empty_label="Todas las cuentas",
|
||||||
|
)
|
||||||
|
fmt = forms.ChoiceField(
|
||||||
|
choices=FMT_CHOICES, label="Formato", initial=DEFAULT_DIALECT
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop("user")
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Activas e inactivas: se exporta histórico, y una cuenta cerrada tiene
|
||||||
|
# movimientos que interesan.
|
||||||
|
self.fields["account"].queryset = Account.objects.filter(owner=user)
|
||||||
|
self.fields["date_from"].input_formats = ["%Y-%m-%d"]
|
||||||
|
self.fields["date_to"].input_formats = ["%Y-%m-%d"]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
date_from, date_to = cleaned.get("date_from"), cleaned.get("date_to")
|
||||||
|
|
||||||
|
# En un listado un rango invertido se ignora; aquí no, porque el
|
||||||
|
# resultado sería un fichero vacío descargado sin explicación.
|
||||||
|
if date_from and date_to and date_from > date_to:
|
||||||
|
self.add_error("date_to", "La fecha final es anterior a la inicial.")
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
@ -43,6 +43,7 @@
|
|||||||
<a href="{% url 'category_list' %}">Categorías</a>
|
<a href="{% url 'category_list' %}">Categorías</a>
|
||||||
<a href="{% url 'tag_list' %}">Etiquetas</a>
|
<a href="{% url 'tag_list' %}">Etiquetas</a>
|
||||||
<a href="{% url 'goal_list' %}">Objetivos</a>
|
<a href="{% url 'goal_list' %}">Objetivos</a>
|
||||||
|
<a href="{% url 'export_index' %}">Exportar datos</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
{% extends "expenses/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Exportar datos{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Exportar datos</h1>
|
||||||
|
|
||||||
|
<p>Descarga tus movimientos en CSV para abrirlos en Excel o tratarlos aparte.</p>
|
||||||
|
|
||||||
|
<div class="messages">
|
||||||
|
<div class="message warning">
|
||||||
|
Los dos movimientos de cada traspaso también salen en el CSV de gastos y
|
||||||
|
en el de ingresos, marcados con «Traspaso = Sí». Sumar los tres ficheros
|
||||||
|
cuenta el mismo dinero dos veces.
|
||||||
|
</div>
|
||||||
|
<div class="message warning">
|
||||||
|
La comisión de un traspaso sale en el CSV de gastos como gasto normal
|
||||||
|
(«Traspaso = No»), porque es dinero que sale de verdad del patrimonio.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="{% url 'export_download' %}" class="app-form">
|
||||||
|
{% include "expenses/_form_fields.html" %}
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Descargar CSV</button>
|
||||||
|
<a class="btn btn-secondary" href="{% url 'settings_index' %}">Volver</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@ -24,6 +24,11 @@
|
|||||||
<h2>Objetivos</h2>
|
<h2>Objetivos</h2>
|
||||||
<p>Define y controla tus metas de gasto.</p>
|
<p>Define y controla tus metas de gasto.</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a class="settings-card" href="{% url 'export_index' %}">
|
||||||
|
<h2>Exportar datos</h2>
|
||||||
|
<p>Descarga tus gastos, ingresos o traspasos en CSV.</p>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
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>/edit/', views.category_edit, name='category_edit'),
|
||||||
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
||||||
path('settings/', views.settings_index, name='settings_index'),
|
path('settings/', views.settings_index, name='settings_index'),
|
||||||
|
path('export/', views.export_index, name='export_index'),
|
||||||
|
path('export/download/', views.export_download, name='export_download'),
|
||||||
path('goals/', views.goal_list, name='goal_list'),
|
path('goals/', views.goal_list, name='goal_list'),
|
||||||
path('goals/new/', views.goal_create, name='goal_create'),
|
path('goals/new/', views.goal_create, name='goal_create'),
|
||||||
path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'),
|
path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'),
|
||||||
|
|||||||
@ -23,7 +23,9 @@ from .forms import (
|
|||||||
CategoryForm,
|
CategoryForm,
|
||||||
GoalForm,
|
GoalForm,
|
||||||
TransferForm,
|
TransferForm,
|
||||||
|
ExportForm,
|
||||||
)
|
)
|
||||||
|
from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv
|
||||||
|
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Sum, Count, Min, ProtectedError
|
from django.db.models import Sum, Count, Min, ProtectedError
|
||||||
@ -36,6 +38,7 @@ from django.db.models.functions import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import HttpResponseBadRequest, StreamingHttpResponse
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.shortcuts import get_object_or_404, render, redirect
|
from django.shortcuts import get_object_or_404, render, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -1667,3 +1670,41 @@ def transfer_delete(request, pk):
|
|||||||
"transfers/confirm_delete.html",
|
"transfers/confirm_delete.html",
|
||||||
{"active_menu": "transfers", "transfer": transfer},
|
{"active_menu": "transfers", "transfer": transfer},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def export_index(request):
|
||||||
|
# Se enlaza el form si la URL trae algo para que, al volver de una descarga
|
||||||
|
# con parámetros mal puestos, se vean los errores en la pantalla.
|
||||||
|
form = ExportForm(request.GET or None, user=request.user)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"expenses/export_form.html",
|
||||||
|
{"active_menu": "settings", "form": form},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def export_download(request):
|
||||||
|
form = ExportForm(request.GET, user=request.user)
|
||||||
|
if not form.is_valid():
|
||||||
|
return HttpResponseBadRequest("Parámetros de exportación no válidos.")
|
||||||
|
|
||||||
|
data = form.cleaned_data
|
||||||
|
dialect = DIALECTS[data["fmt"]]
|
||||||
|
stem, build_export = EXPORTS[data["kind"]]
|
||||||
|
header, rows = build_export(
|
||||||
|
request.user, data["date_from"], data["date_to"], data["account"], dialect
|
||||||
|
)
|
||||||
|
|
||||||
|
# Streaming y no HttpResponse: con los volúmenes de hoy da igual en memoria,
|
||||||
|
# pero el coste de código es el mismo.
|
||||||
|
response = StreamingHttpResponse(
|
||||||
|
stream_csv(header, rows, dialect),
|
||||||
|
content_type="text/csv; charset=utf-8",
|
||||||
|
)
|
||||||
|
filename = csv_filename(stem, data["date_from"], data["date_to"])
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user