"""Draft template endpoints — Phase 6 post-resolve templatization flow. Engineers who picked "Run now, templatize after resolve" on the three-option dialog (Phase 5) generate a `draft_templates` row at decision time. After the session resolves, the TemplatizePrompt component lets them either: - Accept → promotes the draft to a real `script_templates` row - Reject → marks the draft rejected, no library entry created The Script Library sidebar uses the list endpoint to surface a "X drafts ready to review" badge for the account. See FLOWPILOT-MIGRATION.md Section 5.3. """ import logging import re from datetime import datetime, timezone from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin from app.models.ai_session import AISession from app.models.draft_template import DraftTemplate from app.models.script_template import ScriptCategory, ScriptTemplate from app.models.user import User from app.schemas.draft_template import ( DraftTemplateAcceptRequest, DraftTemplateAcceptResponse, DraftTemplateListResponse, DraftTemplateRejectResponse, DraftTemplateResponse, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/draft-templates", tags=["draft-templates"]) def _slugify(name: str) -> str: """Same slug rule as scripts.create_template — lowercase, kebab-case, ASCII.""" return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") # ── List ───────────────────────────────────────────────────────────────── @router.get("", response_model=DraftTemplateListResponse) async def list_drafts( current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_engineer_or_admin), pending_only: bool = True, ) -> DraftTemplateListResponse: """List drafts for the current user's account. Defaults to pending-only — that's what the Script Library badge counts and what the post-resolve TemplatizePrompt iterates over. Pass `pending_only=false` to include accepted/rejected for an audit view. """ stmt = select(DraftTemplate).order_by(DraftTemplate.created_at.desc()) if pending_only: stmt = stmt.where(DraftTemplate.status == "pending") result = await db.execute(stmt) drafts = list(result.scalars().all()) return DraftTemplateListResponse( drafts=[DraftTemplateResponse.model_validate(d) for d in drafts] ) # ── Get one ────────────────────────────────────────────────────────────── @router.get("/{draft_id}", response_model=DraftTemplateResponse) async def get_draft( draft_id: UUID, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_engineer_or_admin), ) -> DraftTemplateResponse: draft = await _load_draft_or_404(db, draft_id) return DraftTemplateResponse.model_validate(draft) # ── Accept ─────────────────────────────────────────────────────────────── @router.post( "/{draft_id}/accept", response_model=DraftTemplateAcceptResponse, status_code=201, ) async def accept_draft( draft_id: UUID, body: DraftTemplateAcceptRequest, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_engineer_or_admin), ) -> DraftTemplateAcceptResponse: """Promote a draft to a real `script_templates` row. Provenance fields (`source_session_id`, `source_user_id`, `source_ticket_ref`) are copied so the Script Library can render the "generated from CW #X · resolved by Y · used N times" chip. On success: draft.status='accepted', draft.promoted_template_id set, draft.resolved_at set. The new template is owned by the engineer's team (matches scripts.create_template's behavior). Returns 409 if the draft is already accepted/rejected. """ draft = await _load_draft_or_404(db, draft_id) if draft.status != "pending": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Draft is already {draft.status}", ) # Validate the category exists and belongs to (or is global for) this account. cat_result = await db.execute( select(ScriptCategory).where( ScriptCategory.id == body.category_id, ScriptCategory.is_active == True, # noqa: E712 ) ) if cat_result.scalar_one_or_none() is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="category_id does not reference an active script category", ) # Look up source-session ticket ref for the provenance chip. RLS makes # cross-account ai_session lookup impossible — the draft must belong to # the same account as the requesting user. source_session = ( await db.execute( select(AISession).where(AISession.id == draft.source_session_id) ) ).scalar_one_or_none() source_ticket_ref = ( f"CW #{source_session.psa_ticket_id}" if source_session and source_session.psa_ticket_id else None ) slug = _slugify(body.name) template = ScriptTemplate( category_id=body.category_id, team_id=current_user.team_id, account_id=current_user.account_id, created_by=current_user.id, name=body.name, slug=slug, description=body.description, script_body=body.edited_body or draft.script_body, parameters_schema=body.parameters_schema, # FlowPilot provenance — drives the Script Library chip. source_session_id=draft.source_session_id, source_user_id=draft.source_user_id, source_ticket_ref=source_ticket_ref, ) db.add(template) await db.flush() # populate template.id draft.status = "accepted" draft.promoted_template_id = template.id draft.resolved_at = datetime.now(timezone.utc) await db.commit() await db.refresh(template) return DraftTemplateAcceptResponse( draft_id=draft.id, promoted_template_id=template.id, template_slug=template.slug, ) # ── Reject ─────────────────────────────────────────────────────────────── @router.post("/{draft_id}/reject", response_model=DraftTemplateRejectResponse) async def reject_draft( draft_id: UUID, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_engineer_or_admin), ) -> DraftTemplateRejectResponse: """Mark a draft rejected. No template is created. The row stays for audit (so a team admin can see the engineer reviewed and explicitly declined). Returns 409 on a draft that's already accepted/rejected. """ draft = await _load_draft_or_404(db, draft_id) if draft.status != "pending": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Draft is already {draft.status}", ) draft.status = "rejected" draft.resolved_at = datetime.now(timezone.utc) await db.commit() return DraftTemplateRejectResponse(draft_id=draft.id, status="rejected") # ── Helpers ───────────────────────────────────────────────────────────── async def _load_draft_or_404( db: AsyncSession, draft_id: UUID ) -> DraftTemplate: """RLS-scoped draft load. 404 covers missing + cross-tenant.""" result = await db.execute( select(DraftTemplate).where(DraftTemplate.id == draft_id) ) draft = result.scalar_one_or_none() if draft is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Draft template not found", ) return draft