feat: implement export functionality for projects and scenarios with CSV and Excel support
This commit is contained in:
2
main.py
2
main.py
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
132
routes/exports.py
Normal 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
69
schemas/exports.py
Normal 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
86
static/css/imports.css
Normal 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
97
static/js/exports.js
Normal 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
100
static/js/imports.js
Normal 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).
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
51
templates/exports/modal.html
Normal file
51
templates/exports/modal.html
Normal 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>
|
||||||
10
templates/partials/alerts.html
Normal file
10
templates/partials/alerts.html
Normal 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 %}
|
||||||
17
templates/partials/import_preview_table.html
Normal file
17
templates/partials/import_preview_table.html
Normal 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 %}
|
||||||
25
templates/partials/import_upload.html
Normal file
25
templates/partials/import_upload.html
Normal 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>
|
||||||
@@ -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
130
tests/test_export_routes.py
Normal 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()
|
||||||
Reference in New Issue
Block a user