feat: implement governed reporting vertical
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.dataflows import dataflow_dataset_output
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_reporting.backend.definitions import (
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
ReportingDefinitionError,
|
||||
create_definition,
|
||||
definition_history,
|
||||
get_definition,
|
||||
list_definitions,
|
||||
update_definition,
|
||||
)
|
||||
from govoplan_reporting.backend.execution import (
|
||||
QUALITY_SCOPE,
|
||||
RUN_SCOPE,
|
||||
ReportingExecutionError,
|
||||
ReportingExecutionFailure,
|
||||
execute_report,
|
||||
get_execution,
|
||||
list_executions,
|
||||
run_quality_plan,
|
||||
)
|
||||
from govoplan_reporting.backend.operations import (
|
||||
IMPORT_SCOPE,
|
||||
PUBLISH_SCOPE,
|
||||
SCHEDULE_SCOPE,
|
||||
ReportingOperationError,
|
||||
assess_import,
|
||||
delete_saved_view,
|
||||
dispatch_due_schedules,
|
||||
export_execution,
|
||||
list_import_assessments,
|
||||
list_saved_views,
|
||||
list_schedules,
|
||||
publish_execution,
|
||||
upsert_saved_view,
|
||||
upsert_schedule,
|
||||
)
|
||||
from govoplan_reporting.backend.query_engine import ReportingQueryError
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
DefinitionUpdateRequest,
|
||||
DefinitionWriteRequest,
|
||||
ImportAssessmentRequest,
|
||||
PublicationRequest,
|
||||
QualityRunRequest,
|
||||
ReportExecutionRequest,
|
||||
SavedViewWriteRequest,
|
||||
ScheduleWriteRequest,
|
||||
)
|
||||
|
||||
|
||||
def create_router(registry: object | None) -> APIRouter:
|
||||
router = APIRouter(prefix="/reporting", tags=["reporting"])
|
||||
|
||||
@router.get("/definitions")
|
||||
def api_list_definitions(
|
||||
definition_kind: list[str] | None = Query(default=None),
|
||||
definition_status: list[str] | None = Query(default=None, alias="status"),
|
||||
query: str = "",
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
records, total = list_definitions(
|
||||
session,
|
||||
principal,
|
||||
definition_kinds=definition_kind,
|
||||
statuses=definition_status,
|
||||
query=query,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
except ReportingDefinitionError as exc:
|
||||
raise _error(exc) from exc
|
||||
return {
|
||||
"definitions": [item.to_dict() for item in records],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@router.post(
|
||||
"/definitions",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_definition(
|
||||
payload: DefinitionWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
try:
|
||||
record = create_definition(
|
||||
session,
|
||||
principal,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
except (ReportingDefinitionError, ValueError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return record.to_dict()
|
||||
|
||||
@router.get("/definitions/{definition_kind}/{definition_id}")
|
||||
def api_get_definition(
|
||||
definition_kind: str,
|
||||
definition_id: str,
|
||||
revision: int | None = Query(default=None, ge=1),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind=definition_kind,
|
||||
definition_id=definition_id,
|
||||
revision=revision,
|
||||
)
|
||||
except ReportingDefinitionError as exc:
|
||||
raise _error(exc) from exc
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Reporting definition not found"
|
||||
)
|
||||
return record.to_dict()
|
||||
|
||||
@router.patch("/definitions/{definition_kind}/{definition_id}")
|
||||
def api_update_definition(
|
||||
definition_kind: str,
|
||||
definition_id: str,
|
||||
payload: DefinitionUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
changes = payload.model_dump(
|
||||
exclude={
|
||||
"expected_revision",
|
||||
"recorded_at",
|
||||
"change_reason",
|
||||
"idempotency_key",
|
||||
},
|
||||
exclude_unset=True,
|
||||
)
|
||||
try:
|
||||
record = update_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind=definition_kind,
|
||||
definition_id=definition_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
changes=changes,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ReportingDefinitionError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return record.to_dict()
|
||||
|
||||
@router.get("/definitions/{definition_kind}/{definition_id}/history")
|
||||
def api_definition_history(
|
||||
definition_kind: str,
|
||||
definition_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
records = definition_history(
|
||||
session,
|
||||
principal,
|
||||
definition_kind=definition_kind,
|
||||
definition_id=definition_id,
|
||||
limit=limit,
|
||||
)
|
||||
if not records:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Reporting definition not found"
|
||||
)
|
||||
return {"revisions": [item.to_dict() for item in records]}
|
||||
|
||||
@router.get("/sources/dataflow")
|
||||
def api_dataflow_sources(
|
||||
query: str = "",
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
provider = dataflow_dataset_output(registry)
|
||||
if provider is None:
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "The Dataflow dataset provider is not enabled.",
|
||||
"sources": [],
|
||||
}
|
||||
sources = provider.list_outputs(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"available": True,
|
||||
"reason": None,
|
||||
"sources": [
|
||||
{
|
||||
"pipeline_ref": item.pipeline_ref,
|
||||
"name": item.name,
|
||||
"description": item.description,
|
||||
"revision": item.revision,
|
||||
"definition_hash": item.definition_hash,
|
||||
"status": item.status,
|
||||
"updated_at": item.updated_at.isoformat()
|
||||
if item.updated_at
|
||||
else None,
|
||||
"parameters": dict(item.parameters),
|
||||
"provenance": dict(item.provenance),
|
||||
}
|
||||
for item in sources
|
||||
],
|
||||
}
|
||||
|
||||
@router.post("/reports/{report_id}/executions")
|
||||
def api_execute_report(
|
||||
report_id: str,
|
||||
payload: ReportExecutionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
result = execute_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
report_id=report_id,
|
||||
report_revision=payload.report_revision,
|
||||
parameters=payload.parameters,
|
||||
query=payload.query,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except ReportingExecutionFailure as exc:
|
||||
session.commit()
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"message": str(exc), "execution_id": exc.execution_id},
|
||||
) from exc
|
||||
except (
|
||||
ReportingExecutionError,
|
||||
ReportingQueryError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/reports/{report_id}/executions")
|
||||
def api_list_executions(
|
||||
report_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
return {
|
||||
"executions": list(
|
||||
list_executions(session, principal, report_id=report_id, limit=limit)
|
||||
)
|
||||
}
|
||||
|
||||
@router.get("/executions/{execution_id}")
|
||||
def api_get_execution(
|
||||
execution_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
result = get_execution(session, principal, execution_id=execution_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Reporting execution not found")
|
||||
return result
|
||||
|
||||
@router.get("/executions/{execution_id}/export")
|
||||
def api_export_execution(
|
||||
execution_id: str,
|
||||
format: str = Query(default="csv", pattern="^(csv|json)$"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> Response:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
content, media_type, filename = export_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
format=format,
|
||||
)
|
||||
except (ReportingOperationError, LookupError) as exc:
|
||||
raise _error(exc) from exc
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@router.post("/executions/{execution_id}/publications")
|
||||
def api_publish_execution(
|
||||
execution_id: str,
|
||||
payload: PublicationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, PUBLISH_SCOPE)
|
||||
try:
|
||||
result = publish_execution(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=execution_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
except (ReportingOperationError, PermissionError, LookupError) as exc:
|
||||
session.commit()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/reports/{report_id}/saved-views")
|
||||
def api_list_saved_views(
|
||||
report_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
return {
|
||||
"views": list(list_saved_views(session, principal, report_id=report_id))
|
||||
}
|
||||
|
||||
@router.put("/reports/{report_id}/saved-views/{view_id}")
|
||||
def api_upsert_saved_view(
|
||||
report_id: str,
|
||||
view_id: str,
|
||||
payload: SavedViewWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
if view_id != payload.view_id:
|
||||
raise HTTPException(status_code=400, detail="Saved-view identifiers differ")
|
||||
try:
|
||||
result = upsert_saved_view(
|
||||
session,
|
||||
principal,
|
||||
report_id=report_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
except (ReportingOperationError, PermissionError, LookupError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.delete("/saved-views/{view_id}", status_code=204)
|
||||
def api_delete_saved_view(
|
||||
view_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> Response:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
deleted = delete_saved_view(session, principal, view_id=view_id)
|
||||
session.commit()
|
||||
except PermissionError as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Saved view not found")
|
||||
return Response(status_code=204)
|
||||
|
||||
@router.get("/schedules")
|
||||
def api_list_schedules(
|
||||
report_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, SCHEDULE_SCOPE)
|
||||
return {
|
||||
"schedules": list(list_schedules(session, principal, report_id=report_id))
|
||||
}
|
||||
|
||||
@router.put("/schedules/{schedule_id}")
|
||||
def api_upsert_schedule(
|
||||
schedule_id: str,
|
||||
payload: ScheduleWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, SCHEDULE_SCOPE)
|
||||
if schedule_id != payload.schedule_id:
|
||||
raise HTTPException(status_code=400, detail="Schedule identifiers differ")
|
||||
try:
|
||||
result = upsert_schedule(session, principal, **payload.model_dump())
|
||||
session.commit()
|
||||
except (ReportingOperationError, PermissionError, LookupError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.post("/schedules/dispatch")
|
||||
def api_dispatch_schedules(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, SCHEDULE_SCOPE)
|
||||
result = dispatch_due_schedules(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
now=None,
|
||||
limit=limit,
|
||||
)
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
@router.post("/quality-plans/{quality_plan_id}/runs")
|
||||
def api_run_quality_plan(
|
||||
quality_plan_id: str,
|
||||
payload: QualityRunRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, QUALITY_SCOPE)
|
||||
try:
|
||||
result = run_quality_plan(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
quality_plan_id=quality_plan_id,
|
||||
quality_plan_revision=payload.quality_plan_revision,
|
||||
parameters=payload.parameters,
|
||||
)
|
||||
session.commit()
|
||||
except (ReportingExecutionError, PermissionError, LookupError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.post("/imports/assessments", status_code=201)
|
||||
def api_assess_import(
|
||||
payload: ImportAssessmentRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, IMPORT_SCOPE)
|
||||
try:
|
||||
result = assess_import(session, principal, **payload.model_dump())
|
||||
session.commit()
|
||||
except (ReportingOperationError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/imports/assessments")
|
||||
def api_list_import_assessments(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, IMPORT_SCOPE)
|
||||
return {
|
||||
"assessments": list(
|
||||
list_import_assessments(session, principal, limit=limit)
|
||||
)
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _error(exc: Exception) -> HTTPException:
|
||||
message = str(exc)
|
||||
lowered = message.casefold()
|
||||
if isinstance(exc, LookupError):
|
||||
code = 404
|
||||
elif isinstance(exc, PermissionError):
|
||||
code = 403
|
||||
elif any(word in lowered for word in ("conflict", "already", "stale")):
|
||||
code = 409
|
||||
elif "unavailable" in lowered or "not enabled" in lowered:
|
||||
code = 503
|
||||
else:
|
||||
code = 400
|
||||
return HTTPException(status_code=code, detail=message)
|
||||
|
||||
|
||||
__all__ = ["create_router"]
|
||||
Reference in New Issue
Block a user