Files
site/apps/api/app/modules/sync/router.py
T

179 lines
6.0 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.core.dependencies import require_superuser
from app.modules.sync import service
from app.modules.sync.schemas import (
AckChangesRequest,
AckChangesResponse,
HeartbeatRequest,
HeartbeatResponse,
PairConfirmRequest,
PairConfirmResponse,
PairStartRequest,
PullChangesRequest,
PullChangesResponse,
PushChangesRequest,
PushChangesResponse,
ResolveConflictRequest,
SyncCapabilitiesResponse,
)
from app.modules.sync.service import SyncServiceError
from app.modules.sync.tenant import (
HubPrincipal,
TenantContext,
get_hub_auth,
get_tenant_context,
require_enterprise_zootech,
)
from app.modules.users.models import User
from app.core.dependencies import get_current_user
router = APIRouter()
def _map_error(exc: SyncServiceError) -> HTTPException:
code = str(exc)
status_code = status.HTTP_400_BAD_REQUEST
if code in {"ENTERPRISE_ADMIN_ONLY", "ENTERPRISE_FORBIDDEN", "ENTERPRISE_ZOOTECH_ONLY"}:
status_code = status.HTTP_403_FORBIDDEN
if code in {"INVALID_PAIRING_CODE", "PAIRING_CODE_EXPIRED"}:
status_code = status.HTTP_400_BAD_REQUEST
if code == "CONFLICT_NOT_FOUND":
status_code = status.HTTP_404_NOT_FOUND
return HTTPException(status_code=status_code, detail=code)
@router.post("/reports/push")
def reports_push(body: PushChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PushChangesResponse:
"""Report domain ingest — same durability path as global push."""
return service.push_changes(hub, body)
@router.post("/reports/refresh")
def reports_refresh(
farm_hub_id: str = Query(...),
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
if tenant.farm_hub_ids is not None and farm_hub_id not in tenant.farm_hub_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FARM_ACCESS_FORBIDDEN")
return {"status": "queued", "farm_hub_id": farm_hub_id, "enterprise_id": tenant.enterprise_id}
@router.get("/metrics")
def sync_metrics(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
if tenant.enterprise_id != enterprise_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
return service.get_sync_metrics(enterprise_id)
@router.get("/capabilities", response_model=SyncCapabilitiesResponse)
def capabilities() -> SyncCapabilitiesResponse:
return service.get_capabilities()
@router.post("/changes/push", response_model=PushChangesResponse)
def push_changes(body: PushChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PushChangesResponse:
return service.push_changes(hub, body)
@router.post("/changes/pull", response_model=PullChangesResponse)
def pull_changes(body: PullChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PullChangesResponse:
return service.pull_changes(hub, body)
@router.post("/changes/ack", response_model=AckChangesResponse)
def ack_changes(body: AckChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> AckChangesResponse:
return service.ack_changes(hub, body)
@router.post("/hubs/heartbeat", response_model=HeartbeatResponse)
def heartbeat(body: HeartbeatRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> HeartbeatResponse:
return service.hub_heartbeat(hub, body)
@router.get("/hub/conflicts")
def list_hub_conflicts(hub: HubPrincipal = Depends(get_hub_auth)):
return service.list_conflicts_for_enterprise(hub.enterprise_id)
@router.get("/hub/conflicts/{conflict_id}")
def get_hub_conflict(conflict_id: str, hub: HubPrincipal = Depends(get_hub_auth)):
try:
return service.get_conflict_detail(conflict_id, hub.enterprise_id)
except SyncServiceError as exc:
raise _map_error(exc) from exc
@router.post("/hub/conflicts/{conflict_id}/resolve")
def resolve_hub_conflict(
conflict_id: str,
body: ResolveConflictRequest,
hub: HubPrincipal = Depends(get_hub_auth),
):
try:
service.resolve_conflict(conflict_id, hub.enterprise_id, f"hub:{hub.hub_site_id}", body)
return {"status": "ok"}
except SyncServiceError as exc:
raise _map_error(exc) from exc
@router.get("/conflicts")
def list_conflicts(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
if tenant.enterprise_id != enterprise_id and tenant.enterprise_role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
return service.list_conflicts_for_enterprise(enterprise_id)
@router.get("/conflicts/{conflict_id}")
def get_conflict(
conflict_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
try:
return service.get_conflict_detail(conflict_id, enterprise_id)
except SyncServiceError as exc:
raise _map_error(exc) from exc
@router.post("/conflicts/{conflict_id}/resolve")
def resolve_conflict(
conflict_id: str,
body: ResolveConflictRequest,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
try:
service.resolve_conflict(conflict_id, enterprise_id, tenant.user_id or "", body)
return {"status": "ok"}
except SyncServiceError as exc:
raise _map_error(exc) from exc
enterprise_router = APIRouter()
@enterprise_router.post("/pair/start")
def pair_start(body: PairStartRequest, user: User = Depends(get_current_user)):
try:
return service.start_pairing(user, body)
except SyncServiceError as exc:
raise _map_error(exc) from exc
@enterprise_router.post("/pair/confirm", response_model=PairConfirmResponse)
def pair_confirm(body: PairConfirmRequest) -> PairConfirmResponse:
try:
return service.confirm_pairing(body)
except SyncServiceError as exc:
raise _map_error(exc) from exc