feat(pilot): Phase 6 — post-resolve templatize prompt + draft accept/reject
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
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>
This commit is contained in:
@@ -16,6 +16,7 @@ from app.models.refresh_token import RefreshToken
|
||||
from app.core.email import EmailService
|
||||
from app.models.account import Account
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.account_settings import AccountSettings
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User
|
||||
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
|
||||
@@ -559,3 +560,65 @@ async def get_sso_status(
|
||||
sso_enabled=account.sso_enabled,
|
||||
sso_provider=account.sso_provider,
|
||||
)
|
||||
|
||||
|
||||
# ─── Account Preferences (FlowPilot Phase 6) ──────────────────────────────────
|
||||
#
|
||||
# Preferences live in `account_settings.preferences` as a JSONB grab-bag
|
||||
# (per FLOWPILOT-MIGRATION.md Section 4.6). Rows are lazily created on first
|
||||
# write. Any engineer-role user can read + update preferences because the
|
||||
# keys stored here (templatize_prompt_enabled, cw_resolved_status_id, etc.)
|
||||
# are team-level toggles rather than account-owner-gated admin settings.
|
||||
|
||||
|
||||
class AccountPreferencesResponse(BaseModel):
|
||||
preferences: dict
|
||||
|
||||
|
||||
class AccountPreferencesUpdate(BaseModel):
|
||||
"""Merge-style update — each key in `preferences` overwrites that key in
|
||||
the stored JSONB, other keys are preserved. Omit the body entirely to
|
||||
no-op.
|
||||
"""
|
||||
preferences: dict
|
||||
|
||||
|
||||
@router.get("/me/preferences", response_model=AccountPreferencesResponse)
|
||||
async def get_my_preferences(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Return the current account's preferences JSONB (empty dict if no row)."""
|
||||
result = await db.execute(
|
||||
select(AccountSettings.preferences).where(
|
||||
AccountSettings.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
prefs = result.scalar_one_or_none() or {}
|
||||
return AccountPreferencesResponse(preferences=prefs)
|
||||
|
||||
|
||||
@router.patch("/me/preferences", response_model=AccountPreferencesResponse)
|
||||
async def update_my_preferences(
|
||||
data: AccountPreferencesUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Upsert preference keys. Existing keys not present in the payload are kept.
|
||||
|
||||
Example: posting `{"preferences": {"templatize_prompt_enabled": false}}`
|
||||
from the post-resolve "Don't ask me again for this team" checkbox sets
|
||||
just that key without clobbering any other preferences.
|
||||
"""
|
||||
for key, value in data.preferences.items():
|
||||
await AccountSettings.set_setting(db, current_user.account_id, key, value)
|
||||
await db.commit()
|
||||
|
||||
# Return the merged state so the client doesn't need a second GET.
|
||||
result = await db.execute(
|
||||
select(AccountSettings.preferences).where(
|
||||
AccountSettings.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
prefs = result.scalar_one_or_none() or {}
|
||||
return AccountPreferencesResponse(preferences=prefs)
|
||||
|
||||
221
backend/app/api/endpoints/draft_templates.py
Normal file
221
backend/app/api/endpoints/draft_templates.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""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
|
||||
@@ -25,6 +25,7 @@ from app.api.endpoints import (
|
||||
categories,
|
||||
copilot,
|
||||
device_types,
|
||||
draft_templates,
|
||||
feedback,
|
||||
flow_proposals,
|
||||
flowpilot_analytics,
|
||||
@@ -141,6 +142,7 @@ api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
||||
# so the {session_id}/facts subpaths take precedence over any future generic catchalls.
|
||||
api_router.include_router(session_facts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_suggested_fixes.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(draft_templates.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
||||
|
||||
68
backend/app/schemas/draft_template.py
Normal file
68
backend/app/schemas/draft_template.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Pydantic schemas for FlowPilot Phase 6 draft templates.
|
||||
|
||||
A draft is the engineer's "Run now, templatize after resolve" path output:
|
||||
the script ran for the ticket, and the AI proposed a parameterization.
|
||||
Post-resolve, the engineer accepts (promotes to a real template) or rejects.
|
||||
|
||||
See FLOWPILOT-MIGRATION.md Section 5.3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
DraftStatus = Literal["pending", "accepted", "rejected"]
|
||||
|
||||
|
||||
class DraftTemplateResponse(BaseModel):
|
||||
id: UUID
|
||||
account_id: UUID
|
||||
source_session_id: UUID
|
||||
source_user_id: UUID
|
||||
script_body: str
|
||||
proposed_parameters: dict[str, Any]
|
||||
proposed_name: str | None
|
||||
proposed_category_id: UUID | None
|
||||
status: DraftStatus
|
||||
resolved_at: datetime | None
|
||||
promoted_template_id: UUID | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DraftTemplateListResponse(BaseModel):
|
||||
drafts: list[DraftTemplateResponse]
|
||||
|
||||
|
||||
class DraftTemplateAcceptRequest(BaseModel):
|
||||
"""Engineer's confirmation that this draft should become a real template.
|
||||
|
||||
Engineer may override the AI's proposed name / category and edit the
|
||||
parameter schema before promotion. Body and parameters_schema are
|
||||
persisted to the new `script_templates` row.
|
||||
"""
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
category_id: UUID
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
# Final parameter schema in the Script Generator's standard shape.
|
||||
# See ScriptTemplate.parameters_schema for the contract.
|
||||
parameters_schema: dict[str, Any]
|
||||
# Optional last-minute edits to the script body. Defaults to the draft's
|
||||
# `script_body` (which TemplateExtractionService produced as the templated
|
||||
# form with `{{ key }}` placeholders).
|
||||
edited_body: str | None = Field(None, min_length=1, max_length=50_000)
|
||||
|
||||
|
||||
class DraftTemplateAcceptResponse(BaseModel):
|
||||
draft_id: UUID
|
||||
promoted_template_id: UUID
|
||||
template_slug: str
|
||||
|
||||
|
||||
class DraftTemplateRejectResponse(BaseModel):
|
||||
draft_id: UUID
|
||||
status: Literal["rejected"]
|
||||
295
backend/tests/test_phase6_draft_templates.py
Normal file
295
backend/tests/test_phase6_draft_templates.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""API tests for the FlowPilot Phase 6 post-resolve templatization flow.
|
||||
|
||||
Covers:
|
||||
- GET /api/v1/draft-templates list with pending_only filter.
|
||||
- POST /{id}/accept → creates script_templates row with provenance fields,
|
||||
marks draft accepted + promoted_template_id set.
|
||||
- POST /{id}/reject → marks rejected.
|
||||
- 409 when accepting or rejecting a non-pending draft.
|
||||
- Category validation (400 on unknown category_id).
|
||||
- GET/PATCH /accounts/me/preferences round-trip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.account_settings import AccountSettings
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.draft_template import DraftTemplate
|
||||
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||
|
||||
|
||||
async def _make_draft(
|
||||
test_db,
|
||||
user,
|
||||
*,
|
||||
proposed_name: str = "Test draft",
|
||||
status_: str = "pending",
|
||||
with_psa_ticket: bool = False,
|
||||
) -> tuple[AISession, DraftTemplate]:
|
||||
session = AISession(
|
||||
user_id=user["user_data"]["id"],
|
||||
account_id=user["user_data"]["account_id"],
|
||||
session_type="chat",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "phase 6 test"},
|
||||
status="resolved",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
psa_ticket_id="48307" if with_psa_ticket else None,
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
draft = DraftTemplate(
|
||||
account_id=user["user_data"]["account_id"],
|
||||
source_session_id=session.id,
|
||||
source_user_id=user["user_data"]["id"],
|
||||
script_body='Do-Something -Target {{ target_name }}\n',
|
||||
proposed_parameters={
|
||||
"parameters": [
|
||||
{"key": "target_name", "label": "Target Name", "type": "text"},
|
||||
],
|
||||
},
|
||||
proposed_name=proposed_name,
|
||||
status=status_,
|
||||
)
|
||||
test_db.add(draft)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(draft)
|
||||
return session, draft
|
||||
|
||||
|
||||
async def _make_category(test_db) -> ScriptCategory:
|
||||
cat = ScriptCategory(
|
||||
name="Phase 6 Test Category",
|
||||
slug=f"phase-6-test-{datetime.now(timezone.utc).timestamp()}",
|
||||
description="test",
|
||||
is_active=True,
|
||||
)
|
||||
test_db.add(cat)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
# ── List ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_only_default(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
await _make_draft(test_db, test_user, proposed_name="pending-a", status_="pending")
|
||||
await _make_draft(test_db, test_user, proposed_name="accepted-b", status_="accepted")
|
||||
|
||||
r = await client.get("/api/v1/draft-templates", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
drafts = r.json()["drafts"]
|
||||
names = {d["proposed_name"] for d in drafts}
|
||||
assert "pending-a" in names
|
||||
assert "accepted-b" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_with_pending_only_false_includes_all(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
await _make_draft(test_db, test_user, proposed_name="pending-c", status_="pending")
|
||||
await _make_draft(test_db, test_user, proposed_name="rejected-d", status_="rejected")
|
||||
|
||||
r = await client.get(
|
||||
"/api/v1/draft-templates?pending_only=false", headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
names = {d["proposed_name"] for d in r.json()["drafts"]}
|
||||
assert {"pending-c", "rejected-d"}.issubset(names)
|
||||
|
||||
|
||||
# ── Accept ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_creates_template_with_provenance(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session, draft = await _make_draft(test_db, test_user, with_psa_ticket=True)
|
||||
cat = await _make_category(test_db)
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "Do Something On Target",
|
||||
"category_id": str(cat.id),
|
||||
"description": "promoted from phase 6 test",
|
||||
"parameters_schema": {
|
||||
"parameters": [
|
||||
{"key": "target_name", "label": "Target", "field_type": "text"},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["draft_id"] == str(draft.id)
|
||||
assert body["promoted_template_id"] is not None
|
||||
assert body["template_slug"] == "do-something-on-target"
|
||||
|
||||
# Draft row is now accepted with the promoted template ID set.
|
||||
await test_db.refresh(draft)
|
||||
assert draft.status == "accepted"
|
||||
assert draft.promoted_template_id == UUID(body["promoted_template_id"])
|
||||
assert draft.resolved_at is not None
|
||||
|
||||
# New template row exists with provenance fields populated.
|
||||
tpl_result = await test_db.execute(
|
||||
select(ScriptTemplate).where(ScriptTemplate.id == UUID(body["promoted_template_id"]))
|
||||
)
|
||||
tpl = tpl_result.scalar_one()
|
||||
assert tpl.source_session_id == session.id
|
||||
assert tpl.source_user_id == UUID(test_user["user_data"]["id"])
|
||||
assert tpl.source_ticket_ref == "CW #48307"
|
||||
assert tpl.script_body == draft.script_body # edited_body was not supplied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_with_edited_body_overrides_draft(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user)
|
||||
cat = await _make_category(test_db)
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "Edited Body Test",
|
||||
"category_id": str(cat.id),
|
||||
"parameters_schema": {"parameters": []},
|
||||
"edited_body": 'Write-Host "edited final version"\n',
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
tpl = (
|
||||
await test_db.execute(
|
||||
select(ScriptTemplate).where(
|
||||
ScriptTemplate.id == UUID(r.json()["promoted_template_id"])
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert tpl.script_body == 'Write-Host "edited final version"\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_rejects_unknown_category(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user)
|
||||
bogus_cat = "00000000-0000-0000-0000-000000000000"
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "x",
|
||||
"category_id": bogus_cat,
|
||||
"parameters_schema": {"parameters": []},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_already_accepted_returns_409(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user, status_="accepted")
|
||||
cat = await _make_category(test_db)
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "x",
|
||||
"category_id": str(cat.id),
|
||||
"parameters_schema": {"parameters": []},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ── Reject ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_marks_draft_rejected(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user)
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "rejected"
|
||||
await test_db.refresh(draft)
|
||||
assert draft.status == "rejected"
|
||||
assert draft.resolved_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_already_accepted_returns_409(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user, status_="accepted")
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ── Preferences ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_preferences_empty_by_default(
|
||||
client: AsyncClient, auth_headers,
|
||||
):
|
||||
r = await client.get("/api/v1/accounts/me/preferences", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["preferences"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_preferences_merges_keys(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
# First write: one key.
|
||||
r = await client.patch(
|
||||
"/api/v1/accounts/me/preferences",
|
||||
headers=auth_headers,
|
||||
json={"preferences": {"templatize_prompt_enabled": False}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["preferences"]["templatize_prompt_enabled"] is False
|
||||
|
||||
# Second write: different key — first must be preserved (merge semantics).
|
||||
r2 = await client.patch(
|
||||
"/api/v1/accounts/me/preferences",
|
||||
headers=auth_headers,
|
||||
json={"preferences": {"cw_resolved_status_id": 42}},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
prefs = r2.json()["preferences"]
|
||||
assert prefs["templatize_prompt_enabled"] is False
|
||||
assert prefs["cw_resolved_status_id"] == 42
|
||||
|
||||
# Stored on the account_settings row.
|
||||
stored = (
|
||||
await test_db.execute(
|
||||
select(AccountSettings.preferences).where(
|
||||
AccountSettings.account_id == UUID(test_user["user_data"]["account_id"])
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert stored["templatize_prompt_enabled"] is False
|
||||
assert stored["cw_resolved_status_id"] == 42
|
||||
Reference in New Issue
Block a user