Initialize governed Dataflow module

This commit is contained in:
2026-07-28 01:28:17 +02:00
commit 223c008ff3
44 changed files with 6204 additions and 0 deletions

View File

@@ -0,0 +1,264 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_dataflow.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RUN_SCOPE, WRITE_SCOPE
from govoplan_dataflow.backend.schemas import (
PipelineCreateRequest,
PipelineDeleteResponse,
PipelineDraftRequest,
PipelineListResponse,
PipelinePreviewRequest,
PipelinePreviewResponse,
PipelineResponse,
PipelineSqlResponse,
PipelineUpdateRequest,
PipelineValidationResponse,
)
from govoplan_dataflow.backend.service import (
DataflowConflictError,
DataflowError,
DataflowNotFoundError,
DataflowValidationError,
compile_sql_draft,
create_pipeline,
delete_pipeline,
get_pipeline,
list_pipelines,
pipeline_response,
preview_pipeline,
render_graph_sql,
update_pipeline,
validate_draft,
)
router = APIRouter(prefix="/dataflow", tags=["dataflow"])
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
if any(has_scope(principal, scope) for scope in scopes):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing one of the required scopes: {', '.join(scopes)}",
)
def _actor_id(principal: ApiPrincipal) -> str | None:
return (
principal.account_id
or principal.membership_id
or getattr(principal.user, "id", None)
or principal.identity_id
)
def _http_error(exc: DataflowError) -> HTTPException:
if isinstance(exc, DataflowNotFoundError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, DataflowConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, DataflowValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [item.model_dump(mode="json") for item in exc.diagnostics],
},
)
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
@router.get("/pipelines", response_model=PipelineListResponse)
def api_list_pipelines(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelineListResponse:
_require_any_scope(principal, READ_SCOPE, ADMIN_SCOPE)
pipelines = list_pipelines(session, tenant_id=principal.tenant_id)
return PipelineListResponse(
pipelines=[pipeline_response(session, pipeline) for pipeline in pipelines]
)
@router.post("/pipelines", response_model=PipelineResponse, status_code=status.HTTP_201_CREATED)
def api_create_pipeline(
payload: PipelineCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelineResponse:
_require_any_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
pipeline = create_pipeline(
session,
tenant_id=principal.tenant_id,
actor_id=_actor_id(principal),
payload=payload,
)
except DataflowError as exc:
raise _http_error(exc) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="dataflow.pipeline.created",
object_type="dataflow_pipeline",
object_id=pipeline.id,
details={"revision": pipeline.current_revision, "status": pipeline.status},
)
response = pipeline_response(session, pipeline)
session.commit()
return response
@router.get("/pipelines/{pipeline_id}", response_model=PipelineResponse)
def api_get_pipeline(
pipeline_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelineResponse:
_require_any_scope(principal, READ_SCOPE, ADMIN_SCOPE)
try:
pipeline = get_pipeline(
session,
tenant_id=principal.tenant_id,
pipeline_id=pipeline_id,
)
return pipeline_response(session, pipeline)
except DataflowError as exc:
raise _http_error(exc) from exc
@router.put("/pipelines/{pipeline_id}", response_model=PipelineResponse)
def api_update_pipeline(
pipeline_id: str,
payload: PipelineUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelineResponse:
_require_any_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
pipeline = update_pipeline(
session,
tenant_id=principal.tenant_id,
pipeline_id=pipeline_id,
actor_id=_actor_id(principal),
payload=payload,
)
except DataflowError as exc:
raise _http_error(exc) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="dataflow.pipeline.updated",
object_type="dataflow_pipeline",
object_id=pipeline.id,
details={"revision": pipeline.current_revision, "status": pipeline.status},
)
response = pipeline_response(session, pipeline)
session.commit()
return response
@router.delete("/pipelines/{pipeline_id}", response_model=PipelineDeleteResponse)
def api_delete_pipeline(
pipeline_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelineDeleteResponse:
_require_any_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
pipeline = delete_pipeline(
session,
tenant_id=principal.tenant_id,
pipeline_id=pipeline_id,
actor_id=_actor_id(principal),
)
except DataflowError as exc:
raise _http_error(exc) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="dataflow.pipeline.deleted",
object_type="dataflow_pipeline",
object_id=pipeline.id,
details={"revision": pipeline.current_revision},
)
session.commit()
return PipelineDeleteResponse(deleted=True, pipeline_id=pipeline.id)
@router.post("/validate", response_model=PipelineValidationResponse)
def api_validate_pipeline(
payload: PipelineDraftRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelineValidationResponse:
_require_any_scope(principal, READ_SCOPE, WRITE_SCOPE, RUN_SCOPE, ADMIN_SCOPE)
return validate_draft(payload)
@router.post("/sql/compile", response_model=PipelineSqlResponse)
def api_compile_pipeline_sql(
payload: PipelineDraftRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelineSqlResponse:
_require_any_scope(principal, READ_SCOPE, WRITE_SCOPE, RUN_SCOPE, ADMIN_SCOPE)
return compile_sql_draft(payload)
@router.post("/sql/render", response_model=PipelineSqlResponse)
def api_render_pipeline_sql(
payload: PipelineDraftRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelineSqlResponse:
_require_any_scope(principal, READ_SCOPE, WRITE_SCOPE, RUN_SCOPE, ADMIN_SCOPE)
return render_graph_sql(payload)
@router.post("/preview", response_model=PipelinePreviewResponse)
def api_preview_pipeline(
payload: PipelinePreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PipelinePreviewResponse:
_require_any_scope(principal, RUN_SCOPE, ADMIN_SCOPE)
try:
response = preview_pipeline(
session,
tenant_id=principal.tenant_id,
actor_id=_actor_id(principal),
payload=payload,
)
except DataflowError as exc:
raise _http_error(exc) from exc
if response.pipeline_id:
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="dataflow.pipeline.previewed",
object_type="dataflow_pipeline",
object_id=response.pipeline_id,
details={
"revision": response.revision,
"run_id": response.run_id,
"status": response.status,
"output_row_count": response.total_rows,
},
)
session.commit()
return response
__all__ = ["router"]