Initial public release
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s

This commit is contained in:
Jens
2026-08-31 21:56:53 +02:00
commit faeb58ef6d
1386 changed files with 263203 additions and 0 deletions
View File
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from uuid import UUID
from fastapi import Request
from app.core.errors import AppError
def guest_project_scope(request: Request) -> UUID | None:
principal = getattr(request.state, "auth_principal", None)
if getattr(principal, "role", None) != "guest":
return None
project_id = getattr(principal, "project_id", None)
if isinstance(project_id, UUID):
return project_id
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def assert_guest_project_scope(request: Request, project_id: UUID) -> None:
guest_project_id = guest_project_scope(request)
if guest_project_id is not None and project_id != guest_project_id:
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def guest_scoped_project_filter(
request: Request,
requested_project_id: UUID | None,
) -> UUID | None:
guest_project_id = guest_project_scope(request)
if guest_project_id is None:
return requested_project_id
if requested_project_id is not None:
assert_guest_project_scope(request, requested_project_id)
return guest_project_id
View File
+15
View File
@@ -0,0 +1,15 @@
__all__ = [
"analysis",
"areas",
"assistant",
"auth",
"datasets",
"exports",
"external",
"health",
"jobs",
"projects",
"qa",
"source_registry",
"temporal",
]
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.guest_scope import assert_guest_project_scope
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Dataset
from app.schemas import Envelope, JobRead
from app.schemas.analysis import ChangeDetectionRequest
from app.services.change_detection_service import ChangeDetectionService
from app.services.job_service import JobService
from app.utils.response import envelope
router = APIRouter(prefix="/analysis", tags=["analysis"])
@router.post("/change-detection", response_model=Envelope[JobRead])
def run_change_detection(
payload: ChangeDetectionRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
source_dataset = db.get(Dataset, payload.source_dataset_id)
if not source_dataset:
raise AppError(code="DATASET_NOT_FOUND", message="Source dataset not found", status_code=404)
assert_guest_project_scope(request, source_dataset.project_id)
ChangeDetectionService._get_project_vector_dataset(db, payload.source_dataset_id, source_dataset.project_id, "Source")
ChangeDetectionService._get_project_vector_dataset(db, payload.target_dataset_id, source_dataset.project_id, "Target")
job = JobService.run_sync_job(
db=db,
project_id=source_dataset.project_id,
job_type="analysis.change-detection",
parameters=payload.model_dump(mode="json"),
input_dataset_id=payload.source_dataset_id,
operation=lambda: ChangeDetectionService.compare_vector_datasets(
db=db,
project_id=source_dataset.project_id,
source_dataset_id=payload.source_dataset_id,
target_dataset_id=payload.target_dataset_id,
iou_threshold=payload.iou_threshold,
modified_threshold=payload.modified_threshold,
include_unchanged=payload.include_unchanged,
bbox=payload.bbox.model_dump() if payload.bbox is not None else None,
area_id=payload.area_id,
preview_limit=payload.preview_limit,
).model_dump(mode="json"),
)
return envelope(job)
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.aoi_operation import AoiOperationCreate, AoiOperationList, AoiOperationRead, AoiPartitionCheckpoint, AoiPartitionComplete, AoiPartitionFail, AoiPartitionRead
from app.schemas.common import Envelope
from app.services.aoi_operation_service import AoiOperationService
from app.services.aoi_operation_executor import AoiOperationExecutor
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}/aoi-operations", tags=["aoi-operations"])
@router.post("", status_code=201, response_model=Envelope[AoiOperationRead])
def create_operation(project_id: UUID, payload: AoiOperationCreate, db: Session = Depends(get_db)):
return envelope(AoiOperationService.create(db, project_id, payload))
@router.get("", response_model=Envelope[AoiOperationList])
def list_operations(project_id: UUID, limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db)):
return envelope(AoiOperationService.list(db, project_id, limit))
@router.get("/{operation_id}", response_model=Envelope[AoiOperationRead])
def read_operation(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
return envelope(AoiOperationService.read(db, project_id, operation_id))
@router.post("/{operation_id}/partitions/claim", response_model=Envelope[AoiPartitionRead | None])
def claim_partition(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
partition = AoiOperationService.claim_next(db, project_id, operation_id)
return envelope(AoiPartitionRead.model_validate(partition).model_dump() if partition else None)
@router.post("/{operation_id}/execute-next", response_model=Envelope[AoiOperationRead])
def execute_next_partition(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
return envelope(AoiOperationExecutor.execute_next(db, project_id, operation_id))
@router.put("/{operation_id}/partitions/{partition_id}/checkpoint", response_model=Envelope[AoiPartitionRead])
def checkpoint_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionCheckpoint, db: Session = Depends(get_db)):
partition = AoiOperationService.checkpoint(db, project_id, operation_id, partition_id, payload.checkpoint_json)
return envelope(AoiPartitionRead.model_validate(partition).model_dump())
@router.post("/{operation_id}/partitions/{partition_id}/complete", response_model=Envelope[AoiOperationRead])
def complete_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionComplete, db: Session = Depends(get_db)):
return envelope(AoiOperationService.complete(db, project_id, operation_id, partition_id, payload.result_json, payload.skipped))
@router.post("/{operation_id}/partitions/{partition_id}/fail", response_model=Envelope[AoiOperationRead])
def fail_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionFail, db: Session = Depends(get_db)):
return envelope(AoiOperationService.fail(db, project_id, operation_id, partition_id, payload.error_message, payload.retryable, payload.details))
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Area
from app.schemas import Envelope
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate, MunicipalitySearchList
from app.services.area_service import AreaService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"])
@router.get("", response_model=Envelope[AreaList])
def list_areas(
project_id: UUID,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
areas, total = AreaService.list_areas(db, project_id=project_id, limit=limit, offset=offset)
return envelope({"items": [AreaService.serialize_area(area) for area in areas], "total": total, "limit": limit, "offset": offset})
@router.post("", status_code=201, response_model=Envelope[AreaRead])
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
area = AreaService.create_area(db, project_id, payload)
return envelope(AreaService.serialize_area(area))
@router.get("/municipalities", response_model=Envelope[MunicipalitySearchList])
def search_municipalities(
project_id: UUID,
query: str = Query(default="", max_length=120),
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
):
items, total = AreaService.search_municipalities(db, project_id, query, limit)
return envelope({"items": items, "total": total})
@router.post("/municipalities/{niscode}/activate", response_model=Envelope[AreaRead])
def activate_municipality(project_id: UUID, niscode: str, db: Session = Depends(get_db)):
area = AreaService.activate_municipality(db, project_id, niscode)
return envelope(AreaService.serialize_area(area))
@router.get("/{area_id}", response_model=Envelope[AreaRead])
def get_area(
project_id: UUID,
area_id: UUID,
db: Session = Depends(get_db),
):
area = AreaService.get_area(db, area_id)
if area.project_id != project_id:
raise HTTPException(status_code=404, detail="Area not found")
return envelope(AreaService.serialize_area(area))
@router.patch("/{area_id}", response_model=Envelope[AreaRead])
def update_area(
project_id: UUID,
area_id: UUID,
payload: AreaUpdate,
db: Session = Depends(get_db),
):
existing = db.get(Area, area_id)
if not existing or existing.project_id != project_id:
raise HTTPException(status_code=404, detail="Area not found")
area = AreaService.update_area(db, area_id, payload)
return envelope(AreaService.serialize_area(area))
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.assistant import (
AssistantModelList,
AssistantQueryRequest,
AssistantQueryResponse,
AssistantStatus,
)
from app.services.geo_assistant_service import GeoAssistantService
from app.utils.response import envelope
router = APIRouter(tags=["assistant"])
@router.get("/assistant/status", response_model=Envelope[AssistantStatus])
def assistant_status() -> dict:
return envelope(GeoAssistantService().status().model_dump())
@router.get("/assistant/models", response_model=Envelope[AssistantModelList])
def assistant_models() -> dict:
service = GeoAssistantService()
models = service.list_models()
return envelope(
{
"items": [model.model_dump() for model in models],
"total": len(models),
"default_model": service.settings.ollama_default_model,
}
)
@router.post(
"/projects/{project_id}/assistant/query",
response_model=Envelope[AssistantQueryResponse],
)
def assistant_query(
project_id: UUID,
payload: AssistantQueryRequest,
db: Session = Depends(get_db),
) -> dict:
return envelope(GeoAssistantService().query(db, project_id=project_id, payload=payload).model_dump())
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from ipaddress import ip_address, ip_network
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
from app.services.auth_service import AuthPrincipal, AuthService
from app.services.authentik_oidc_service import AuthentikOidcService
from app.services.demo_workflow_service import DemoWorkflowService
router = APIRouter(prefix="/auth", tags=["auth"])
COOKIE_NAME = "geointel_session"
OIDC_FLOW_COOKIE_NAME = "geointel_oidc_flow"
logger = logging.getLogger("geointel.auth")
_TRUSTED_PROXY_NETWORKS = (
ip_network("127.0.0.0/8"),
ip_network("::1/128"),
ip_network("172.16.0.0/12"),
)
def _peer_is_trusted_proxy(request: Request) -> bool:
if request.client is None:
return False
try:
peer_address = ip_address(request.client.host)
except ValueError:
return False
return any(peer_address in network for network in _TRUSTED_PROXY_NETWORKS)
def _request_is_https(request: Request) -> bool:
if request.url.scheme == "https":
return True
if not _peer_is_trusted_proxy(request):
return False
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
return forwarded_proto == "https"
def _client_host(request: Request) -> str:
peer = request.client.host if request.client else "unknown"
if not _peer_is_trusted_proxy(request):
return peer
forwarded = request.headers.get("x-real-ip", "").strip()
if not forwarded:
return peer
try:
return str(ip_address(forwarded))
except ValueError:
return peer
def _session_from_principal(
principal: AuthPrincipal,
*,
guest_access_enabled: bool,
authentik_enabled: bool,
) -> AuthSession:
return AuthSession(
authentication_required=True,
authenticated=True,
username=principal.username,
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
role=principal.role,
guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
guest_project_id=principal.project_id,
)
def _session_payload(request: Request) -> AuthSession:
settings = get_settings()
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
authentik_enabled = AuthentikOidcService(settings).enabled
if not settings.auth_enabled:
return AuthSession(
authentication_required=False,
authenticated=True,
guest_access_enabled=False,
authentik_enabled=False,
)
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
if principal is None:
return AuthSession(
authentication_required=True,
authenticated=False,
guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
)
return _session_from_principal(
principal,
guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
)
def _set_session_cookie(
*,
request: Request,
response: Response,
token: str,
max_age: int,
) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=max_age,
httponly=True,
secure=_request_is_https(request),
samesite="strict",
path="/",
)
@router.get("/session", response_model=AuthSessionEnvelope)
def session(request: Request) -> AuthSessionEnvelope:
return AuthSessionEnvelope(data=_session_payload(request))
@router.post("/login", response_model=AuthSessionEnvelope)
def login(payload: AuthLoginRequest, request: Request, response: Response) -> AuthSessionEnvelope:
settings = get_settings()
if not settings.auth_enabled:
raise AppError(
code="AUTHENTICATION_DISABLED",
message="Operator authentication is not enabled on this runtime",
status_code=status.HTTP_409_CONFLICT,
)
if settings.auth_require_https and not _request_is_https(request):
raise AppError(
code="AUTH_HTTPS_REQUIRED",
message="Operator authentication requires HTTPS on this runtime",
status_code=status.HTTP_426_UPGRADE_REQUIRED,
)
client_host = _client_host(request)
throttle_key = f"{client_host}:{payload.username.casefold()}"
retry_after = AuthService.retry_after_seconds(throttle_key)
if retry_after:
raise AppError(
code="LOGIN_RATE_LIMITED",
message="Te veel mislukte aanmeldpogingen. Probeer later opnieuw.",
details={"retry_after_seconds": retry_after},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not AuthService.credentials_match(payload.username, payload.password, settings):
AuthService.record_failure(throttle_key)
raise AppError(
code="INVALID_CREDENTIALS",
message="Gebruikersnaam of wachtwoord is onjuist.",
status_code=status.HTTP_401_UNAUTHORIZED,
)
AuthService.clear_failures(throttle_key)
token = AuthService.create_session_token(payload.username, settings)
principal = AuthService.verify_session_token(token, settings)
if principal is None: # pragma: no cover - defensive invariant
raise AppError(
code="SESSION_CREATION_FAILED",
message="De beveiligde sessie kon niet worden aangemaakt.",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.auth_session_ttl_seconds,
)
return AuthSessionEnvelope(
data=_session_from_principal(
principal,
guest_access_enabled=settings.guest_access_enabled,
authentik_enabled=AuthentikOidcService(settings).enabled,
)
)
@router.get("/authentik/start")
def authentik_start(request: Request) -> RedirectResponse:
settings = get_settings()
service = AuthentikOidcService(settings)
try:
location, flow = service.start()
except Exception as exc:
logger.warning("Authentik authorization start failed: %s", type(exc).__name__)
raise AppError(
code="AUTHENTIK_UNAVAILABLE",
message="Authentik is momenteel niet beschikbaar.",
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
) from exc
response = RedirectResponse(location, status_code=status.HTTP_302_FOUND)
response.set_cookie(
OIDC_FLOW_COOKIE_NAME,
flow,
max_age=600,
httponly=True,
secure=True,
samesite="lax",
path=f"{settings.api_prefix}/auth/authentik",
)
return response
@router.get("/authentik/callback")
def authentik_callback(
request: Request,
code: str = "",
state: str = "",
) -> RedirectResponse:
settings = get_settings()
service = AuthentikOidcService(settings)
base_url = settings.public_base_url.rstrip("/")
try:
service.finish(
code=code,
state=state,
flow_cookie=request.cookies.get(OIDC_FLOW_COOKIE_NAME, ""),
)
token = AuthService.create_session_token(
settings.auth_username or "operator",
settings,
)
except Exception as exc:
logger.warning("Authentik callback rejected: %s", type(exc).__name__)
response = RedirectResponse(
f"{base_url}/?authentik=error",
status_code=status.HTTP_302_FOUND,
)
else:
response = RedirectResponse(
f"{base_url}/",
status_code=status.HTTP_302_FOUND,
)
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.auth_session_ttl_seconds,
)
response.delete_cookie(
OIDC_FLOW_COOKIE_NAME,
path=f"{settings.api_prefix}/auth/authentik",
secure=True,
httponly=True,
samesite="lax",
)
return response
@router.post("/guest", response_model=AuthSessionEnvelope)
def guest_login(
request: Request,
response: Response,
db: Session = Depends(get_db),
) -> AuthSessionEnvelope:
settings = get_settings()
if not settings.auth_enabled or not settings.guest_access_enabled:
raise AppError(
code="GUEST_ACCESS_DISABLED",
message="Gasttoegang is niet ingeschakeld op deze GeoIntel-installatie.",
status_code=status.HTTP_403_FORBIDDEN,
)
client_host = _client_host(request)
retry_after = AuthService.consume_guest_request(
f"guest-login:{client_host}",
max_requests=settings.guest_login_requests_per_minute,
)
if retry_after:
raise AppError(
code="GUEST_LOGIN_RATE_LIMITED",
message="Too many guest sessions were requested. Try again later.",
details={"retry_after_seconds": retry_after},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
demo = DemoWorkflowService.seed(db)
token = AuthService.create_session_token(
settings.guest_display_name,
settings,
role="guest",
project_id=demo.project_id,
ttl_seconds=settings.guest_session_ttl_seconds,
)
principal = AuthService.verify_session_token(token, settings)
if principal is None: # pragma: no cover - defensive invariant
raise AppError(
code="SESSION_CREATION_FAILED",
message="De tijdelijke gastensessie kon niet worden aangemaakt.",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.guest_session_ttl_seconds,
)
return AuthSessionEnvelope(
data=_session_from_principal(
principal,
guest_access_enabled=True,
authentik_enabled=AuthentikOidcService(settings).enabled,
)
)
@router.post("/logout", response_model=AuthSessionEnvelope)
def logout(response: Response) -> AuthSessionEnvelope:
settings = get_settings()
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
return AuthSessionEnvelope(
data=AuthSession(
authentication_required=settings.auth_enabled,
authenticated=not settings.auth_enabled,
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
authentik_enabled=AuthentikOidcService(settings).enabled,
)
)
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.demo import DemoWorkflowResponse
from app.services.demo_workflow_service import DemoWorkflowService
from app.utils.response import envelope
router = APIRouter(prefix="/demo", tags=["demo"])
@router.post(
"/workflow",
status_code=status.HTTP_201_CREATED,
response_model=Envelope[DemoWorkflowResponse],
)
def seed_demo_workflow(db: Session = Depends(get_db)) -> dict:
result: DemoWorkflowResponse = DemoWorkflowService.seed(db)
return envelope(result.model_dump())
+336
View File
@@ -0,0 +1,336 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.guest_scope import (
assert_guest_project_scope,
guest_project_scope,
guest_scoped_project_filter,
)
from app.db.session import get_db
from app.schemas import (
AnalysisQaResponse,
DetectionListResponse,
DetectionModelsResponse,
DetectionComparisonRequest,
DetectionComparisonResponse,
DetectionQaRequest,
DetectionRead,
DetectionRunListResponse,
DetectionRunRead,
DetectionRunRequest,
DetectionRunResponse,
Envelope,
GeoJsonFeatureCollection,
JobRead,
ModelAssetListResponse,
YoloPreflightResponse,
)
from app.services.detection_comparison_service import DetectionComparisonService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.model_registry_service import ModelRegistryService
from app.services.yolo_preflight_service import YoloPreflightService
from app.utils.response import envelope
router = APIRouter(prefix="/detection", tags=["detection"])
@router.get("/models", response_model=Envelope[DetectionModelsResponse])
def list_detection_models() -> dict:
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]})
@router.get("/model-assets", response_model=Envelope[ModelAssetListResponse])
def list_detection_model_assets() -> dict:
return envelope(ModelAssetCatalogService.list_assets().model_dump())
@router.get("/yolo/preflight", response_model=Envelope[YoloPreflightResponse])
def get_yolo_preflight(
tile_manifest_path: str | None = None,
check_model_load: bool = False,
model_asset_id: str | None = None,
db: Session = Depends(get_db),
) -> dict:
return envelope(
YoloPreflightService.run(
tile_manifest_path=tile_manifest_path,
check_model_load=check_model_load,
model_asset_id=model_asset_id,
db=db,
)
)
@router.post("/run", response_model=Envelope[DetectionRunResponse])
def run_detection(
payload: DetectionRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
assert_guest_project_scope(request, payload.project_id)
result = DetectionService.run_detection(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
model_asset_id=payload.model_asset_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(result.model_dump())
@router.post("/run-async", response_model=Envelope[JobRead])
def queue_detection(
payload: DetectionRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Queue a detection run for the background worker.
Tiled GPU inference takes minutes; ``POST /detection/run`` performs it
inside the request and is only appropriate for a handful of tiles. Poll
``GET /jobs/{id}`` for the queued run instead.
"""
assert_guest_project_scope(request, payload.project_id)
job = DetectionService.enqueue_detection(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
model_asset_id=payload.model_asset_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
@router.get("/runs", response_model=Envelope[DetectionRunListResponse])
def list_detection_runs(
request: Request,
project_id: UUID | None = None,
dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
project_id = guest_scoped_project_filter(request, project_id)
return envelope(
DetectionService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
).model_dump()
)
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
def get_detection_run(
analysis_run_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(run.model_dump())
@router.get(
"/runs/{analysis_run_id}/detections",
response_model=Envelope[DetectionListResponse],
)
def list_detection_run_detections(
analysis_run_id: UUID,
request: Request,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.list_detections(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
limit=limit,
offset=offset,
).model_dump()
)
@router.get(
"/datasets/{dataset_id}/detections",
response_model=Envelope[DetectionListResponse],
)
def list_dataset_detections(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
DetectionService.list_detections(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
limit=limit,
offset=offset,
).model_dump()
)
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
def get_detection(
detection_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
detection = DetectionService.get_detection(db, detection_id)
assert_guest_project_scope(request, detection.project_id)
return envelope(detection.model_dump())
@router.get(
"/runs/{analysis_run_id}/geojson",
response_model=Envelope[GeoJsonFeatureCollection],
)
def get_detection_run_geojson(
analysis_run_id: UUID,
request: Request,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.detections_to_geojson(
db,
limit=limit,
analysis_run_id=analysis_run_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.get(
"/datasets/{dataset_id}/geojson",
response_model=Envelope[GeoJsonFeatureCollection],
)
def get_dataset_detection_geojson(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
DetectionService.detections_to_geojson(
db,
limit=limit,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.post("/runs/compare", response_model=Envelope[DetectionComparisonResponse])
def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = Depends(get_db)) -> dict:
"""Rank several runs against one reference on average precision.
The workbench ranks model variants by a stored F1 measured at each
variant's own confidence threshold, which orders the thresholds as much as
the models. Average precision describes the whole ranking a model produced.
Comparability is reported first: runs over different rasters, different
references or different inference coverage are not alternatives.
"""
return envelope(
DetectionComparisonService.compare_runs(
db,
analysis_run_ids=payload.analysis_run_ids,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
)
)
@router.post(
"/runs/{analysis_run_id}/qa/reference",
response_model=Envelope[AnalysisQaResponse],
)
def compare_detection_run_with_reference(
analysis_run_id: UUID,
payload: DetectionQaRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = DetectionService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
class_name=payload.class_name,
min_confidence=payload.min_confidence,
calibration_thresholds=payload.calibration_thresholds,
)
)
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.guest_scope import assert_guest_project_scope, guest_project_scope
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.export import (
ExportContentResponse,
ExportCreateResponse,
ExportListResponse,
ExportRead,
GeoJsonExportRequest,
MapResultExportRequest,
MetadataExportRequest,
ReportExportRequest,
)
from app.services.export_service import ExportService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
router = APIRouter(prefix="/exports", tags=["exports"])
@router.post("/geojson", response_model=Envelope[ExportCreateResponse])
def export_geojson(
payload: GeoJsonExportRequest,
request: Request,
db: Session = Depends(get_db),
):
if guest_project_scope(request) is not None:
if payload.export_kind in {"dataset", "vector_selection"} and payload.dataset_id is not None:
dataset = DatasetService.get_dataset(db, payload.dataset_id)
assert_guest_project_scope(request, dataset.project_id)
elif payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
run = DetectionService.get_run(db, payload.analysis_run_id)
assert_guest_project_scope(request, run.project_id)
elif payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
run = SegmentationService.get_run(db, payload.analysis_run_id)
assert_guest_project_scope(request, run.project_id)
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
return envelope(
ExportService.export_vector_selection_geojson(
db,
payload.dataset_id,
payload.bbox.model_dump(),
area_id=payload.area_id,
limit=payload.limit,
name=payload.name,
).model_dump(mode="json")
)
if payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
return envelope(
ExportService.export_detection_run_geojson(
db,
payload.analysis_run_id,
payload.name,
intended_use=payload.intended_use,
).model_dump(mode="json")
)
if payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
return envelope(
ExportService.export_segmentation_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
)
if payload.dataset_id is not None:
return envelope(ExportService.export_dataset_geojson(db, payload.dataset_id, payload.name).model_dump(mode="json"))
raise AppError(
code="INVALID_EXPORT_REQUEST",
message="GeoJSON export request does not match any supported export target",
status_code=422,
)
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
def export_project_metadata(
payload: MetadataExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/report", response_model=Envelope[ExportCreateResponse])
def export_project_report(
payload: ReportExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/map-result", response_model=Envelope[ExportCreateResponse])
def export_map_result(
payload: MapResultExportRequest,
request: Request,
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
@router.get(
"/projects/{project_id}/exports",
response_model=Envelope[ExportListResponse],
)
def list_project_exports(
project_id: UUID,
request: Request,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
assert_guest_project_scope(request, project_id)
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
@router.get("/{export_id}", response_model=Envelope[ExportRead])
def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
return envelope(export.model_dump(mode="json"))
@router.get("/{export_id}/download")
def download_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
if guest_project_scope(request) is not None:
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
path = ExportService.get_export_download_path(db, export_id)
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
return FileResponse(path, filename=path.name, media_type=media_type)
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse])
def get_export_content(export_id: UUID, request: Request, db: Session = Depends(get_db)):
if guest_project_scope(request) is not None:
export = ExportService.get_export(db, export_id)
assert_guest_project_scope(request, export.project_id)
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Area, Project
from app.providers.registry import fetch_provider_data, get_provider, import_provider_dataset, list_provider_capabilities
from app.schemas import (
CoverageCatalogResponse,
CoverageResolveRequest,
CoverageResolveResponse,
Envelope,
ExternalFetchRequest,
ExternalFetchResponse,
ProviderCapabilitiesResponse,
ProviderCapabilityResponse,
ProviderImportRequest,
ProviderImportResponse,
ProviderLayersResponse,
ProviderStatusResponse,
)
from app.services.coverage_registry_service import CoverageRegistryService
from app.utils.response import envelope
router = APIRouter(prefix="/external", tags=["external"])
def _validate_area_in_project(db: Session, project_id, area_id: str | None) -> None:
if area_id is None:
return
area = db.get(Area, area_id)
if not area:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
if area.project_id != project_id:
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
def _assert_project_exists(db: Session, project_id):
project = db.get(Project, project_id)
if not project:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
def _assert_guest_project_scope(request: Request, project_id) -> None:
principal = getattr(request.state, "auth_principal", None)
if (
getattr(principal, "role", None) == "guest"
and getattr(principal, "project_id", None) != project_id
):
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def _normalize_layer_input(layers: list[str] | None) -> list[str]:
return [layer.strip() for layer in (layers or []) if isinstance(layer, str) and layer.strip()]
def _provider_payload(provider_name: str) -> dict:
return get_provider(provider_name).capability.to_dict()
@router.get("/providers", response_model=Envelope[ProviderCapabilitiesResponse])
def list_external_providers() -> dict:
return envelope({
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
})
@router.get("/coverage/catalog", response_model=Envelope[CoverageCatalogResponse])
def get_coverage_catalog() -> dict:
return envelope(CoverageRegistryService.catalog().model_dump())
@router.post("/coverage/resolve", response_model=Envelope[CoverageResolveResponse])
def resolve_project_coverage(
payload: CoverageResolveRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
_assert_guest_project_scope(request, payload.project_id)
result = CoverageRegistryService.resolve(
db,
project_id=payload.project_id,
bbox=payload.bbox,
themes=payload.themes,
)
return envelope(result.model_dump())
@router.get(
"/providers/capabilities",
response_model=Envelope[ProviderCapabilitiesResponse],
)
def get_external_provider_capabilities() -> dict:
return envelope({
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
})
@router.get(
"/providers/{provider_name}",
response_model=Envelope[ProviderCapabilityResponse],
)
def get_external_provider(provider_name: str) -> dict:
return envelope(_provider_payload(provider_name))
@router.get(
"/providers/{provider_name}/layers",
response_model=Envelope[ProviderLayersResponse],
)
def get_external_provider_layers(provider_name: str) -> dict:
provider = get_provider(provider_name)
return envelope({
"provider_name": provider.provider_name,
"layers": provider.supported_layers,
})
@router.get(
"/providers/{provider_name}/status",
response_model=Envelope[ProviderStatusResponse],
)
def get_external_provider_status(provider_name: str) -> dict:
provider = get_provider(provider_name)
return envelope({
"provider_name": provider.provider_name,
"configured": provider.is_configured,
"status": provider.capability.status,
"limitation_message": provider.limitation_message,
})
@router.post(
"/providers/{provider_name}/import",
response_model=Envelope[ProviderImportResponse],
)
def import_external_provider_dataset(provider_name: str, payload: ProviderImportRequest) -> dict:
result = import_provider_dataset(
provider_name=provider_name,
project_id=payload.project_id,
area_id=payload.area_id,
layers=_normalize_layer_input(payload.layers),
requested_dataset_role=payload.dataset_role,
)
return envelope(result.model_dump())
def _run_fetch(payload: ExternalFetchRequest, provider_name: str) -> ExternalFetchResponse:
area_id_str = str(payload.area_id) if payload.area_id else None
response = fetch_provider_data(
provider_name=provider_name,
project_id=str(payload.project_id),
area_id=area_id_str,
layers=_normalize_layer_input(payload.layers),
)
return ExternalFetchResponse(
provider=provider_name,
status=response.get("status", "not_configured"),
message=response.get("message", "Provider fetch executed."),
requested_layers=_normalize_layer_input(payload.layers),
project_id=payload.project_id,
area_id=payload.area_id,
)
@router.post("/osm/fetch", response_model=Envelope[ExternalFetchResponse])
def fetch_osm(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
_assert_project_exists(db, payload.project_id)
_validate_area_in_project(db, payload.project_id, payload.area_id)
return envelope(_run_fetch(payload, "osm").model_dump())
@router.post("/grb/fetch", response_model=Envelope[ExternalFetchResponse])
def fetch_grb(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
_assert_project_exists(db, payload.project_id)
_validate_area_in_project(db, payload.project_id, payload.area_id)
return envelope(_run_fetch(payload, "grb").model_dump())
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from importlib import import_module
from pathlib import Path
from tempfile import NamedTemporaryFile
from alembic.config import Config
from alembic.script import ScriptDirectory
from fastapi import APIRouter, Response, status
from sqlalchemy import text
from app.core.config import get_settings
from app.db.session import get_engine
from app.providers.registry import list_provider_capabilities
from app.schemas.health import (
HealthResponse,
SystemCapabilities,
SystemCapabilitiesEnvelope,
)
from app.services.model_registry_service import ModelRegistryService
router = APIRouter()
def _dependency_enabled(module: str) -> bool:
try:
import_module(module)
return True
except Exception:
return False
def _expected_migration_heads() -> list[str]:
backend_root = Path(__file__).resolve().parents[3]
config = Config(str(backend_root / "alembic.ini"))
config.set_main_option("script_location", str(backend_root / "alembic"))
return list(ScriptDirectory.from_config(config).get_heads())
def _database_checks() -> dict[str, str]:
checks = {
"database": "degraded",
"postgis": "degraded",
"migration": "degraded",
}
try:
with get_engine().connect() as connection:
connection.execute(text("SELECT 1"))
checks["database"] = "ok"
connection.execute(
text("SELECT PostGIS_Version()")
).scalar_one()
checks["postgis"] = "ok"
database_head = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
expected_heads = _expected_migration_heads()
if len(expected_heads) == 1 and database_head == expected_heads[0]:
checks["migration"] = "ok"
else:
checks["migration"] = "degraded"
except Exception:
return checks
return checks
def _storage_check(storage_root: str) -> str:
root = Path(storage_root).expanduser()
try:
root.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile(
prefix=".geointel-readiness-",
dir=root,
delete=True,
) as handle:
handle.write(b"ok")
handle.flush()
return "ok"
except OSError:
return "degraded"
def _readiness_payload() -> HealthResponse:
settings = get_settings()
checks = _database_checks()
checks["storage"] = _storage_check(settings.storage_root)
ready = all(
value == "ok" or value.startswith("ok:")
for value in checks.values()
)
return HealthResponse(
status="ok" if ready else "degraded",
service="geointel-backend",
version="public",
database=checks["database"],
postgis=checks["postgis"],
migration=checks["migration"],
storage=checks["storage"],
checks=checks,
)
@router.get("/health/live", response_model=HealthResponse)
def liveness() -> HealthResponse:
return HealthResponse(
status="ok",
service="geointel-backend",
version="public",
)
def _readiness_response(response: Response) -> HealthResponse:
payload = _readiness_payload()
if payload.status != "ok":
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return payload
@router.get("/health", response_model=HealthResponse)
def readiness(response: Response) -> HealthResponse:
return _readiness_response(response)
@router.get("/health/ready", response_model=HealthResponse)
def readiness_explicit(response: Response) -> HealthResponse:
return _readiness_response(response)
@router.get(
"/api/v1/system/capabilities",
response_model=SystemCapabilitiesEnvelope,
)
def capabilities() -> SystemCapabilitiesEnvelope:
settings = get_settings()
providers = [item.to_dict() for item in list_provider_capabilities()]
configured_yolo = ModelRegistryService.get_model_capability(
settings.yolo_model_id,
settings=settings,
)
yolo_configured = bool(configured_yolo and configured_yolo.configured)
yolo_status = configured_yolo.status if configured_yolo else "not_configured"
configured_sam = ModelRegistryService.get_model_capability(
settings.sam_model_id,
settings=settings,
task_type="segmentation",
)
postgis_ready = _database_checks()["postgis"].startswith("ok:")
return SystemCapabilitiesEnvelope(
data=SystemCapabilities(
postgis=postgis_ready,
rasterio=_dependency_enabled("rasterio"),
geopandas=_dependency_enabled("geopandas"),
yolo=yolo_configured,
yolo_status=yolo_status,
sam=bool(configured_sam and configured_sam.configured),
grb="bounded",
sentinel="planned",
version=settings.app_version,
build_sha=settings.build_sha,
providers=providers,
)
)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope, JobCreate, JobList, JobRead, JobStatus
from app.services.job_service import JobService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["jobs"])
@router.post("/jobs", status_code=201, response_model=Envelope[JobRead])
def create_job(
project_id: UUID,
payload: JobCreate,
db: Session = Depends(get_db),
):
if payload.project_id != project_id:
raise HTTPException(status_code=400, detail="project_id mismatch")
return envelope(JobService.create_job(db, payload).model_dump())
@router.get("/jobs", response_model=Envelope[JobList])
def list_jobs(
project_id: UUID,
dataset_id: UUID | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
items, total = JobService.list_jobs(
db,
project_id=project_id,
dataset_id=dataset_id,
limit=limit,
offset=offset,
)
return envelope(JobList(items=items, total=total, limit=limit, offset=offset).model_dump())
@router.get("/jobs/{job_id}", response_model=Envelope[JobRead])
def read_job(
project_id: UUID,
job_id: UUID,
db: Session = Depends(get_db),
):
job = JobService.get_job(db, job_id)
if job.project_id != project_id:
raise HTTPException(status_code=404, detail="Job not found")
return envelope(job.model_dump())
@router.get("/jobs/{job_id}/status", response_model=Envelope[JobStatus])
def read_job_status(
project_id: UUID,
job_id: UUID,
db: Session = Depends(get_db),
):
status_row = JobService.get_job_status(db, job_id)
if status_row["project_id"] != str(project_id):
raise HTTPException(status_code=404, detail="Job not found")
return envelope(JobStatus(**status_row).model_dump())
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
from app.services.project_service import ProjectService
from app.utils.response import envelope
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("", response_model=Envelope[ProjectList])
def list_projects(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
name: str | None = Query(default=None, min_length=1, max_length=255),
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
db: Session = Depends(get_db),
):
principal = getattr(request.state, "auth_principal", None)
if principal is not None and principal.role == "guest":
project = ProjectService.get_project(db, principal.project_id)
status_matches = bool(
project is not None
and (project_status == "all" or project.status == project_status)
)
name_matches = bool(
project is not None
and (name is None or name.casefold() in project.name.casefold())
)
matches = project is not None and status_matches and name_matches
visible = [project] if matches and offset == 0 else []
return envelope(
{
"items": [ProjectRead.model_validate(item).model_dump() for item in visible[:limit]],
"total": 1 if matches else 0,
"limit": limit,
"offset": offset,
}
)
projects, total = ProjectService.list_projects(
db,
limit=limit,
offset=offset,
name=name,
project_status=project_status,
)
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
@router.post("", status_code=status.HTTP_201_CREATED, response_model=Envelope[ProjectRead])
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
project = ProjectService.create_project(db, payload)
return envelope(ProjectRead.model_validate(project).model_dump())
@router.get("/{project_id}", response_model=Envelope[ProjectRead])
def get_project(project_id: UUID, db: Session = Depends(get_db)):
project = ProjectService.get_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return envelope(ProjectRead.model_validate(project).model_dump())
@router.patch("/{project_id}", response_model=Envelope[ProjectRead])
def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)):
project = ProjectService.update_project(db, project_id, payload)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return envelope(ProjectRead.model_validate(project).model_dump())
@router.delete(
"/{project_id}",
status_code=status.HTTP_200_OK,
response_model=Envelope[ProjectDeleteResult],
)
def delete_project(project_id: UUID, db: Session = Depends(get_db)):
if not ProjectService.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return envelope({"deleted": True})
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.errors import AppError
from app.models import Dataset, Job
from app.schemas import Envelope, JobRead, QaProviderComparisonRequest
from app.services.qa_service import QaService
from app.services.job_service import JobService
from app.services.quality_service import QualityService
from app.utils.response import envelope
router = APIRouter(prefix="/qa", tags=["qa"])
@router.post("/detections-vs-reference", response_model=Envelope[JobRead])
def compare_candidate_with_reference(
payload: QaProviderComparisonRequest,
db: Session = Depends(get_db),
) -> dict:
candidate_dataset = db.get(Dataset, payload.candidate_dataset_id)
if not candidate_dataset:
raise AppError(code="DATASET_NOT_FOUND", message="Candidate dataset not found", status_code=404)
job = JobService.run_sync_job(
db=db,
project_id=candidate_dataset.project_id,
job_type="qa.compare-candidate-with-reference",
parameters=payload.model_dump(mode="json"),
input_dataset_id=candidate_dataset.id,
operation=lambda: QaService.compare_candidate_with_reference(
db=db,
project_id=candidate_dataset.project_id,
candidate_dataset_id=payload.candidate_dataset_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
area_id=payload.area_id,
).model_dump(mode="json"),
)
result_json = job.get("result_json") if isinstance(job, dict) else None
if isinstance(result_json, dict) and job.get("status") == "success":
quality_check = QualityService.persist_quality_check(
db=db,
project_id=candidate_dataset.project_id,
job_id=uuid.UUID(str(job["id"])),
candidate_dataset_id=payload.candidate_dataset_id,
reference_dataset_id=payload.reference_dataset_id,
check_type="candidate_vs_reference",
status=str(result_json.get("status", "ok")),
score=result_json.get("f1_score"),
parameters=payload.model_dump(mode="json"),
findings={
"matches": result_json.get("matches"),
"false_positives": result_json.get("false_positives"),
"false_negatives": result_json.get("false_negatives"),
"warnings": result_json.get("warnings", []),
"unsupported_geometry": result_json.get("unsupported_geometry", False),
"unsupported_geometries": result_json.get("unsupported_geometries", []),
"match_evidence": result_json.get("match_evidence", []),
"false_positive_evidence": result_json.get("false_positive_evidence", []),
"false_negative_evidence": result_json.get("false_negative_evidence", []),
},
metrics={
"precision": result_json.get("precision"),
"recall": result_json.get("recall"),
"f1": result_json.get("f1_score"),
"mean_iou": result_json.get("mean_iou"),
"false_positive_count": result_json.get("false_positives"),
"false_negative_count": result_json.get("false_negatives"),
},
)
result_json["quality_check_id"] = str(quality_check.id)
job_record = db.get(Job, uuid.UUID(str(job["id"])))
if job_record:
job_record.result_json = result_json
db.add(job_record)
db.commit()
return envelope(job)
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope, QualityEvidenceResponse
from app.schemas.detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewUpsert
from app.schemas.qa import QualityCheckList
from app.services.detection_review_service import DetectionReviewService
from app.services.quality_evidence_service import QualityEvidenceService
from app.services.quality_check_service import QualityCheckService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["quality-checks"])
@router.get("/quality-checks", response_model=Envelope[QualityCheckList])
def list_quality_checks(
project_id: UUID,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
items, total = QualityCheckService.list_quality_checks(
db,
project_id=project_id,
limit=limit,
offset=offset,
)
return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump())
@router.get(
"/quality-checks/{quality_check_id}/evidence/geojson",
response_model=Envelope[QualityEvidenceResponse],
)
def get_quality_check_evidence_geojson(
project_id: UUID,
quality_check_id: UUID,
limit: int = Query(
default=QualityEvidenceService.DEFAULT_EVIDENCE_LIMIT,
ge=0,
le=100_000,
description="Maximum evidence features to draw; 0 returns everything. Misses and false positives first.",
),
db: Session = Depends(get_db),
) -> dict:
return envelope(
QualityEvidenceService.evidence_geojson(
db,
project_id=project_id,
quality_check_id=quality_check_id,
limit=limit,
)
)
@router.get(
"/quality-checks/{quality_check_id}/reviews",
response_model=Envelope[DetectionReviewList],
)
def list_detection_reviews(
project_id: UUID,
quality_check_id: UUID,
evidence_role: str | None = Query(default=None, pattern="^(false_positive|false_negative)$"),
decision: str | None = Query(default=None, max_length=64),
reviewed: bool | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionReviewService.list_reviews(
db,
project_id=project_id,
quality_check_id=quality_check_id,
evidence_role=evidence_role,
decision=decision,
reviewed=reviewed,
limit=limit,
offset=offset,
).model_dump()
)
@router.post(
"/quality-checks/{quality_check_id}/reviews",
response_model=Envelope[DetectionReviewRead],
)
def upsert_detection_review(
project_id: UUID,
quality_check_id: UUID,
payload: DetectionReviewUpsert,
db: Session = Depends(get_db),
) -> dict:
return envelope(
DetectionReviewService.upsert_review(
db,
project_id=project_id,
quality_check_id=quality_check_id,
payload=payload,
).model_dump()
)
+284
View File
@@ -0,0 +1,284 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.guest_scope import (
assert_guest_project_scope,
guest_project_scope,
guest_scoped_project_filter,
)
from app.db.session import get_db
from app.schemas import (
AnalysisQaResponse,
Envelope,
GeoJsonFeatureCollection,
JobRead,
SegmentationListResponse,
SegmentationModelsResponse,
SegmentationQaRequest,
SegmentationRead,
SegmentationRunListResponse,
SegmentationRunRead,
SegmentationRunRequest,
SegmentationRunResponse,
)
from app.services.model_registry_service import ModelRegistryService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
router = APIRouter(prefix="/segmentation", tags=["segmentation"])
@router.get("/models", response_model=Envelope[SegmentationModelsResponse])
def list_segmentation_models() -> dict:
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")]})
@router.post("/run", response_model=Envelope[SegmentationRunResponse])
def run_segmentation(
payload: SegmentationRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
assert_guest_project_scope(request, payload.project_id)
result = SegmentationService.run_segmentation(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(result.model_dump())
@router.post("/run-async", response_model=Envelope[JobRead])
def queue_segmentation(
payload: SegmentationRunRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Queue a segmentation run for the background worker.
Configured segmentation walks the same tile manifest as detection and is
just as unsuited to running inside the request. Poll ``GET /jobs/{id}``.
"""
assert_guest_project_scope(request, payload.project_id)
job = SegmentationService.enqueue_segmentation(
db=db,
project_id=payload.project_id,
dataset_id=payload.dataset_id,
model_id=payload.model_id,
confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path,
parameters_json=payload.parameters_json,
)
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
def list_segmentation_runs(
request: Request,
project_id: UUID | None = None,
dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
project_id = guest_scoped_project_filter(request, project_id)
return envelope(
SegmentationService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
).model_dump()
)
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
def get_segmentation_run(
analysis_run_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(run.model_dump())
@router.get(
"/runs/{analysis_run_id}/segmentations",
response_model=Envelope[SegmentationListResponse],
)
def list_segmentation_run_outputs(
analysis_run_id: UUID,
request: Request,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.list_segmentations(
db,
limit=limit,
offset=offset,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
).model_dump()
)
@router.get(
"/datasets/{dataset_id}/segmentations",
response_model=Envelope[SegmentationListResponse],
)
def list_dataset_segmentations(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
SegmentationService.list_segmentations(
db,
limit=limit,
offset=offset,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
).model_dump()
)
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
def get_segmentation(
segmentation_id: UUID,
request: Request,
db: Session = Depends(get_db),
) -> dict:
segmentation = SegmentationService.get_segmentation(db, segmentation_id)
assert_guest_project_scope(request, segmentation.project_id)
return envelope(segmentation.model_dump())
@router.get(
"/runs/{analysis_run_id}/geojson",
response_model=Envelope[GeoJsonFeatureCollection],
)
def get_segmentation_run_geojson(
analysis_run_id: UUID,
request: Request,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.segmentations_to_geojson(
db,
limit=limit,
analysis_run_id=analysis_run_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.get(
"/datasets/{dataset_id}/geojson",
response_model=Envelope[GeoJsonFeatureCollection],
)
def get_dataset_segmentation_geojson(
dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
default=DetectionService.DEFAULT_RESULT_LIMIT,
ge=0,
le=50_000,
description="Maximum results to return; 0 returns everything. Highest confidence first.",
),
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
dataset = DatasetService.get_dataset(db, dataset_id)
assert_guest_project_scope(request, dataset.project_id)
return envelope(
SegmentationService.segmentations_to_geojson(
db,
limit=limit,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name=class_name,
min_confidence=min_confidence,
)
)
@router.post(
"/runs/{analysis_run_id}/qa/reference",
response_model=Envelope[AnalysisQaResponse],
)
def compare_segmentation_run_with_reference(
analysis_run_id: UUID,
payload: SegmentationQaRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
if guest_project_scope(request) is not None:
run = SegmentationService.get_run(db, analysis_run_id)
assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.compare_segmentations_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=payload.reference_dataset_id,
iou_threshold=payload.iou_threshold,
class_name=payload.class_name,
min_confidence=payload.min_confidence,
calibration_thresholds=payload.calibration_thresholds,
)
)
@@ -0,0 +1,87 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Area, Dataset
from app.schemas.common import Envelope
from app.schemas.operations import VectorSelectionResponse
from app.schemas.selection_partitions import VectorPartitionSelectionRequest
from app.services.vector_feature_service import VectorFeatureService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["selection-partitions"])
def _product_identity(dataset: Dataset) -> str:
metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
return str(metadata.get("product_key") or dataset.reference_layer_name or "")
@router.post(
"/datasets/vector/partitions/select",
response_model=Envelope[VectorSelectionResponse],
)
def select_vector_partitions(
project_id: UUID,
payload: VectorPartitionSelectionRequest,
db: Session = Depends(get_db),
):
datasets = db.query(Dataset).filter(Dataset.id.in_(payload.dataset_ids)).all()
by_id = {dataset.id: dataset for dataset in datasets}
ordered = [by_id.get(dataset_id) for dataset_id in payload.dataset_ids]
if any(dataset is None or dataset.project_id != project_id for dataset in ordered):
raise AppError(code="DATASET_NOT_FOUND", message="One or more selection partitions were not found", status_code=404)
typed_datasets = [dataset for dataset in ordered if dataset is not None]
if any(dataset.dataset_type not in {"vector", "geojson"} or dataset.status != "ready" for dataset in typed_datasets):
raise AppError(
code="INVALID_VECTOR_PARTITIONS",
message="Every selection partition must be a ready vector dataset",
status_code=409,
)
source_names = {dataset.source_name for dataset in typed_datasets}
product_keys = {_product_identity(dataset) for dataset in typed_datasets}
if len(source_names) != 1 or len(product_keys) != 1:
raise AppError(
code="VECTOR_PARTITION_SOURCE_MISMATCH",
message="Selection partitions must belong to one governed source product",
details={"source_names": sorted(str(value) for value in source_names), "product_keys": sorted(product_keys)},
status_code=409,
)
selection_geometry = None
selection_area_id = None
if payload.area_id is not None:
selection_area = db.get(Area, payload.area_id)
if selection_area is None or selection_area.project_id != project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
payload.bbox.model_dump(),
selection_area.geometry,
)
selection_area_id = selection_area.id
representative = typed_datasets[0]
dataset_ids = [dataset.id for dataset in typed_datasets]
result = VectorFeatureService.select_features_by_bbox(
db,
dataset_id=representative.id,
dataset_ids=dataset_ids,
bbox=payload.bbox.model_dump(),
limit=payload.limit,
dataset=representative,
selection_geometry=selection_geometry,
selection_area_id=selection_area_id,
deduplicate_source_features=True,
)
result.update(
partition_count=len(dataset_ids),
source_name=representative.source_name,
dataset_ids=dataset_ids,
)
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Dataset, DatasetLineageEdge, DatasetQuarantine, Project, SourceRegistry, SourceSnapshot
from app.schemas import (
DatasetLineageEdgeRead,
DatasetProvenanceRead,
DatasetQuarantineRead,
Envelope,
ItemList,
SourceRegistryDetailRead,
SourceRegistryRead,
SourceSnapshotRead,
)
from app.utils.response import envelope
router = APIRouter(tags=["source-registry"])
def _source_read(source: SourceRegistry, *, snapshot_count: int = 0) -> SourceRegistryRead:
return SourceRegistryRead.model_validate(source).model_copy(update={"snapshot_count": int(snapshot_count)})
@router.get("/source-registry", response_model=Envelope[ItemList[SourceRegistryRead]])
def list_source_registry(
classification: str | None = None,
db: Session = Depends(get_db),
) -> dict:
query = (
db.query(SourceRegistry, func.count(SourceSnapshot.id).label("snapshot_count"))
.outerjoin(SourceSnapshot, SourceSnapshot.source_registry_id == SourceRegistry.id)
)
if classification:
query = query.filter(SourceRegistry.classification == classification.strip().lower())
rows = (
query.group_by(SourceRegistry.id)
.order_by(SourceRegistry.classification.asc(), SourceRegistry.display_name.asc())
.all()
)
items = [_source_read(source, snapshot_count=count) for source, count in rows]
return envelope({"items": items, "total": len(items)})
@router.get("/source-registry/{source_key}", response_model=Envelope[SourceRegistryDetailRead])
def get_source_registry_entry(source_key: str, db: Session = Depends(get_db)) -> dict:
normalized_key = source_key.strip().lower()
source = db.query(SourceRegistry).filter(SourceRegistry.source_key == normalized_key).one_or_none()
if source is None:
raise AppError(code="SOURCE_REGISTRY_ENTRY_NOT_FOUND", message="Source registry entry was not found", status_code=404)
snapshots = (
db.query(SourceSnapshot)
.filter(SourceSnapshot.source_registry_id == source.id)
.order_by(SourceSnapshot.fetched_at.desc(), SourceSnapshot.created_at.desc())
.all()
)
detail = SourceRegistryDetailRead(
source=_source_read(source, snapshot_count=len(snapshots)),
snapshots=[SourceSnapshotRead.model_validate(snapshot) for snapshot in snapshots],
)
return envelope(detail)
@router.get(
"/projects/{project_id}/datasets/{dataset_id}/provenance",
response_model=Envelope[DatasetProvenanceRead],
)
def get_dataset_provenance(
project_id: UUID,
dataset_id: UUID,
db: Session = Depends(get_db),
) -> dict:
if db.get(Project, project_id) is None:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
dataset = db.get(Dataset, dataset_id)
if dataset is None or dataset.project_id != project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
source = db.get(SourceRegistry, dataset.source_registry_id) if dataset.source_registry_id else None
snapshot = db.get(SourceSnapshot, dataset.source_snapshot_id) if dataset.source_snapshot_id else None
lineage = (
db.query(DatasetLineageEdge)
.filter(
or_(
DatasetLineageEdge.parent_dataset_id == dataset.id,
DatasetLineageEdge.child_dataset_id == dataset.id,
)
)
.order_by(DatasetLineageEdge.created_at.asc(), DatasetLineageEdge.id.asc())
.all()
)
quarantines = (
db.query(DatasetQuarantine)
.filter(DatasetQuarantine.dataset_id == dataset.id)
.order_by(DatasetQuarantine.created_at.desc(), DatasetQuarantine.id.desc())
.all()
)
result = DatasetProvenanceRead(
dataset_id=dataset.id,
source=_source_read(source) if source else None,
snapshot=SourceSnapshotRead.model_validate(snapshot) if snapshot else None,
data_contract_key=dataset.data_contract_key,
data_contract_version=dataset.data_contract_version,
validation_status=dataset.validation_status,
validation_report_json=dataset.validation_report_json,
provenance_status=dataset.provenance_status,
lineage_status=dataset.lineage_status,
quarantine_status=dataset.quarantine_status,
lineage=[DatasetLineageEdgeRead.model_validate(item) for item in lineage],
quarantines=[DatasetQuarantineRead.model_validate(item) for item in quarantines],
)
return envelope(result)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas import Envelope, ItemList
from app.schemas.temporal import (
TemporalComparisonRequest,
TemporalComparisonResponse,
TemporalSeriesRead,
)
from app.services.temporal_analysis_service import TemporalAnalysisService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}/temporal", tags=["temporal"])
@router.get("/series", response_model=Envelope[ItemList[TemporalSeriesRead]])
def list_temporal_series(project_id: UUID, db: Session = Depends(get_db)):
series = TemporalAnalysisService.list_series(db, project_id)
return envelope({"items": [item.model_dump() for item in series], "total": len(series)})
@router.post("/compare", response_model=Envelope[TemporalComparisonResponse])
def compare_temporal_snapshots(
project_id: UUID,
payload: TemporalComparisonRequest,
db: Session = Depends(get_db),
):
return envelope(TemporalAnalysisService.compare(db, project_id=project_id, payload=payload).model_dump())