Implement governed tabular source snapshots
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, 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.core.tabular_sources import (
|
||||
TabularReadRequest,
|
||||
TabularSnapshotInput,
|
||||
TabularSource,
|
||||
TabularSourceAccessError,
|
||||
TabularSourceError,
|
||||
TabularSourceNotFoundError,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_connectors.backend.schemas import (
|
||||
SnapshotCreateRequest,
|
||||
TabularColumnResponse,
|
||||
TabularSourceDeleteResponse,
|
||||
TabularSourceListResponse,
|
||||
TabularSourcePreviewResponse,
|
||||
TabularSourceResponse,
|
||||
)
|
||||
from govoplan_connectors.backend.tabular_sources import (
|
||||
ADMIN_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
SqlTabularSourceProvider,
|
||||
parse_csv_snapshot,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/connectors", tags=["connectors"])
|
||||
provider = SqlTabularSourceProvider()
|
||||
|
||||
|
||||
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 _http_error(exc: TabularSourceError) -> HTTPException:
|
||||
if isinstance(exc, TabularSourceNotFoundError):
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, TabularSourceAccessError):
|
||||
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/tabular-sources", response_model=TabularSourceListResponse)
|
||||
def api_list_tabular_sources(
|
||||
query: str = Query(default="", max_length=200),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TabularSourceListResponse:
|
||||
_require_any_scope(principal, READ_SCOPE, ADMIN_SCOPE)
|
||||
sources = provider.list_sources(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
return TabularSourceListResponse(sources=[_source_response(source) for source in sources])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tabular-sources/snapshots",
|
||||
response_model=TabularSourceResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_tabular_snapshot(
|
||||
payload: SnapshotCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TabularSourceResponse:
|
||||
_require_any_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
rows = (
|
||||
tuple(payload.rows or ())
|
||||
if payload.format == "json"
|
||||
else parse_csv_snapshot(payload.csv_text or "", delimiter=payload.delimiter)
|
||||
)
|
||||
source = provider.create_snapshot(
|
||||
session,
|
||||
principal,
|
||||
snapshot=TabularSnapshotInput(
|
||||
name=payload.name,
|
||||
source_name=payload.source_name,
|
||||
description=payload.description,
|
||||
rows=rows,
|
||||
metadata={"import_format": payload.format},
|
||||
),
|
||||
)
|
||||
except TabularSourceError 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="connectors.tabular_snapshot.created",
|
||||
object_type="connector_tabular_source",
|
||||
object_id=source.ref,
|
||||
details={
|
||||
"provider": source.provider,
|
||||
"source_name": source.source_name,
|
||||
"fingerprint": source.fingerprint,
|
||||
"row_count": source.row_count,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _source_response(source)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tabular-sources/{source_id}/preview",
|
||||
response_model=TabularSourcePreviewResponse,
|
||||
)
|
||||
def api_preview_tabular_source(
|
||||
source_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TabularSourcePreviewResponse:
|
||||
_require_any_scope(principal, READ_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
result = provider.read_source(
|
||||
session,
|
||||
principal,
|
||||
request=TabularReadRequest(
|
||||
source_ref=f"snapshot:{source_id}",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
),
|
||||
)
|
||||
except TabularSourceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return TabularSourcePreviewResponse(
|
||||
source=_source_response(result.source),
|
||||
rows=[dict(row) for row in result.rows],
|
||||
total_rows=result.total_rows,
|
||||
truncated=result.truncated,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tabular-sources/{source_id}",
|
||||
response_model=TabularSourceDeleteResponse,
|
||||
)
|
||||
def api_delete_tabular_source(
|
||||
source_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TabularSourceDeleteResponse:
|
||||
_require_any_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
source_ref = f"snapshot:{source_id}"
|
||||
try:
|
||||
source = provider.delete_snapshot(
|
||||
session,
|
||||
principal,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
except TabularSourceError 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="connectors.tabular_snapshot.deleted",
|
||||
object_type="connector_tabular_source",
|
||||
object_id=source_ref,
|
||||
details={"source_name": source.source_name, "fingerprint": source.fingerprint},
|
||||
)
|
||||
session.commit()
|
||||
return TabularSourceDeleteResponse(deleted=True, source_ref=source_ref)
|
||||
|
||||
|
||||
def _source_response(source: TabularSource) -> TabularSourceResponse:
|
||||
return TabularSourceResponse(
|
||||
ref=source.ref,
|
||||
provider=source.provider,
|
||||
source_name=source.source_name,
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
columns=[
|
||||
TabularColumnResponse(
|
||||
name=column.name,
|
||||
data_type=column.data_type,
|
||||
nullable=column.nullable,
|
||||
)
|
||||
for column in source.schema
|
||||
],
|
||||
schema_version=source.schema_version,
|
||||
fingerprint=source.fingerprint,
|
||||
row_count=source.row_count,
|
||||
byte_count=source.byte_count,
|
||||
updated_at=source.updated_at.isoformat() if source.updated_at else None,
|
||||
capabilities=list(source.capabilities),
|
||||
metadata=dict(source.metadata),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
Reference in New Issue
Block a user