feat: implement export functionality for projects and scenarios with CSV and Excel support

This commit is contained in:
2025-11-10 18:32:24 +01:00
parent 4b33a5dba3
commit 43b1e53837
15 changed files with 906 additions and 133 deletions

View File

@@ -17,6 +17,7 @@ from models import (
from routes.auth import router as auth_router from routes.auth import router as auth_router
from routes.dashboard import router as dashboard_router from routes.dashboard import router as dashboard_router
from routes.imports import router as imports_router from routes.imports import router as imports_router
from routes.exports import router as exports_router
from routes.projects import router as projects_router from routes.projects import router as projects_router
from routes.scenarios import router as scenarios_router from routes.scenarios import router as scenarios_router
from services.bootstrap import bootstrap_admin from services.bootstrap import bootstrap_admin
@@ -63,6 +64,7 @@ async def ensure_admin_bootstrap() -> None:
app.include_router(dashboard_router) app.include_router(dashboard_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(imports_router) app.include_router(imports_router)
app.include_router(exports_router)
app.include_router(projects_router) app.include_router(projects_router)
app.include_router(scenarios_router) app.include_router(scenarios_router)

View File

@@ -7,8 +7,7 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from dependencies import get_unit_of_work, require_authenticated_user from dependencies import get_unit_of_work, require_authenticated_user
from models import User from models import ScenarioStatus, User
from models import ScenarioStatus
from services.unit_of_work import UnitOfWork from services.unit_of_work import UnitOfWork
router = APIRouter(tags=["Dashboard"]) router = APIRouter(tags=["Dashboard"])
@@ -120,5 +119,9 @@ def dashboard_home(
"recent_projects": _load_recent_projects(uow), "recent_projects": _load_recent_projects(uow),
"simulation_updates": _load_simulation_updates(uow), "simulation_updates": _load_simulation_updates(uow),
"scenario_alerts": _load_scenario_alerts(request, uow), "scenario_alerts": _load_scenario_alerts(request, uow),
"export_modals": {
"projects": request.url_for("exports.modal", dataset="projects"),
"scenarios": request.url_for("exports.modal", dataset="scenarios"),
},
} }
return templates.TemplateResponse(request, "dashboard.html", context) return templates.TemplateResponse(request, "dashboard.html", context)

132
routes/exports.py Normal file
View File

@@ -0,0 +1,132 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from dependencies import get_unit_of_work, require_any_role
from schemas.exports import (
ExportFormat,
ProjectExportRequest,
ScenarioExportRequest,
)
from services.export_serializers import (
export_projects_to_excel,
export_scenarios_to_excel,
stream_projects_to_csv,
stream_scenarios_to_csv,
)
from services.unit_of_work import UnitOfWork
router = APIRouter(prefix="/exports", tags=["exports"])
@router.get(
"/modal/{dataset}",
response_model=None,
response_class=HTMLResponse,
include_in_schema=False,
name="exports.modal",
)
async def export_modal(
dataset: str,
request: Request,
) -> HTMLResponse:
dataset = dataset.lower()
if dataset not in {"projects", "scenarios"}:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Unknown dataset")
submit_url = request.url_for(
"export_projects" if dataset == "projects" else "export_scenarios"
)
templates = Jinja2Templates(directory="templates")
return templates.TemplateResponse(
request,
"exports/modal.html",
{
"dataset": dataset,
"submit_url": submit_url,
},
)
def _timestamp_suffix() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
def _ensure_repository(repo, name: str):
if repo is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"{name} repository unavailable")
return repo
@router.post(
"/projects",
status_code=status.HTTP_200_OK,
response_class=StreamingResponse,
dependencies=[Depends(require_any_role(
"admin", "project_manager", "analyst"))],
)
async def export_projects(
request: ProjectExportRequest,
uow: Annotated[UnitOfWork, Depends(get_unit_of_work)],
) -> Response:
project_repo = _ensure_repository(
getattr(uow, "projects", None), "Project")
projects = project_repo.filtered_for_export(request.filters)
filename = f"projects-{_timestamp_suffix()}"
if request.format == ExportFormat.CSV:
stream = stream_projects_to_csv(projects)
response = StreamingResponse(stream, media_type="text/csv")
response.headers["Content-Disposition"] = f"attachment; filename={filename}.csv"
return response
data = export_projects_to_excel(projects)
return StreamingResponse(
iter([data]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}.xlsx",
},
)
@router.post(
"/scenarios",
status_code=status.HTTP_200_OK,
response_class=StreamingResponse,
dependencies=[Depends(require_any_role(
"admin", "project_manager", "analyst"))],
)
async def export_scenarios(
request: ScenarioExportRequest,
uow: Annotated[UnitOfWork, Depends(get_unit_of_work)],
) -> Response:
scenario_repo = _ensure_repository(
getattr(uow, "scenarios", None), "Scenario")
scenarios = scenario_repo.filtered_for_export(
request.filters, include_project=True)
filename = f"scenarios-{_timestamp_suffix()}"
if request.format == ExportFormat.CSV:
stream = stream_scenarios_to_csv(scenarios)
response = StreamingResponse(stream, media_type="text/csv")
response.headers["Content-Disposition"] = f"attachment; filename={filename}.csv"
return response
data = export_scenarios_to_excel(scenarios)
return StreamingResponse(
iter([data]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}.xlsx",
},
)

69
schemas/exports.py Normal file
View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from enum import Enum
from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator
from services.export_query import ProjectExportFilters, ScenarioExportFilters
class ExportFormat(str, Enum):
CSV = "csv"
XLSX = "xlsx"
class BaseExportRequest(BaseModel):
format: ExportFormat = ExportFormat.CSV
include_metadata: bool = False
model_config = ConfigDict(extra="forbid")
class ProjectExportRequest(BaseExportRequest):
filters: ProjectExportFilters | None = None
@field_validator("filters", mode="before")
@classmethod
def validate_filters(cls, value: ProjectExportFilters | None) -> ProjectExportFilters | None:
if value is None:
return None
if isinstance(value, ProjectExportFilters):
return value
return ProjectExportFilters(**value)
class ScenarioExportRequest(BaseExportRequest):
filters: ScenarioExportFilters | None = None
@field_validator("filters", mode="before")
@classmethod
def validate_filters(cls, value: ScenarioExportFilters | None) -> ScenarioExportFilters | None:
if value is None:
return None
if isinstance(value, ScenarioExportFilters):
return value
return ScenarioExportFilters(**value)
class ExportTicket(BaseModel):
token: str
format: ExportFormat
resource: Literal["projects", "scenarios"]
model_config = ConfigDict(extra="forbid")
class ExportResponse(BaseModel):
ticket: ExportTicket
model_config = ConfigDict(extra="forbid")
__all__ = [
"ExportFormat",
"ProjectExportRequest",
"ScenarioExportRequest",
"ExportTicket",
"ExportResponse",
]

86
static/css/imports.css Normal file
View File

@@ -0,0 +1,86 @@
.import-upload {
background-color: var(--surface-color);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.import-upload__header {
margin-bottom: 1rem;
}
.import-upload__dropzone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-sm);
padding: 2rem;
text-align: center;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.import-upload__dropzone.dragover {
border-color: var(--primary-color);
background-color: rgba(0, 123, 255, 0.05);
}
.import-upload__actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.table-cell-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-ghost {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
color: var(--text-muted);
}
.btn-ghost:hover {
color: var(--primary-color);
}
.toast {
position: fixed;
right: 1rem;
bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-md);
color: #fff;
box-shadow: var(--shadow-lg);
z-index: 1000;
}
.toast.hidden {
display: none;
}
.toast--success {
background-color: #198754;
}
.toast--error {
background-color: #dc3545;
}
.toast--info {
background-color: #0d6efd;
}
.toast__close {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
}

97
static/js/exports.js Normal file
View File

@@ -0,0 +1,97 @@
document.addEventListener("DOMContentLoaded", () => {
const modalContainer = document.createElement("div");
modalContainer.id = "export-modal-container";
document.body.appendChild(modalContainer);
async function loadModal(dataset) {
const response = await fetch(`/exports/modal/${dataset}`);
if (!response.ok) {
throw new Error(`Failed to load export modal (${response.status})`);
}
const html = await response.text();
modalContainer.innerHTML = html;
const modal = modalContainer.querySelector(".modal");
if (!modal) return;
modal.classList.add("is-active");
const closeButtons = modal.querySelectorAll("[data-dismiss='modal']");
closeButtons.forEach((btn) =>
btn.addEventListener("click", () => closeModal(modal))
);
const form = modal.querySelector("[data-export-form]");
if (form) {
form.addEventListener("submit", handleSubmit);
}
}
function closeModal(modal) {
modal.classList.remove("is-active");
setTimeout(() => {
modalContainer.innerHTML = "";
}, 200);
}
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const submitUrl = form.action;
const formData = new FormData(form);
const format = formData.get("format") || "csv";
const response = await fetch(submitUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
format,
include_metadata: formData.get("include_metadata") === "true",
filters: null,
}),
});
if (!response.ok) {
alert("Export failed. Please try again.");
return;
}
const blob = await response.blob();
const disposition = response.headers.get("Content-Disposition");
let filename = "export";
if (disposition) {
const match = disposition.match(/filename=([^;]+)/i);
if (match) {
filename = match[1].replace(/"/g, "");
}
}
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
const modal = modalContainer.querySelector(".modal");
if (modal) {
closeModal(modal);
}
}
document.querySelectorAll("[data-export-trigger]").forEach((button) => {
button.addEventListener("click", async (event) => {
event.preventDefault();
const dataset = button.getAttribute("data-export-target");
if (!dataset) return;
try {
await loadModal(dataset);
} catch (error) {
console.error(error);
alert("Unable to open export dialog.");
}
});
});
});

100
static/js/imports.js Normal file
View File

@@ -0,0 +1,100 @@
document.addEventListener("DOMContentLoaded", () => {
const dropzones = document.querySelectorAll("[data-import-dropzone]");
const uploadButtons = document.querySelectorAll(
"[data-import-upload-trigger]"
);
const resetButtons = document.querySelectorAll("[data-import-reset]");
const feedbackEl = document.querySelector("#import-upload-feedback");
function showFeedback(message, type = "info") {
if (!feedbackEl) return;
feedbackEl.textContent = message;
feedbackEl.classList.remove("hidden", "success", "error", "info");
feedbackEl.classList.add(type);
}
function hideFeedback() {
if (!feedbackEl) return;
feedbackEl.textContent = "";
feedbackEl.classList.add("hidden");
}
dropzones.forEach((zone) => {
const input = zone.querySelector("input[type='file']");
const uploadButton = zone
.closest("[data-import-upload]")
.querySelector("[data-import-upload-trigger]");
const resetButton = zone
.closest("[data-import-upload]")
.querySelector("[data-import-reset]");
function enableUpload() {
if (uploadButton) {
uploadButton.disabled = false;
}
if (resetButton) {
resetButton.hidden = false;
}
}
function disableUpload() {
if (uploadButton) {
uploadButton.disabled = true;
}
if (resetButton) {
resetButton.hidden = true;
}
}
zone.addEventListener("dragover", (event) => {
event.preventDefault();
zone.classList.add("dragover");
});
zone.addEventListener("dragleave", () => {
zone.classList.remove("dragover");
});
zone.addEventListener("drop", (event) => {
event.preventDefault();
zone.classList.remove("dragover");
if (!event.dataTransfer?.files?.length) {
return;
}
input.files = event.dataTransfer.files;
enableUpload();
hideFeedback();
});
input.addEventListener("change", () => {
if (input.files?.length) {
enableUpload();
hideFeedback();
} else {
disableUpload();
}
});
resetButton?.addEventListener("click", () => {
input.value = "";
disableUpload();
hideFeedback();
});
uploadButton?.addEventListener("click", () => {
if (!input.files?.length) {
showFeedback(
"Please select a CSV or XLSX file before uploading.",
"error"
);
return;
}
showFeedback("Uploading…", "info");
uploadButton.disabled = true;
uploadButton.classList.add("loading");
// Actual upload logic handled separately (e.g., fetch).
});
});
});

View File

@@ -1,18 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
{% block title %}Dashboard · CalMiner{% endblock %} block head_extra %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/dashboard.css" /> <link rel="stylesheet" href="/static/css/dashboard.css" />
{% endblock %} {% endblock %} {% block content %}
{% block content %}
<section class="page-header dashboard-header"> <section class="page-header dashboard-header">
<div> <div>
<h1>Welcome back</h1> <h1>Welcome back</h1>
<p class="page-subtitle">Monitor project progress and scenario insights at a glance.</p> <p class="page-subtitle">
Monitor project progress and scenario insights at a glance.
</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<a class="btn primary" href="{{ url_for('projects.create_project_form') }}">New Project</a> <a class="btn primary" href="{{ url_for('projects.create_project_form') }}"
>New Project</a
>
<a class="btn" href="#">Import Data</a> <a class="btn" href="#">Import Data</a>
</div> </div>
</section> </section>
@@ -45,7 +45,11 @@
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
<h2>Recent Projects</h2> <h2>Recent Projects</h2>
<a class="btn btn-link" href="{{ url_for('projects.project_list_page') }}">View all</a> <a
class="btn btn-link"
href="{{ url_for('projects.project_list_page') }}"
>View all</a
>
</header> </header>
{% if recent_projects %} {% if recent_projects %}
<table class="table"> <table class="table">
@@ -59,17 +63,40 @@
<tbody> <tbody>
{% for project in recent_projects %} {% for project in recent_projects %}
<tr> <tr>
<td> <td class="table-cell-actions">
<a class="table-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a> <a
class="table-link"
href="{{ url_for('projects.view_project', project_id=project.id) }}"
>{{ project.name }}</a
>
<button
class="btn btn-ghost"
data-export-trigger
data-export-target="projects"
title="Export projects dataset"
>
<span aria-hidden="true"></span>
<span class="sr-only">Export</span>
</button>
</td>
<td>
{{ project.operation_type.value.replace('_', ' ') | title }}
</td>
<td>
{{ project.updated_at.strftime('%Y-%m-%d') if project.updated_at
else '—' }}
</td> </td>
<td>{{ project.operation_type.value.replace('_', ' ') | title }}</td>
<td>{{ project.updated_at.strftime('%Y-%m-%d') if project.updated_at else '—' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p class="empty-state">No recent projects. <a href="{{ url_for('projects.create_project_form') }}">Create one now.</a></p> <p class="empty-state">
No recent projects.
<a href="{{ url_for('projects.create_project_form') }}"
>Create one now.</a
>
</p>
{% endif %} {% endif %}
</div> </div>
@@ -81,7 +108,9 @@
<ul class="timeline"> <ul class="timeline">
{% for update in simulation_updates %} {% for update in simulation_updates %}
<li> <li>
<span class="timeline-label">{{ update.timestamp_label or '—' }}</span> <span class="timeline-label"
>{{ update.timestamp_label or '—' }}</span
>
<div> <div>
<strong>{{ update.title }}</strong> <strong>{{ update.title }}</strong>
<p>{{ update.description }}</p> <p>{{ update.description }}</p>
@@ -90,7 +119,9 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="empty-state">No simulation runs yet. Configure a scenario to start simulations.</p> <p class="empty-state">
No simulation runs yet. Configure a scenario to start simulations.
</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -109,11 +140,22 @@
{% if alert.link %} {% if alert.link %}
<a class="btn btn-link" href="{{ alert.link }}">Review</a> <a class="btn btn-link" href="{{ alert.link }}">Review</a>
{% endif %} {% endif %}
<button
class="btn btn-ghost"
data-export-trigger
data-export-target="scenarios"
title="Export scenarios dataset"
>
<span aria-hidden="true"></span>
<span class="sr-only">Export</span>
</button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="empty-state">All scenarios look good. We'll highlight issues here.</p> <p class="empty-state">
All scenarios look good. We'll highlight issues here.
</p>
{% endif %} {% endif %}
</div> </div>
@@ -122,8 +164,12 @@
<h2>Resources</h2> <h2>Resources</h2>
</header> </header>
<ul class="links-list"> <ul class="links-list">
<li><a href="https://github.com/" target="_blank">CalMiner Repository</a></li> <li>
<li><a href="https://example.com/docs" target="_blank">Documentation</a></li> <a href="https://github.com/" target="_blank">CalMiner Repository</a>
</li>
<li>
<a href="https://example.com/docs" target="_blank">Documentation</a>
</li>
<li><a href="mailto:support@example.com">Contact Support</a></li> <li><a href="mailto:support@example.com">Contact Support</a></li>
</ul> </ul>
</div> </div>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}CalMiner{% endblock %}</title> <title>{% block title %}CalMiner{% endblock %}</title>
<link rel="stylesheet" href="/static/css/main.css" /> <link rel="stylesheet" href="/static/css/main.css" />
<link rel="stylesheet" href="/static/css/imports.css" />
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
<body> <body>
@@ -21,6 +22,8 @@
</div> </div>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<script src="/static/js/projects.js" defer></script> <script src="/static/js/projects.js" defer></script>
<script src="/static/js/exports.js" defer></script>
<script src="/static/js/imports.js" defer></script>
<script src="/static/js/theme.js"></script> <script src="/static/js/theme.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,51 @@
<div
class="modal"
id="export-modal-{{ dataset }}"
data-export-dataset="{{ dataset }}"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Export {{ dataset|capitalize }}</h5>
<button
type="button"
class="btn-close"
data-dismiss="modal"
aria-label="Close"
></button>
</div>
<form method="post" action="{{ submit_url }}" data-export-form>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="export-format">Format</label>
<select class="form-select" id="export-format" name="format">
<option value="csv">CSV</option>
<option value="xlsx">Excel (.xlsx)</option>
</select>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="true"
id="include-metadata"
name="include_metadata"
/>
<label class="form-check-label" for="include-metadata">
Include metadata sheet (Excel only)
</label>
</div>
<small class="form-text text-muted"
>Filters can be adjusted in the advanced export section.</small
>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
Cancel
</button>
<button type="submit" class="btn btn-primary">Download</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,10 @@
{% macro toast(id, hidden=True, level="info", message="") %}
<div id="{{ id }}" class="toast toast--{{ level }}{% if hidden %} hidden{% endif %}" role="alert">
<span class="toast__icon" aria-hidden="true"></span>
<p class="toast__message">{{ message }}</p>
<button type="button" class="toast__close" data-toast-close>
<span aria-hidden="true">×</span>
<span class="sr-only">Dismiss</span>
</button>
</div>
{% endmacro %}

View File

@@ -0,0 +1,17 @@
{% from "partials/components.html" import table_container %}
{% call table_container("import-preview-container", hidden=True, aria_label="Import preview table", heading=table_heading or "Preview Rows") %}
<thead>
<tr>
<th scope="col">Row</th>
<th scope="col">Status</th>
<th scope="col">Issues</th>
{% for column in columns %}
<th scope="col">{{ column }}</th>
{% endfor %}
</tr>
</thead>
<tbody data-import-preview-body>
<!-- Rows injected via JavaScript -->
</tbody>
{% endcall %}

View File

@@ -0,0 +1,25 @@
{% from "partials/components.html" import feedback %}
<section class="import-upload" data-import-upload>
<header class="import-upload__header">
<h3>{{ title or "Bulk Import" }}</h3>
{% if description %}<p class="import-upload__description">{{ description }}</p>{% endif %}
</header>
<div class="import-upload__dropzone" data-import-dropzone>
<span class="icon-upload" aria-hidden="true"></span>
<p>Drag & drop CSV/XLSX files here or</p>
<label class="btn secondary">
Browse
<input type="file" name="import-file" accept=".csv,.xlsx" hidden />
</label>
<p class="import-upload__hint">Maximum size {{ max_size_hint or "10 MB" }}. UTF-8 encoding required.</p>
</div>
<div class="import-upload__actions">
<button type="button" class="btn primary" data-import-upload-trigger disabled>Upload & Preview</button>
<button type="button" class="btn" data-import-reset hidden>Reset</button>
</div>
{{ feedback("import-upload-feedback", hidden=True, role="alert") }}
</section>

View File

@@ -18,6 +18,7 @@ from routes.dashboard import router as dashboard_router
from routes.projects import router as projects_router from routes.projects import router as projects_router
from routes.scenarios import router as scenarios_router from routes.scenarios import router as scenarios_router
from routes.imports import router as imports_router from routes.imports import router as imports_router
from routes.exports import router as exports_router
from services.importers import ImportIngestionService from services.importers import ImportIngestionService
from services.unit_of_work import UnitOfWork from services.unit_of_work import UnitOfWork
from services.session import AuthSession, SessionTokens from services.session import AuthSession, SessionTokens
@@ -54,6 +55,7 @@ def app(session_factory: sessionmaker) -> FastAPI:
application.include_router(projects_router) application.include_router(projects_router)
application.include_router(scenarios_router) application.include_router(scenarios_router)
application.include_router(imports_router) application.include_router(imports_router)
application.include_router(exports_router)
def _override_uow() -> Iterator[UnitOfWork]: def _override_uow() -> Iterator[UnitOfWork]:
with UnitOfWork(session_factory=session_factory) as uow: with UnitOfWork(session_factory=session_factory) as uow:

130
tests/test_export_routes.py Normal file
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import csv
from io import BytesIO, StringIO
from zipfile import ZipFile
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from models import Project, Scenario, ScenarioStatus
from services.unit_of_work import UnitOfWork
def _seed_projects(session: Session) -> None:
project = Project(name="Alpha", operation_type="open_pit")
project.updated_at = project.created_at
session.add(project)
session.commit()
def _seed_scenarios(session: Session, project: Project) -> Scenario:
scenario = Scenario(
name="Scenario A",
project_id=project.id,
status=ScenarioStatus.ACTIVE,
)
session.add(scenario)
session.commit()
session.refresh(scenario)
return scenario
def test_projects_export_modal(client: TestClient) -> None:
response = client.get("/exports/modal/projects")
assert response.status_code == 200
assert "Export Projects" in response.text
def test_scenarios_export_modal(client: TestClient) -> None:
response = client.get("/exports/modal/scenarios")
assert response.status_code == 200
assert "Export Scenarios" in response.text
def test_project_export_csv(client: TestClient, unit_of_work_factory) -> None:
with unit_of_work_factory() as uow:
project = Project(name="CSV Project", operation_type="open_pit")
uow.projects.create(project)
response = client.post(
"/exports/projects",
json={"format": "csv"},
)
assert response.status_code == 200
assert response.headers["Content-Type"].startswith("text/csv")
assert "attachment; filename=" in response.headers["Content-Disposition"]
content = response.content.decode("utf-8")
reader = csv.reader(StringIO(content))
rows = list(reader)
assert rows[0][:3] == ["name", "location", "operation_type"]
assert any(row[0] == "CSV Project" for row in rows[1:])
def test_project_export_excel(client: TestClient, unit_of_work_factory) -> None:
with unit_of_work_factory() as uow:
project = Project(name="XLSX Project", operation_type="open_pit")
uow.projects.create(project)
response = client.post(
"/exports/projects",
json={"format": "xlsx"},
)
assert response.status_code == 200
assert response.headers["Content-Type"].startswith(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
with ZipFile(BytesIO(response.content)) as archive:
assert "xl/workbook.xml" in archive.namelist()
def test_scenario_export_csv(client: TestClient, unit_of_work_factory) -> None:
with unit_of_work_factory() as uow:
project = Project(name="Scenario Project", operation_type="open_pit")
uow.projects.create(project)
scenario = Scenario(
name="Scenario CSV",
project_id=project.id,
status=ScenarioStatus.ACTIVE,
)
uow.scenarios.create(scenario)
response = client.post(
"/exports/scenarios",
json={"format": "csv"},
)
assert response.status_code == 200
reader = csv.reader(StringIO(response.content.decode("utf-8")))
rows = list(reader)
assert rows[0][0] == "project_name"
assert any(row[0] == "Scenario Project" for row in rows[1:])
def test_scenario_export_excel(client: TestClient, unit_of_work_factory) -> None:
with unit_of_work_factory() as uow:
project = Project(name="Scenario Excel", operation_type="open_pit")
uow.projects.create(project)
scenario = Scenario(
name="Scenario XLSX",
project_id=project.id,
status=ScenarioStatus.ACTIVE,
)
uow.scenarios.create(scenario)
response = client.post(
"/exports/scenarios",
json={"format": "xlsx"},
)
assert response.status_code == 200
assert response.headers["Content-Type"].startswith(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
with ZipFile(BytesIO(response.content)) as archive:
assert "xl/workbook.xml" in archive.namelist()