* feat: maintenance flow UX redesign — batch status hub, context strip, detail page upgrades (#85) - Add BatchStatusPage (/flows/:id/batches/:batchId): per-target Start/Resume/View cards, progress bar, 5s polling while in-progress, completion outcome summary - Add BatchStatusCard: handles not-started/in-progress/complete states with step progress for in-progress targets - Add ActiveBatchBanner: amber banner on detail page when a batch is running, links to BatchStatusPage - Add MaintenanceContextStrip: amber strip in ProceduralNavigationPage for maintenance flows showing target name, batch progress (X/Y complete), and Back to Batch nav - Update MaintenanceFlowDetailPage: active batch banner, clickable run history rows with mini progress dots and outcome summaries, Run button loading state, post-launch navigates to BatchStatusPage - Update ProceduralNavigationPage: renders MaintenanceContextStrip between top bar and content when tree_type === 'maintenance'; fetches batch progress once on mount - Add batch_id filter to GET /sessions backend endpoint and SessionListParams frontend type - Add /flows/:id/batches/:batchId route to router Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: session detail page — completion action + outcome summary card - In-progress sessions: amber banner with "Complete Session" button opens SessionOutcomeModal to set outcome/notes/next-steps and finalize - Completed sessions: colored outcome summary card (icon + outcome label + duration + notes + next steps) replaces dense header metadata; "Copy for Ticket" promoted to primary action inside the card - Export toolbar de-emphasized to secondary row of smaller controls below the summary card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add library-page action props to StepCard (edit/delete/save) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: pass library-page action props through StepLibraryBrowser + refreshKey Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepFormModal wrapper + submitLabel/isSubmitting props on StepForm Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Step Library page — create, edit, delete, save-to-library Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add RuntimeStep union type for procedural custom steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: custom step insertion in procedural flow sessions Engineers can add custom steps inline during execution. Steps are persisted to session.custom_steps and restored on resume. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: suppress StepFeedback on custom steps, fix resume stepState seeding, functional updater for step index Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add tree forking UI design doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add tree fork UI implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkInfo type and fork fields to Tree/TreeListItem Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: align ForkInfo type with backend schema, remove redundant fork fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ForkInfo placement, required fork_info field, add JSDoc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkModal component with name and reason fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ForkModal accessibility and UX (escape, click-outside, labels, maxLength) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: open ForkModal on fork action in TreeLibraryPage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkModal to MyTreesPage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show Fork chip badge on forked tree cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add flow-to-library step sync design doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add flow-to-library sync implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add sync tracking columns to step_library Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add sync columns and source_tree relationship to StepLibrary model Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add group_label to StepContent, is_flow_synced/source_tree_name to StepLibraryResponse Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: include is_flow_synced and source_tree_name in step list/detail responses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add is_flow_synced and source_tree_name to step list response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add selectinload and sync fields to search and get_step endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add step_sync module with extraction and upsert logic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: safe NOT IN placeholders for asyncpg, add deactivate docstring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: trigger step library sync on tree publish and deactivate on delete - Call sync_steps_from_tree in update_tree whenever the tree is published (status transitions to 'published' or is already published and structure changes) - Call deactivate_synced_steps_for_tree in delete_tree before db.commit() so the FK SET NULL does not nullify source_tree_id before the WHERE clause runs - Fix ::jsonb cast syntax in step_sync.py (asyncpg rejects :: operator in text() queries; replaced with CAST(:content AS jsonb)) - Add UniqueConstraint('source_tree_id','source_node_id') to StepLibrary model so Base.metadata.create_all (used by tests) creates the constraint that the ON CONFLICT clause in sync_steps_from_tree depends on Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add is_flow_synced and source_tree_name to Step types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show From Flow badge and lock icon on flow-synced StepCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show source flow name in StepDetailModal for synced steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add Library Visibility select to procedural StepEditor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address code review issues in flow-to-library sync - Fix sync trigger: only fire on publish transition, not every PUT - Add TestSyncOnPublish integration tests (2 tests, 16 total passing) - Add group_label to frontend StepContent interface - Guard Library Visibility select to procedure_step nodes only - Block API edits to flow-synced steps (400 read-only guard) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: handle None author_id in step sync to avoid invalid UUID error When a system/default tree has no author (author_id is None), str(None) produces the literal string 'None' which asyncpg rejects as an invalid UUID for the created_by column. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add ResolutionFlow service account to own default tree steps in library Default/system trees had no author_id (NULL), causing a NOT NULL violation when syncing steps to step_library.created_by on publish. - Add is_service_account flag to users table (migration 4f4137ce) - Add service_account.py: idempotent ensure_service_account() creates noreply@resolutionflow.com with unusable password on startup - Cache service account ID on app.state at lifespan startup - Add get_service_account_id() FastAPI dep (returns None in tests) - sync_steps_from_tree: resolve author_id or service_account_id as created_by - create_tree: set author_id=service_account_id for is_default trees - Migration 1490781700bc: backfill author_id on 31 existing default trees Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
668 lines
22 KiB
Python
668 lines
22 KiB
Python
from uuid import UUID
|
|
from typing import Optional
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import select, func, desc, Integer, case
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.database import get_db
|
|
from app.api.deps import get_current_active_user, require_engineer_or_admin
|
|
from app.core.permissions import can_view_step, can_edit_step
|
|
from app.core.filters import build_step_visibility_filter
|
|
from app.models.user import User
|
|
from app.models.step_library import StepLibrary, StepRating
|
|
from app.models.step_category import StepCategory
|
|
from app.schemas.step_library import (
|
|
StepLibraryCreate,
|
|
StepLibraryUpdate,
|
|
StepLibraryResponse,
|
|
StepLibraryListResponse,
|
|
StepRatingCreate,
|
|
StepRatingUpdate,
|
|
StepRatingResponse,
|
|
PopularTagResponse,
|
|
)
|
|
|
|
router = APIRouter(prefix="/steps", tags=["steps"])
|
|
|
|
|
|
async def get_step_or_404(
|
|
step_id: UUID,
|
|
db: AsyncSession,
|
|
current_user: User,
|
|
check_view: bool = True,
|
|
check_edit: bool = False
|
|
) -> StepLibrary:
|
|
"""Get step by ID with permission checks."""
|
|
result = await db.execute(
|
|
select(StepLibrary).where(
|
|
StepLibrary.id == step_id,
|
|
StepLibrary.is_active == True
|
|
).options(selectinload(StepLibrary.source_tree))
|
|
)
|
|
step = result.scalar_one_or_none()
|
|
if not step:
|
|
raise HTTPException(status_code=404, detail="Step not found")
|
|
|
|
if check_view and not can_view_step(current_user, step):
|
|
raise HTTPException(status_code=403, detail="Not authorized to view this step")
|
|
|
|
if check_edit and not can_edit_step(current_user, step):
|
|
raise HTTPException(status_code=403, detail="Not authorized to modify this step")
|
|
|
|
return step
|
|
|
|
|
|
|
|
@router.get("", response_model=list[StepLibraryListResponse])
|
|
async def list_steps(
|
|
visibility: Optional[str] = Query(None, pattern="^(private|team|public)$"),
|
|
category_id: Optional[UUID] = None,
|
|
tags: Optional[list[str]] = Query(None),
|
|
min_rating: Optional[float] = Query(None, ge=0, le=5),
|
|
step_type: Optional[str] = Query(None, pattern="^(decision|action|solution)$"),
|
|
sort_by: str = Query("recent", pattern="^(recent|popular|highest_rated|most_used)$"),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""List steps with filters and pagination."""
|
|
query = select(StepLibrary).where(
|
|
StepLibrary.is_active == True,
|
|
build_step_visibility_filter(current_user)
|
|
).options(selectinload(StepLibrary.source_tree))
|
|
|
|
# Apply filters
|
|
if visibility:
|
|
query = query.where(StepLibrary.visibility == visibility)
|
|
if category_id:
|
|
query = query.where(StepLibrary.category_id == category_id)
|
|
if tags:
|
|
# Match any of the provided tags
|
|
query = query.where(StepLibrary.tags.overlap(tags))
|
|
if min_rating is not None:
|
|
query = query.where(StepLibrary.rating_average >= Decimal(str(min_rating)))
|
|
if step_type:
|
|
query = query.where(StepLibrary.step_type == step_type)
|
|
|
|
# Apply sorting
|
|
if sort_by == "recent":
|
|
query = query.order_by(desc(StepLibrary.created_at))
|
|
elif sort_by == "popular" or sort_by == "most_used":
|
|
query = query.order_by(desc(StepLibrary.usage_count))
|
|
elif sort_by == "highest_rated":
|
|
query = query.order_by(desc(StepLibrary.rating_average), desc(StepLibrary.rating_count))
|
|
|
|
# Apply pagination
|
|
query = query.offset(offset).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
steps = result.scalars().all()
|
|
|
|
# Fetch category names and author names
|
|
response = []
|
|
for step in steps:
|
|
step_dict = {
|
|
"id": step.id,
|
|
"title": step.title,
|
|
"step_type": step.step_type,
|
|
"visibility": step.visibility,
|
|
"category_id": step.category_id,
|
|
"tags": step.tags,
|
|
"usage_count": step.usage_count,
|
|
"rating_average": step.rating_average,
|
|
"rating_count": step.rating_count,
|
|
"is_featured": step.is_featured,
|
|
"created_by": step.created_by,
|
|
"created_at": step.created_at,
|
|
"is_flow_synced": step.is_flow_synced,
|
|
"source_tree_name": step.source_tree.name if step.source_tree else None,
|
|
}
|
|
|
|
# Get category name if exists
|
|
if step.category_id:
|
|
cat_result = await db.execute(
|
|
select(StepCategory.name).where(StepCategory.id == step.category_id)
|
|
)
|
|
cat_name = cat_result.scalar_one_or_none()
|
|
step_dict["category_name"] = cat_name
|
|
|
|
# Get author name
|
|
author_result = await db.execute(
|
|
select(User.name).where(User.id == step.created_by)
|
|
)
|
|
author_name = author_result.scalar_one_or_none()
|
|
step_dict["author_name"] = author_name
|
|
|
|
response.append(StepLibraryListResponse(**step_dict))
|
|
|
|
return response
|
|
|
|
|
|
@router.get("/search", response_model=list[StepLibraryListResponse])
|
|
async def search_steps(
|
|
q: str = Query(..., min_length=1),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Full-text search for steps."""
|
|
# Use PostgreSQL full-text search
|
|
search_query = func.to_tsquery('english', q.replace(' ', ' & '))
|
|
|
|
query = select(StepLibrary).where(
|
|
StepLibrary.is_active == True,
|
|
build_step_visibility_filter(current_user),
|
|
func.to_tsvector('english', StepLibrary.title).match(search_query)
|
|
).options(selectinload(StepLibrary.source_tree)).order_by(desc(StepLibrary.rating_average)).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
steps = result.scalars().all()
|
|
|
|
response = []
|
|
for step in steps:
|
|
step_dict = {
|
|
"id": step.id,
|
|
"title": step.title,
|
|
"step_type": step.step_type,
|
|
"visibility": step.visibility,
|
|
"category_id": step.category_id,
|
|
"tags": step.tags,
|
|
"usage_count": step.usage_count,
|
|
"rating_average": step.rating_average,
|
|
"rating_count": step.rating_count,
|
|
"is_featured": step.is_featured,
|
|
"created_by": step.created_by,
|
|
"created_at": step.created_at,
|
|
"is_flow_synced": step.is_flow_synced,
|
|
"source_tree_name": step.source_tree.name if step.source_tree else None,
|
|
}
|
|
|
|
if step.category_id:
|
|
cat_result = await db.execute(
|
|
select(StepCategory.name).where(StepCategory.id == step.category_id)
|
|
)
|
|
step_dict["category_name"] = cat_result.scalar_one_or_none()
|
|
|
|
author_result = await db.execute(
|
|
select(User.name).where(User.id == step.created_by)
|
|
)
|
|
step_dict["author_name"] = author_result.scalar_one_or_none()
|
|
|
|
response.append(StepLibraryListResponse(**step_dict))
|
|
|
|
return response
|
|
|
|
|
|
@router.get("/tags/popular", response_model=list[PopularTagResponse])
|
|
async def get_popular_tags(
|
|
limit: int = Query(20, ge=1, le=50),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Get popular tags with usage counts."""
|
|
# Use unnest to expand arrays and count occurrences
|
|
query = select(
|
|
func.unnest(StepLibrary.tags).label('tag'),
|
|
func.count().label('count')
|
|
).where(
|
|
StepLibrary.is_active == True,
|
|
build_step_visibility_filter(current_user)
|
|
).group_by(
|
|
func.unnest(StepLibrary.tags)
|
|
).order_by(
|
|
desc(func.count())
|
|
).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
tags = result.all()
|
|
|
|
return [PopularTagResponse(tag=row.tag, count=row.count) for row in tags]
|
|
|
|
|
|
@router.get("/{step_id}", response_model=StepLibraryResponse)
|
|
async def get_step(
|
|
step_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Get a step by ID."""
|
|
step = await get_step_or_404(step_id, db, current_user, check_view=True)
|
|
|
|
response_dict = {
|
|
"id": step.id,
|
|
"title": step.title,
|
|
"step_type": step.step_type,
|
|
"content": step.content,
|
|
"category_id": step.category_id,
|
|
"tags": step.tags,
|
|
"visibility": step.visibility,
|
|
"created_by": step.created_by,
|
|
"account_id": step.account_id,
|
|
"usage_count": step.usage_count,
|
|
"rating_average": step.rating_average,
|
|
"rating_count": step.rating_count,
|
|
"helpful_yes": step.helpful_yes,
|
|
"helpful_no": step.helpful_no,
|
|
"is_featured": step.is_featured,
|
|
"is_verified": step.is_verified,
|
|
"is_active": step.is_active,
|
|
"created_at": step.created_at,
|
|
"updated_at": step.updated_at,
|
|
"is_flow_synced": step.is_flow_synced,
|
|
"source_tree_name": step.source_tree.name if step.source_tree else None,
|
|
}
|
|
|
|
# Get category name if exists
|
|
if step.category_id:
|
|
cat_result = await db.execute(
|
|
select(StepCategory.name).where(StepCategory.id == step.category_id)
|
|
)
|
|
response_dict["category_name"] = cat_result.scalar_one_or_none()
|
|
|
|
# Get author name
|
|
author_result = await db.execute(
|
|
select(User.name).where(User.id == step.created_by)
|
|
)
|
|
response_dict["author_name"] = author_result.scalar_one_or_none()
|
|
|
|
return StepLibraryResponse(**response_dict)
|
|
|
|
|
|
@router.post("", response_model=StepLibraryResponse, status_code=201)
|
|
async def create_step(
|
|
step_data: StepLibraryCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_engineer_or_admin)
|
|
):
|
|
"""Create a new step (engineers and admins only, not viewers)."""
|
|
# Validate category if provided
|
|
if step_data.category_id:
|
|
cat_result = await db.execute(
|
|
select(StepCategory).where(
|
|
StepCategory.id == step_data.category_id,
|
|
StepCategory.is_active == True
|
|
)
|
|
)
|
|
if not cat_result.scalar_one_or_none():
|
|
raise HTTPException(status_code=400, detail="Invalid category")
|
|
|
|
# Account validation: can only set account_id to own account
|
|
account_id = step_data.account_id
|
|
if account_id and account_id != current_user.account_id and not current_user.is_super_admin:
|
|
raise HTTPException(status_code=403, detail="Cannot create step for another account")
|
|
|
|
step = StepLibrary(
|
|
title=step_data.title,
|
|
step_type=step_data.step_type,
|
|
content=step_data.content.model_dump(),
|
|
category_id=step_data.category_id,
|
|
tags=step_data.tags,
|
|
visibility=step_data.visibility,
|
|
created_by=current_user.id,
|
|
account_id=account_id or current_user.account_id,
|
|
)
|
|
|
|
db.add(step)
|
|
await db.commit()
|
|
await db.refresh(step)
|
|
|
|
# Build response
|
|
response_dict = {
|
|
"id": step.id,
|
|
"title": step.title,
|
|
"step_type": step.step_type,
|
|
"content": step.content,
|
|
"category_id": step.category_id,
|
|
"tags": step.tags,
|
|
"visibility": step.visibility,
|
|
"created_by": step.created_by,
|
|
"account_id": step.account_id,
|
|
"usage_count": step.usage_count,
|
|
"rating_average": step.rating_average,
|
|
"rating_count": step.rating_count,
|
|
"helpful_yes": step.helpful_yes,
|
|
"helpful_no": step.helpful_no,
|
|
"is_featured": step.is_featured,
|
|
"is_verified": step.is_verified,
|
|
"is_active": step.is_active,
|
|
"created_at": step.created_at,
|
|
"updated_at": step.updated_at,
|
|
"author_name": current_user.name,
|
|
}
|
|
|
|
if step.category_id:
|
|
cat_result = await db.execute(
|
|
select(StepCategory.name).where(StepCategory.id == step.category_id)
|
|
)
|
|
response_dict["category_name"] = cat_result.scalar_one_or_none()
|
|
|
|
return StepLibraryResponse(**response_dict)
|
|
|
|
|
|
@router.put("/{step_id}", response_model=StepLibraryResponse)
|
|
async def update_step(
|
|
step_id: UUID,
|
|
step_data: StepLibraryUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Update a step (owner or admin only)."""
|
|
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
|
|
|
|
if step.is_flow_synced:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Flow-synced steps are read-only. Fork to customize."
|
|
)
|
|
|
|
# Validate category if being updated
|
|
if step_data.category_id:
|
|
cat_result = await db.execute(
|
|
select(StepCategory).where(
|
|
StepCategory.id == step_data.category_id,
|
|
StepCategory.is_active == True
|
|
)
|
|
)
|
|
if not cat_result.scalar_one_or_none():
|
|
raise HTTPException(status_code=400, detail="Invalid category")
|
|
|
|
# Apply updates
|
|
update_data = step_data.model_dump(exclude_unset=True)
|
|
if 'content' in update_data and update_data['content']:
|
|
update_data['content'] = update_data['content'].model_dump() if hasattr(update_data['content'], 'model_dump') else update_data['content']
|
|
|
|
for field, value in update_data.items():
|
|
setattr(step, field, value)
|
|
|
|
step.updated_at = datetime.now(timezone.utc)
|
|
|
|
await db.commit()
|
|
await db.refresh(step)
|
|
|
|
# Build response
|
|
response_dict = {
|
|
"id": step.id,
|
|
"title": step.title,
|
|
"step_type": step.step_type,
|
|
"content": step.content,
|
|
"category_id": step.category_id,
|
|
"tags": step.tags,
|
|
"visibility": step.visibility,
|
|
"created_by": step.created_by,
|
|
"account_id": step.account_id,
|
|
"usage_count": step.usage_count,
|
|
"rating_average": step.rating_average,
|
|
"rating_count": step.rating_count,
|
|
"helpful_yes": step.helpful_yes,
|
|
"helpful_no": step.helpful_no,
|
|
"is_featured": step.is_featured,
|
|
"is_verified": step.is_verified,
|
|
"is_active": step.is_active,
|
|
"created_at": step.created_at,
|
|
"updated_at": step.updated_at,
|
|
}
|
|
|
|
if step.category_id:
|
|
cat_result = await db.execute(
|
|
select(StepCategory.name).where(StepCategory.id == step.category_id)
|
|
)
|
|
response_dict["category_name"] = cat_result.scalar_one_or_none()
|
|
|
|
author_result = await db.execute(
|
|
select(User.name).where(User.id == step.created_by)
|
|
)
|
|
response_dict["author_name"] = author_result.scalar_one_or_none()
|
|
|
|
return StepLibraryResponse(**response_dict)
|
|
|
|
|
|
@router.delete("/{step_id}", status_code=204)
|
|
async def delete_step(
|
|
step_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Soft delete a step (owner or admin only)."""
|
|
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
|
|
|
|
step.is_active = False
|
|
step.updated_at = datetime.now(timezone.utc)
|
|
|
|
await db.commit()
|
|
return None
|
|
|
|
|
|
# Rating endpoints
|
|
@router.post("/{step_id}/rate", response_model=StepRatingResponse, status_code=201)
|
|
async def rate_step(
|
|
step_id: UUID,
|
|
rating_data: StepRatingCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Rate a step (1-5 stars with optional review)."""
|
|
step = await get_step_or_404(step_id, db, current_user, check_view=True)
|
|
|
|
# Check if user already rated
|
|
existing = await db.execute(
|
|
select(StepRating).where(
|
|
StepRating.step_id == step_id,
|
|
StepRating.user_id == current_user.id
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(status_code=400, detail="You have already rated this step. Use PUT to update.")
|
|
|
|
rating = StepRating(
|
|
step_id=step_id,
|
|
user_id=current_user.id,
|
|
rating=rating_data.rating,
|
|
was_helpful=rating_data.was_helpful,
|
|
review_text=rating_data.review_text,
|
|
session_id=rating_data.session_id,
|
|
is_verified_use=rating_data.session_id is not None,
|
|
)
|
|
|
|
db.add(rating)
|
|
|
|
# Update aggregated ratings on step
|
|
await _update_step_ratings(db, step)
|
|
|
|
await db.commit()
|
|
await db.refresh(rating)
|
|
|
|
return StepRatingResponse(
|
|
id=rating.id,
|
|
step_id=rating.step_id,
|
|
user_id=rating.user_id,
|
|
rating=rating.rating,
|
|
was_helpful=rating.was_helpful,
|
|
review_text=rating.review_text,
|
|
is_verified_use=rating.is_verified_use,
|
|
session_id=rating.session_id,
|
|
is_visible=rating.is_visible,
|
|
created_at=rating.created_at,
|
|
updated_at=rating.updated_at,
|
|
user_name=current_user.name,
|
|
)
|
|
|
|
|
|
@router.put("/{step_id}/rate", response_model=StepRatingResponse)
|
|
async def update_rating(
|
|
step_id: UUID,
|
|
rating_data: StepRatingUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Update your rating for a step."""
|
|
step = await get_step_or_404(step_id, db, current_user, check_view=True)
|
|
|
|
result = await db.execute(
|
|
select(StepRating).where(
|
|
StepRating.step_id == step_id,
|
|
StepRating.user_id == current_user.id
|
|
)
|
|
)
|
|
rating = result.scalar_one_or_none()
|
|
if not rating:
|
|
raise HTTPException(status_code=404, detail="Rating not found. Use POST to create.")
|
|
|
|
update_data = rating_data.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(rating, field, value)
|
|
|
|
rating.updated_at = datetime.now(timezone.utc)
|
|
|
|
# Update aggregated ratings on step
|
|
await _update_step_ratings(db, step)
|
|
|
|
await db.commit()
|
|
await db.refresh(rating)
|
|
|
|
return StepRatingResponse(
|
|
id=rating.id,
|
|
step_id=rating.step_id,
|
|
user_id=rating.user_id,
|
|
rating=rating.rating,
|
|
was_helpful=rating.was_helpful,
|
|
review_text=rating.review_text,
|
|
is_verified_use=rating.is_verified_use,
|
|
session_id=rating.session_id,
|
|
is_visible=rating.is_visible,
|
|
created_at=rating.created_at,
|
|
updated_at=rating.updated_at,
|
|
user_name=current_user.name,
|
|
)
|
|
|
|
|
|
@router.delete("/{step_id}/rate", status_code=204)
|
|
async def delete_rating(
|
|
step_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Delete your rating for a step."""
|
|
step = await get_step_or_404(step_id, db, current_user, check_view=True)
|
|
|
|
result = await db.execute(
|
|
select(StepRating).where(
|
|
StepRating.step_id == step_id,
|
|
StepRating.user_id == current_user.id
|
|
)
|
|
)
|
|
rating = result.scalar_one_or_none()
|
|
if not rating:
|
|
raise HTTPException(status_code=404, detail="Rating not found")
|
|
|
|
await db.delete(rating)
|
|
|
|
# Update aggregated ratings on step
|
|
await _update_step_ratings(db, step)
|
|
|
|
await db.commit()
|
|
return None
|
|
|
|
|
|
# Backward-compatible /ratings alias routes
|
|
@router.post("/{step_id}/ratings", response_model=StepRatingResponse, status_code=201,
|
|
include_in_schema=False)
|
|
async def rate_step_alias(
|
|
step_id: UUID,
|
|
rating_data: StepRatingCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Alias for POST /{step_id}/rate."""
|
|
return await rate_step(step_id, rating_data, db, current_user)
|
|
|
|
|
|
@router.put("/{step_id}/ratings", response_model=StepRatingResponse,
|
|
include_in_schema=False)
|
|
async def update_rating_alias(
|
|
step_id: UUID,
|
|
rating_data: StepRatingUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Alias for PUT /{step_id}/rate."""
|
|
return await update_rating(step_id, rating_data, db, current_user)
|
|
|
|
|
|
@router.delete("/{step_id}/ratings", status_code=204,
|
|
include_in_schema=False)
|
|
async def delete_rating_alias(
|
|
step_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Alias for DELETE /{step_id}/rate."""
|
|
return await delete_rating(step_id, db, current_user)
|
|
|
|
|
|
@router.get("/{step_id}/reviews", response_model=list[StepRatingResponse])
|
|
async def get_reviews(
|
|
step_id: UUID,
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""Get reviews for a step."""
|
|
await get_step_or_404(step_id, db, current_user, check_view=True)
|
|
|
|
result = await db.execute(
|
|
select(StepRating).where(
|
|
StepRating.step_id == step_id,
|
|
StepRating.is_visible == True,
|
|
StepRating.review_text.isnot(None)
|
|
).order_by(desc(StepRating.created_at)).offset(offset).limit(limit)
|
|
)
|
|
ratings = result.scalars().all()
|
|
|
|
response = []
|
|
for rating in ratings:
|
|
user_result = await db.execute(
|
|
select(User.name).where(User.id == rating.user_id)
|
|
)
|
|
user_name = user_result.scalar_one_or_none()
|
|
|
|
response.append(StepRatingResponse(
|
|
id=rating.id,
|
|
step_id=rating.step_id,
|
|
user_id=rating.user_id,
|
|
rating=rating.rating,
|
|
was_helpful=rating.was_helpful,
|
|
review_text=rating.review_text,
|
|
is_verified_use=rating.is_verified_use,
|
|
session_id=rating.session_id,
|
|
is_visible=rating.is_visible,
|
|
created_at=rating.created_at,
|
|
updated_at=rating.updated_at,
|
|
user_name=user_name,
|
|
))
|
|
|
|
return response
|
|
|
|
|
|
async def _update_step_ratings(db: AsyncSession, step: StepLibrary):
|
|
"""Update aggregated rating fields on a step."""
|
|
# Calculate new aggregates
|
|
result = await db.execute(
|
|
select(
|
|
func.count(StepRating.id),
|
|
func.avg(StepRating.rating),
|
|
func.sum(case((StepRating.was_helpful == True, 1), else_=0)),
|
|
func.sum(case((StepRating.was_helpful == False, 1), else_=0))
|
|
).where(StepRating.step_id == step.id)
|
|
)
|
|
row = result.one()
|
|
|
|
step.rating_count = row[0] or 0
|
|
step.rating_average = Decimal(str(round(row[1] or 0, 2)))
|
|
step.helpful_yes = row[2] or 0
|
|
step.helpful_no = row[3] or 0
|