feat: implement CSV export functionality with customizable columns and formatters

This commit is contained in:
2025-11-10 15:36:14 +01:00
parent 1a7581cda0
commit 5f183faa63
2 changed files with 347 additions and 119 deletions

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any, Iterable
import pytest
from services.export_serializers import (
CSVExportColumn,
CSVExporter,
default_formatter,
default_project_columns,
default_scenario_columns,
format_date_iso,
format_datetime_utc,
format_decimal,
stream_projects_to_csv,
stream_scenarios_to_csv,
)
@dataclass(slots=True)
class DummyProject:
name: str
location: str | None = None
operation_type: str = "open_pit"
description: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@dataclass(slots=True)
class DummyScenario:
project: DummyProject | None
name: str
status: str = "draft"
start_date: date | None = None
end_date: date | None = None
discount_rate: Decimal | None = None
currency: str | None = None
primary_resource: str | None = None
description: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
def collect_csv_bytes(chunks: Iterable[bytes]) -> list[str]:
return [chunk.decode("utf-8") for chunk in chunks]
def test_csv_exporter_writes_header_and_rows() -> None:
exporter = CSVExporter(
[
CSVExportColumn("Name", "name"),
CSVExportColumn("Location", "location"),
]
)
project = DummyProject(name="Alpha", location="Nevada")
chunks = collect_csv_bytes(exporter.iter_bytes([project]))
assert chunks[0] == "Name,Location\n"
assert chunks[1] == "Alpha,Nevada\n"
def test_csv_exporter_handles_optional_values_and_default_formatter() -> None:
exporter = CSVExporter(
[
CSVExportColumn("Name", "name"),
CSVExportColumn("Description", "description"),
]
)
project = DummyProject(name="Bravo")
chunks = collect_csv_bytes(exporter.iter_bytes([project]))
assert chunks[-1] == "Bravo,\n"
def test_stream_projects_uses_default_columns() -> None:
projects = [
DummyProject(
name="Alpha",
location="Nevada",
operation_type="open_pit",
description="Primary",
created_at=datetime(2025, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2025, 1, 2, tzinfo=timezone.utc),
)
]
chunks = collect_csv_bytes(stream_projects_to_csv(projects))
assert chunks[0].startswith("name,location,operation_type")
assert any("Alpha" in chunk for chunk in chunks)
def test_stream_scenarios_resolves_project_name_accessor() -> None:
project = DummyProject(name="Project X")
scenario = DummyScenario(project=project, name="Scenario A")
chunks = collect_csv_bytes(stream_scenarios_to_csv([scenario]))
assert "Project X" in chunks[-1]
assert "Scenario A" in chunks[-1]
def test_custom_formatter_applies() -> None:
def uppercase(value: Any) -> str:
return str(value).upper() if value is not None else ""
exporter = CSVExporter([
CSVExportColumn("Name", "name", formatter=uppercase),
])
chunks = collect_csv_bytes(
exporter.iter_bytes([DummyProject(name="alpha")]))
assert chunks[-1] == "ALPHA\n"
def test_default_formatter_handles_multiple_types() -> None:
assert default_formatter(None) == ""
assert default_formatter(True) == "true"
assert default_formatter(False) == "false"
assert default_formatter(Decimal("1.234")) == "1.23"
assert default_formatter(
datetime(2025, 1, 1, tzinfo=timezone.utc)).endswith("Z")
assert default_formatter(date(2025, 1, 1)) == "2025-01-01"
def test_format_helpers() -> None:
assert format_date_iso(date(2025, 5, 1)) == "2025-05-01"
assert format_date_iso("not-a-date") == ""
ts = datetime(2025, 5, 1, 12, 0, tzinfo=timezone.utc)
assert format_datetime_utc(ts) == "2025-05-01T12:00:00Z"
assert format_datetime_utc("nope") == ""
assert format_decimal(None) == ""
assert format_decimal(Decimal("12.345")) == "12.35"
assert format_decimal(10) == "10.00"
def test_default_project_columns_includes_required_fields() -> None:
columns = default_project_columns()
headers = [column.header for column in columns]
assert headers[:3] == ["name", "location", "operation_type"]
def test_default_scenario_columns_handles_missing_project() -> None:
scenario = DummyScenario(project=None, name="Orphan Scenario")
exporter = CSVExporter(default_scenario_columns())
chunks = collect_csv_bytes(exporter.iter_bytes([scenario]))
assert chunks[-1].startswith(",Orphan Scenario")
def test_csv_exporter_requires_columns() -> None:
with pytest.raises(ValueError):
CSVExporter([])