Files
HartOMat/backend/app/domains/tenants/router.py
T
Hartmut 577dd1ca7e refactor(P11+P12): codebase hygiene — CLAUDE.md rewrite, type safety, dead code removal
- Rewrite CLAUDE.md to match current 8-service architecture (was 11, 5 deleted)
- Remove all as-any casts in OrderDetail.tsx (9 casts → 0)
- Add cad_parsed_objects/cad_part_materials to OrderItem interface
- Rename require_admin → require_global_admin across 6 router files (22 calls)
- Remove EXPORT_GLB_PRODUCTION enum + generate_gltf_production_task (dead code)
- Remove worker-thumbnail from ALLOWED_SERVICES, replace Flamenco link
- Delete obsolete PLAN.md (1455 lines) and PLAN_REFACTOR.md (1174 lines)
- Fix digit-only USD prim names with p_ prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:22:04 +01:00

203 lines
6.9 KiB
Python

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_tokens=5,
)
return {"ok": True}
except Exception as exc:
return {"ok": False, "error": str(exc)}
return await asyncio.to_thread(_ping)