Files
resolutionflow/backend/app/api/endpoints/ratings.py
chihlasm 758cd61621 fix: propagate account_id through all write paths missing NOT NULL coverage
Service layer (production code):
- branch_manager: set account_id on SessionBranch (root + fork) and ForkPoint
  from session.account_id; load session in create_fork for this purpose
- handoff_manager: set account_id on SessionHandoff from session.account_id
- ai_suggestions endpoint: set account_id on AISuggestion from current_user
- steps endpoint (/feedback): set account_id on StepRating from current_user
- ratings endpoint: set account_id on StepRating from current_user

Test infrastructure:
- conftest.py: seed PLATFORM_ACCOUNT_ID (00000000-...-0001) account after
  Base.metadata.create_all so global categories and gallery items have a valid FK
- test_rls_isolation: add _ensure_rls_schema fixture that runs
  'alembic upgrade head' before module tests — previous function-scoped
  test_db fixtures drop the schema, leaving the RLS tests with no tables
- test_branding: create Account before User in helper functions
- test_admin_gallery: set account_id=PLATFORM_ACCOUNT_ID on Tree/ScriptTemplate
- test_public_templates: set account_id=PLATFORM_ACCOUNT_ID on Tree,
  ScriptTemplate, TreeCategory
- test_resolution_outputs: set account_id=session.account_id on
  SessionResolutionOutput
- test_analytics_phase5: set account_id on PsaPostLog
- test_draft_trees: replace account_id=None with PLATFORM_ACCOUNT_ID in
  migration default test (NOT NULL now enforced)
- test_maintenance_schedules: set account_id on other_tree
- test_save_session_as_tree: set account_id on all 5 Session() constructors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:24:36 +00:00

126 lines
4.3 KiB
Python

from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models import User, Session, SessionRating
from app.models.step_library import StepLibrary, StepRating
from app.schemas.analytics import SessionRatingCreate, SessionRatingResponse, StepFeedbackCreate
router = APIRouter(tags=["ratings"])
@router.post("/sessions/{session_id}/rate", response_model=SessionRatingResponse, status_code=status.HTTP_201_CREATED)
async def rate_session(
session_id: UUID,
data: SessionRatingCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""Submit a CSAT rating (1-5) for a completed session."""
# Verify session exists and belongs to user
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your session")
if not session.completed_at:
raise HTTPException(status_code=400, detail="Session not completed yet")
# Check for duplicate
existing = await db.execute(
select(SessionRating).where(SessionRating.session_id == session_id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Session already rated")
rating = SessionRating(
session_id=session_id,
user_id=current_user.id,
tree_id=session.tree_id,
account_id=current_user.account_id,
rating=data.rating,
comment=data.comment,
)
db.add(rating)
await db.commit()
await db.refresh(rating)
return SessionRatingResponse(
id=str(rating.id),
session_id=str(rating.session_id),
rating=rating.rating,
comment=rating.comment,
created_at=rating.created_at,
)
@router.post("/steps/{step_id}/feedback", status_code=status.HTTP_201_CREATED)
async def submit_step_feedback(
step_id: UUID,
data: StepFeedbackCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""Submit thumbs up/down feedback for a step used in a session."""
# Verify step exists
result = await db.execute(select(StepLibrary).where(StepLibrary.id == step_id))
step = result.scalar_one_or_none()
if not step:
raise HTTPException(status_code=404, detail="Step not found")
session_uuid = UUID(data.session_id)
# Check for existing feedback for this step+user+session
existing_result = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.user_id == current_user.id,
StepRating.session_id == session_uuid,
)
)
existing = existing_result.scalar_one_or_none()
if existing:
existing.was_helpful = data.was_helpful
resp_status = "updated"
else:
new_rating = StepRating(
step_id=step_id,
user_id=current_user.id,
account_id=current_user.account_id,
session_id=session_uuid,
was_helpful=data.was_helpful,
# rating is nullable now — thumbs-only mode
)
db.add(new_rating)
resp_status = "created"
# Update aggregates on step_library
await _update_step_helpful_counts(db, step_id)
await db.commit()
return {"step_id": str(step_id), "was_helpful": data.was_helpful, "status": resp_status}
async def _update_step_helpful_counts(db: AsyncSession, step_id: UUID):
"""Recalculate helpful_yes and helpful_no on step_library."""
yes_q = await db.execute(
select(func.count()).select_from(StepRating).where(
StepRating.step_id == step_id, StepRating.was_helpful == True
)
)
no_q = await db.execute(
select(func.count()).select_from(StepRating).where(
StepRating.step_id == step_id, StepRating.was_helpful == False
)
)
await db.execute(
StepLibrary.__table__.update()
.where(StepLibrary.id == step_id)
.values(helpful_yes=yes_q.scalar() or 0, helpful_no=no_q.scalar() or 0)
)