Files
breakpilot-compliance/backend-compliance/compliance/api/compliance_scope_routes.py
Sharang Parnerkar 86588aff09 Fix SQLAlchemy 2.x compatibility: wrap raw SQL in text()
SQLAlchemy 2.x requires raw SQL strings to be explicitly wrapped
in text(). Fixed 16 instances across 5 route files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:52 +01:00

136 lines
4.3 KiB
Python

"""
FastAPI routes for Compliance Scope persistence.
Stores the tenant's scope decision (frameworks, regulations, industry context)
in sdk_states.state->compliance_scope as JSONB.
Endpoints:
- GET /v1/compliance-scope?tenant_id=... → returns scope or 404
- POST /v1/compliance-scope → UPSERT scope (idempotent)
"""
import json
import logging
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel
from sqlalchemy import text
from database import SessionLocal
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/compliance-scope", tags=["compliance-scope"])
# =============================================================================
# REQUEST / RESPONSE MODELS
# =============================================================================
class ComplianceScopeRequest(BaseModel):
"""Scope selection submitted by the frontend wizard."""
scope: dict[str, Any]
tenant_id: Optional[str] = None
class ComplianceScopeResponse(BaseModel):
"""Persisted scope object returned to the frontend."""
tenant_id: str
scope: dict[str, Any]
updated_at: str
created_at: str
# =============================================================================
# HELPERS
# =============================================================================
def _get_tid(
x_tenant_id: Optional[str],
query_tenant_id: str,
) -> str:
return x_tenant_id or query_tenant_id or "default"
def _row_to_response(row) -> ComplianceScopeResponse:
"""Convert a DB row (tenant_id, scope, created_at, updated_at) to response."""
return ComplianceScopeResponse(
tenant_id=row[0],
scope=row[1] if isinstance(row[1], dict) else {},
created_at=str(row[2]),
updated_at=str(row[3]),
)
# =============================================================================
# ROUTES
# =============================================================================
@router.get("", response_model=ComplianceScopeResponse)
async def get_compliance_scope(
tenant_id: str = "default",
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
):
"""Return the persisted compliance scope for a tenant, or 404 if not set."""
tid = _get_tid(x_tenant_id, tenant_id)
db = SessionLocal()
try:
row = db.execute(
text("""SELECT tenant_id,
state->'compliance_scope' AS scope,
created_at,
updated_at
FROM sdk_states
WHERE tenant_id = :tid
AND state ? 'compliance_scope'"""),
{"tid": tid},
).fetchone()
if not row or row[1] is None:
raise HTTPException(status_code=404, detail="Compliance scope not found")
return _row_to_response(row)
finally:
db.close()
@router.post("", response_model=ComplianceScopeResponse)
async def upsert_compliance_scope(
body: ComplianceScopeRequest,
tenant_id: str = "default",
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
):
"""Create or update the compliance scope for a tenant (UPSERT)."""
tid = _get_tid(x_tenant_id, body.tenant_id or tenant_id)
scope_json = json.dumps(body.scope)
db = SessionLocal()
try:
db.execute(
text("""INSERT INTO sdk_states (tenant_id, state)
VALUES (:tid, jsonb_build_object('compliance_scope', :scope::jsonb))
ON CONFLICT (tenant_id) DO UPDATE
SET state = sdk_states.state || jsonb_build_object('compliance_scope', :scope::jsonb),
updated_at = NOW()"""),
{"tid": tid, "scope": scope_json},
)
db.commit()
row = db.execute(
text("""SELECT tenant_id,
state->'compliance_scope' AS scope,
created_at,
updated_at
FROM sdk_states
WHERE tenant_id = :tid"""),
{"tid": tid},
).fetchone()
return _row_to_response(row)
except Exception as e:
db.rollback()
logger.error(f"Failed to upsert compliance scope: {e}")
raise HTTPException(status_code=500, detail="Failed to save compliance scope")
finally:
db.close()