import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import update from app.database import get_db from app.utils.auth import require_global_admin from app.domains.tenants.schemas import ( TenantCreate, TenantUpdate, TenantOut, TenantAIConfigUpdate, TenantAIConfigOut, ) from app.domains.tenants import service from app.domains.tenants.models import Tenant router = APIRouter(prefix="/tenants", tags=["tenants"]) @router.get("/", response_model=list[TenantOut]) async def list_tenants( db: AsyncSession = Depends(get_db), _: object = Depends(require_global_admin), ): rows = await service.list_tenants(db) result = [] for row in rows: tenant = row["tenant"] out = TenantOut.model_validate(tenant) out.user_count = row["user_count"] result.append(out) return result @router.get("/{tenant_id}", response_model=TenantOut) async def get_tenant( tenant_id: uuid.UUID, db: AsyncSession = Depends(get_db), _: object = Depends(require_global_admin), ): tenant = await service.get_tenant(db, tenant_id) if not tenant: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") return TenantOut.model_validate(tenant) @router.post("/", response_model=TenantOut, status_code=status.HTTP_201_CREATED) async def create_tenant( body: TenantCreate, db: AsyncSession = Depends(get_db), _: object = Depends(require_global_admin), ): tenant = await service.create_tenant(db, name=body.name, slug=body.slug, is_active=body.is_active) return TenantOut.model_validate(tenant) @router.put("/{tenant_id}", response_model=TenantOut) async def update_tenant( tenant_id: uuid.UUID, body: TenantUpdate, db: AsyncSession = Depends(get_db), _: object = Depends(require_global_admin), ): tenant = await service.update_tenant( db, tenant_id, name=body.name, slug=body.slug, is_active=body.is_active, ) if not tenant: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") return TenantOut.model_validate(tenant) @router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_tenant( tenant_id: uuid.UUID, db: AsyncSession = Depends(get_db), _: object = Depends(require_global_admin), ): ok = await service.delete_tenant(db, tenant_id) if not ok: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Tenant not found or still has users assigned", ) # --------------------------------------------------------------------------- # Per-tenant Azure AI configuration # --------------------------------------------------------------------------- def _tenant_ai_config_out(tenant: Tenant) -> TenantAIConfigOut: """Build TenantAIConfigOut from a Tenant ORM object (never exposes api_key).""" cfg = tenant.tenant_config or {} return TenantAIConfigOut( ai_enabled=cfg.get("ai_enabled", False), ai_endpoint=cfg.get("ai_endpoint"), ai_deployment=cfg.get("ai_deployment", "gpt-4o"), ai_api_version=cfg.get("ai_api_version", "2024-02-01"), has_api_key=bool(cfg.get("ai_api_key")), ai_max_tokens=cfg.get("ai_max_tokens", 500), ai_temperature=cfg.get("ai_temperature", 0.1), ai_validation_prompt=cfg.get("ai_validation_prompt"), ) @router.get("/{tenant_id}/ai-config", response_model=TenantAIConfigOut) async def get_tenant_ai_config( tenant_id: uuid.UUID, db: AsyncSession = Depends(get_db), _: object = Depends(require_global_admin), ): """Return AI config for a tenant (without the raw api_key).""" tenant = await service.get_tenant(db, tenant_id) if not tenant: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") return _tenant_ai_config_out(tenant) @router.put("/{tenant_id}/ai-config", response_model=TenantAIConfigOut) async def update_tenant_ai_config( tenant_id: uuid.UUID, body: TenantAIConfigUpdate, db: AsyncSession = Depends(get_db), _: object = Depends(require_global_admin), ): """Merge AI configuration into tenant_config JSONB. If ai_api_key is None in the request body, the existing key is preserved. """ tenant = await service.get_tenant(db, tenant_id) if not tenant: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") # Build the merged config dict current_cfg: dict = dict(tenant.tenant_config or {}) current_cfg["ai_enabled"] = body.ai_enabled current_cfg["ai_endpoint"] = body.ai_endpoint current_cfg["ai_deployment"] = body.ai_deployment current_cfg["ai_api_version"] = body.ai_api_version current_cfg["ai_max_tokens"] = body.ai_max_tokens current_cfg["ai_temperature"] = body.ai_temperature current_cfg["ai_validation_prompt"] = body.ai_validation_prompt # Only overwrite the stored key if a new value was actually provided if body.ai_api_key is not None: current_cfg["ai_api_key"] = body.ai_api_key # Use direct SQL UPDATE to reliably persist JSONB mutations await db.execute( update(Tenant) .where(Tenant.id == tenant_id) .values(tenant_config=current_cfg) ) await db.commit() await db.refresh(tenant) return _tenant_ai_config_out(tenant) @router.post("/{tenant_id}/ai-config/test") async def test_tenant_ai_config( tenant_id: uuid.UUID, db: AsyncSession = Depends(get_db), _: object = Depends(require_global_admin), ): """Send a minimal ping to Azure OpenAI using the tenant's stored credentials. Returns {"ok": true} or {"ok": false, "error": "human readable message"}. """ tenant = await service.get_tenant(db, tenant_id) if not tenant: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") cfg = tenant.tenant_config or {} api_key = cfg.get("ai_api_key") endpoint = cfg.get("ai_endpoint") deployment = cfg.get("ai_deployment", "gpt-4o") api_version = cfg.get("ai_api_version", "2024-02-01") if not api_key: return {"ok": False, "error": "No API key configured for this tenant"} if not endpoint: return {"ok": False, "error": "No endpoint configured for this tenant"} import asyncio def _ping() -> dict: try: from openai import AzureOpenAI client = AzureOpenAI( api_key=api_key, azure_endpoint=endpoint, api_version=api_version, ) client.chat.completions.create( model=deployment, messages=[{"role": "user", "content": "ping"}], max_completion_tokens=5, ) return {"ok": True} except Exception as exc: return {"ok": False, "error": str(exc)} return await asyncio.to_thread(_ping)