577dd1ca7e
- 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>
203 lines
6.9 KiB
Python
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)
|