All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
Closes the loop on the Phase 5 "Run now, templatize after resolve" path.
After a session resolves, drafts queued by the three-option dialog surface
as a modal that lets the engineer review the AI-proposed parameterization
and either save as a reusable team template or skip. A "don't ask again"
toggle writes to account_settings.preferences so the next resolve won't
pop the modal.
Backend:
- /api/v1/draft-templates:
* GET — list account drafts (pending_only default true; pass false for
audit view including accepted/rejected)
* GET /{id} — single draft
* POST /{id}/accept — promotes to a new script_templates row with
source_session_id / source_user_id / source_ticket_ref populated
(drives the Script Library "generated from CW #X · resolved by Y"
provenance chip). Draft flips to status=accepted,
promoted_template_id set, resolved_at stamped. 409 on re-accept /
already-rejected. 400 on unknown category_id.
* POST /{id}/reject — flips to status=rejected. 409 on re-reject.
- /api/v1/accounts/me/preferences (GET/PATCH) — thin wrapper over
AccountSettings.get_setting/set_setting. PATCH merges keys into the
JSONB column, preserving existing keys the client didn't touch.
Used by the "Don't ask again for this team" checkbox
(templatize_prompt_enabled=false) and, forward-looking, by
cw_resolved_status_id / cw_escalated_status_id from Phase 4.
- 13 tests: list filter, accept with/without edited_body, provenance
copy-through, reject, 409 on re-accept / re-reject, 400 on unknown
category, prefs round-trip with merge semantics.
Frontend:
- src/components/pilot/script/TemplatizePrompt.tsx — modal showing the
drafted script with proposed parameters in the Phase 5
ParameterizationPreview, editable name/category/description, an
individual-parameter remove button, and the "don't ask again" opt-out.
Accept posts to /draft-templates/{id}/accept + optionally PATCHes
preferences. Skip posts /reject.
- src/api/draftTemplates.ts — typed client plus accountPreferencesApi.
- AssistantChatPage: after a successful Resolve (external OR local),
fetches preferences + pending drafts for the session and queues the
modal one draft at a time. Escalate does not trigger this flow.
- Sidebar: Scripts nav shows the pending-draft count as a badge. Fetched
independently of the main sidebar stats so endpoint flakes don't
break the rest of the sidebar.
Verified live 2026-04-22: seed two drafts → GET sees both pending →
accept draft A (template created, provenance CW #99123 populated) →
reject draft B → pending count drops → PATCH opt-out → GET confirms
persistence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
8.4 KiB
Python
222 lines
8.4 KiB
Python
"""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
|