"""HTTP surface for the AI tool / function introduction request flow. Three audience tiers: - ``POST /`` — submit (any authenticated employee) - ``GET /`` — list, scoped: mine / all (admin only) / auto (admin → all, employee → mine) - ``GET /approved`` — public listing of approved tools, visible to every authenticated employee. This is the company-wide registry the user asked for, so it intentionally has no admin gate. - ``POST /{id}/cancel`` — applicant cancels a pending row. """ from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy import func as _func from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( get_current_user, ) from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.core.response import success_response from app.domains.ai_tool_application.models import ( AI_TOOL_DATA_LEVEL_OPTIONS, AI_TOOL_HOW_TO_USE_OPTIONS, AI_TOOL_STATUS_APPROVED, AI_TOOL_STATUS_CANCELLED, AI_TOOL_STATUS_PENDING, AI_TOOL_STATUS_REJECTED, AiToolApplication, ) from app.domains.ai_tool_application.service import ( AiToolApplicationService, ) from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/ai-tool-applications", tags=["ai-tool-application"], # Pre-launch hardening 2026-05-04: leaver_pending users cannot # submit / cancel AI tool applications. dependencies=[Depends(_leaver_runtime_gate)], ) class AiToolApplicationSubmitBody(BaseModel): ai_name: str = Field(min_length=1, max_length=255) how_to_use: list[str] = Field(min_length=1) data_level: list[str] = Field(min_length=1) purpose: str = Field(min_length=1, max_length=4000) data_type: str = Field(min_length=1, max_length=4000) privacy_policy: str = Field(min_length=1, max_length=4000) def _serialize( row: AiToolApplication, persons: dict[str, IdentityPerson], *, public: bool = False, ) -> dict[str, Any]: holder = persons.get(row.person_id) if row.person_id else None person_label = ( (holder.display_name or holder.work_email) if holder else row.legacy_applicant_name ) department = ( holder.department if holder else row.legacy_applicant_department ) base: dict[str, Any] = { "id": row.id, "ai_name": row.ai_name, "how_to_use": [ s.strip() for s in row.how_to_use.split(",") if s.strip() ], "data_level": [ s.strip() for s in row.data_level.split(",") if s.strip() ], "purpose": row.purpose, "data_type": row.data_type, "privacy_policy": row.privacy_policy, "attachment_note": row.attachment_note, "status": row.status, "person_label": person_label, "department": department, "granted_at": row.granted_at.isoformat() if row.granted_at else None, "created_at": row.created_at.isoformat(), } if public: return base base.update({ "person_id": row.person_id, "feishu_instance_code": row.feishu_instance_code, "legacy_request_no": row.legacy_request_no, "updated_at": row.updated_at.isoformat(), }) return base async def _hydrate( db: AsyncSession, rows: list[AiToolApplication], *, public: bool = False, ) -> list[dict[str, Any]]: if not rows: return [] person_ids = {r.person_id for r in rows if r.person_id} persons: dict[str, IdentityPerson] = {} if person_ids: rows_p = (await db.execute( select(IdentityPerson).where( IdentityPerson.id.in_([uuid.UUID(p) for p in person_ids]), ) )).scalars().all() persons = {str(p.id): p for p in rows_p} return [_serialize(r, persons, public=public) for r in rows] @router.post("", status_code=201) async def submit( body: AiToolApplicationSubmitBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AiToolApplicationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.submit( requester=user, ai_name=body.ai_name, how_to_use=body.how_to_use, data_level=body.data_level, purpose=body.purpose, data_type=body.data_type, privacy_policy=body.privacy_policy, trace_id=trace_id, ) await db.commit() [data] = await _hydrate(db, [row]) return success_response(data=data, request=request) @router.get("") async def list_applications( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), scope: str = Query("auto", pattern="^(auto|mine|all)$"), status: str | None = Query( None, pattern=( f"^({AI_TOOL_STATUS_PENDING}|{AI_TOOL_STATUS_APPROVED}|" f"{AI_TOOL_STATUS_REJECTED}|{AI_TOOL_STATUS_CANCELLED})$" ), ), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ): is_admin = has_role(user.role_names, GLOBAL_ADMIN_ROLE) if scope == "all" and not is_admin: scope = "mine" if scope == "auto": scope = "all" if is_admin else "mine" q = select(AiToolApplication) if scope == "mine": q = q.where(AiToolApplication.person_id == str(user.id)) if status: q = q.where(AiToolApplication.status == status) count_q = select(_func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar() or 0 q = ( q.order_by(AiToolApplication.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) rows = (await db.execute(q)).scalars().all() return success_response( data=await _hydrate(db, list(rows)), request=request, meta={"total": total, "page": page, "page_size": page_size}, ) @router.get("/approved") async def list_approved( request: Request, _user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), keyword: str | None = Query(None, max_length=128), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ): """Public registry of approved AI tools, visible to every authenticated employee. No admin gate — this is the company-wide list the user asked us to expose.""" q = select(AiToolApplication).where( AiToolApplication.status == AI_TOOL_STATUS_APPROVED, ) if keyword: like = f"%{keyword.strip()}%" q = q.where(AiToolApplication.ai_name.ilike(like)) count_q = select(_func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar() or 0 # MySQL/TiDB don't support ``NULLS LAST`` — use the standard # MySQL idiom (sort by IS NULL first to push NULLs to the end). q = ( q.order_by( AiToolApplication.granted_at.is_(None), AiToolApplication.granted_at.desc(), AiToolApplication.created_at.desc(), ) .offset((page - 1) * page_size) .limit(page_size) ) rows = (await db.execute(q)).scalars().all() return success_response( data=await _hydrate(db, list(rows), public=True), request=request, meta={ "total": total, "page": page, "page_size": page_size, "how_to_use_options": list(AI_TOOL_HOW_TO_USE_OPTIONS), "data_level_options": list(AI_TOOL_DATA_LEVEL_OPTIONS), }, ) @router.post("/{app_id}/cancel") async def cancel( app_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AiToolApplicationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.cancel(app_id=app_id, actor=user, trace_id=trace_id) await db.commit() [data] = await _hydrate(db, [row]) # False ⇒ Helix cancelled but the parallel Feishu instance is still # pending (e.g. template withdraw disabled) — frontend warns. data["feishu_cancel_synced"] = svc.last_feishu_cancel_synced return success_response(data=data, request=request) class _HandoverBody(BaseModel): successor_person_id: str = Field(..., min_length=1) class _RevokeBody(BaseModel): reason: str | None = Field(None, max_length=255) class _BatchHandoverBody(BaseModel): app_ids: list[str] = Field(..., min_length=1, max_length=200) successor_person_id: str = Field(..., min_length=1) def _require_admin(user: AuthUser) -> None: from app.core.exceptions import ForbiddenError if not has_role(user.role_names, GLOBAL_ADMIN_ROLE): raise ForbiddenError("This action requires global_admin.") @router.post("/{app_id}/revoke") async def revoke( app_id: str, body: _RevokeBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Admin revokes an APPROVED AI tool seat (lifecycle-verb parity — previously only reachable via the offboarding force-revoke path).""" _require_admin(user) svc = AiToolApplicationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.revoke(app_id=app_id, actor=user, reason=body.reason, trace_id=trace_id) await db.commit() [data] = await _hydrate(db, [row]) return success_response(data=data, request=request) @router.post("/{app_id}/handover") async def handover( app_id: str, body: _HandoverBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Transfer an APPROVED AI tool seat to an active colleague. Allowed for the current holder (peer transfer on role change) or an admin.""" svc = AiToolApplicationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") # Owner-or-admin: the service double-checks state; the owner check # mirrors the cancel path so a non-owner non-admin can't move someone # else's seat. from sqlalchemy import select as _select existing = (await db.execute( _select(AiToolApplication).where(AiToolApplication.id == app_id) )).scalar_one_or_none() if existing is None: from app.core.exceptions import NotFoundError raise NotFoundError("AiToolApplication", app_id) if existing.person_id != str(user.id) and not has_role(user.role_names, GLOBAL_ADMIN_ROLE): from app.core.exceptions import ForbiddenError raise ForbiddenError("Only the current holder or an admin can hand over this seat.") row = await svc.handover( app_id=app_id, successor_person_id=body.successor_person_id, actor=user, trace_id=trace_id, ) await db.commit() [data] = await _hydrate(db, [row]) return success_response(data=data, request=request) @router.post("/batch-handover") async def batch_handover( body: _BatchHandoverBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Admin hands over multiple AI tool seats to one successor (e.g. a departing team lead's seats). Per-row SAVEPOINT isolation + idempotent skipped reporting, same contract as the other batch endpoints.""" _require_admin(user) svc = AiToolApplicationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") succeeded: list[str] = [] skipped: list[dict[str, str]] = [] failed: list[dict[str, str]] = [] for app_id in body.app_ids: try: async with db.begin_nested(): await svc.handover( app_id=app_id, successor_person_id=body.successor_person_id, actor=user, trace_id=trace_id, ) succeeded.append(app_id) except Exception as e: # noqa: BLE001 # 1205/1213 roll back the whole tx server-side; swallowing it # cascades into a SAVEPOINT-gone skip of every remaining seat. # Re-raise so the caller can retry on a fresh transaction. from app.core.exceptions import is_transaction_aborting_db_error if is_transaction_aborting_db_error(e): raise code = getattr(e, "code", None) or type(e).__name__ if code in ("AI_TOOL_SAME_OWNER", "AI_TOOL_NOT_HANDOVERABLE"): skipped.append({"app_id": app_id, "reason": code}) else: failed.append({"app_id": app_id, "reason": code}) await db.commit() return success_response( data={"succeeded": succeeded, "skipped": skipped, "failed": failed}, request=request, )