- New 5-step wizard flow at /generate/ (Upload → Configure → Outline → Generate → Edit) - wizardSlice with localStorage persistence for cross-session state - Upload page: drag & drop files, brief text input, file type badges - Configure page: client/deck selectors, slide count slider, tone, language, instructions - Outline review page: split-view with source content + outline editor, template selection - Progress page: SSE streaming + polling fallback for real-time job progress - Review workflow: status badge (Draft/In Review/Approved) with popover transitions - Backend review endpoints: PUT status, POST comment, GET review info with audit logging - Wizard API helpers: clients, master decks, file upload/decompose, job status/cancel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
131 lines
4 KiB
Python
131 lines
4 KiB
Python
"""Presentation review workflow endpoints."""
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from models.sql.presentation import PresentationModel
|
|
from models.sql.user import UserModel
|
|
from services import audit_service
|
|
from services.database import get_async_session
|
|
from utils.auth_dependencies import get_current_user
|
|
|
|
REVIEW_ROUTER = APIRouter(prefix="/api/v1/ppt", tags=["Review"])
|
|
|
|
VALID_STATUSES = {"draft", "in_review", "approved"}
|
|
VALID_TRANSITIONS = {
|
|
"draft": {"in_review"},
|
|
"in_review": {"draft", "approved"},
|
|
"approved": {"draft"},
|
|
}
|
|
|
|
|
|
class StatusUpdateRequest(BaseModel):
|
|
status: str
|
|
comment: Optional[str] = None
|
|
|
|
|
|
class CommentRequest(BaseModel):
|
|
comment: str
|
|
|
|
|
|
@REVIEW_ROUTER.put("/presentation/{presentation_id}/status")
|
|
async def update_status(
|
|
presentation_id: uuid.UUID,
|
|
body: StatusUpdateRequest,
|
|
current_user: UserModel = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Change presentation review status (draft -> in_review -> approved)."""
|
|
if body.status not in VALID_STATUSES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}",
|
|
)
|
|
|
|
presentation = await session.get(PresentationModel, presentation_id)
|
|
if not presentation:
|
|
raise HTTPException(status_code=404, detail="Presentation not found")
|
|
|
|
current = presentation.status or "draft"
|
|
if body.status not in VALID_TRANSITIONS.get(current, set()):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot transition from '{current}' to '{body.status}'",
|
|
)
|
|
|
|
old_status = current
|
|
presentation.status = body.status
|
|
if body.comment:
|
|
presentation.review_comment = body.comment
|
|
|
|
await session.commit()
|
|
await session.refresh(presentation)
|
|
|
|
# Audit log (fire-and-forget)
|
|
audit_service.log(
|
|
user_id=current_user.id,
|
|
action="status_change",
|
|
resource_type="presentation",
|
|
resource_id=presentation.id,
|
|
client_id=presentation.client_id,
|
|
details={
|
|
"old_status": old_status,
|
|
"new_status": body.status,
|
|
"comment": body.comment,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"id": str(presentation.id),
|
|
"status": presentation.status,
|
|
"review_comment": presentation.review_comment,
|
|
}
|
|
|
|
|
|
@REVIEW_ROUTER.post("/presentation/{presentation_id}/comment")
|
|
async def add_comment(
|
|
presentation_id: uuid.UUID,
|
|
body: CommentRequest,
|
|
current_user: UserModel = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Add a review comment to a presentation."""
|
|
presentation = await session.get(PresentationModel, presentation_id)
|
|
if not presentation:
|
|
raise HTTPException(status_code=404, detail="Presentation not found")
|
|
|
|
presentation.review_comment = body.comment
|
|
await session.commit()
|
|
|
|
audit_service.log(
|
|
user_id=current_user.id,
|
|
action="comment",
|
|
resource_type="presentation",
|
|
resource_id=presentation.id,
|
|
client_id=presentation.client_id,
|
|
details={"comment": body.comment},
|
|
)
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
@REVIEW_ROUTER.get("/presentation/{presentation_id}/review")
|
|
async def get_review_info(
|
|
presentation_id: uuid.UUID,
|
|
_current_user: UserModel = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Get review status and comment for a presentation."""
|
|
presentation = await session.get(PresentationModel, presentation_id)
|
|
if not presentation:
|
|
raise HTTPException(status_code=404, detail="Presentation not found")
|
|
|
|
return {
|
|
"id": str(presentation.id),
|
|
"status": presentation.status or "draft",
|
|
"review_comment": presentation.review_comment,
|
|
"title": presentation.title,
|
|
}
|