""" Session API router for tryout session management. Endpoints: - POST /session/{session_id}/complete: Submit answers and complete session - GET /session/{session_id}: Get session details - POST /session: Create new session """ from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Header, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database import get_db from app.models.item import Item from app.models.session import Session from app.models.tryout import Tryout from app.models.tryout_stats import TryoutStats from app.models.user_answer import UserAnswer from app.schemas.session import ( SessionCompleteRequest, SessionCompleteResponse, SessionCreateRequest, SessionResponse, UserAnswerOutput, ) from app.services.ctt_scoring import ( calculate_ctt_bobot, calculate_ctt_nm, calculate_ctt_nn, get_total_bobot_max, update_tryout_stats, ) router = APIRouter(prefix="/session", tags=["sessions"]) def get_website_id_from_header( x_website_id: Optional[str] = Header(None, alias="X-Website-ID"), ) -> int: """ Extract and validate website_id from request header. Args: x_website_id: Website ID from header Returns: Validated website ID as integer Raises: HTTPException: If header is missing or invalid """ if x_website_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="X-Website-ID header is required", ) try: return int(x_website_id) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="X-Website-ID must be a valid integer", ) @router.post( "/{session_id}/complete", response_model=SessionCompleteResponse, summary="Complete session with answers", description="Submit user answers, calculate CTT scores, and complete the session.", ) async def complete_session( session_id: str, request: SessionCompleteRequest, db: AsyncSession = Depends(get_db), website_id: int = Depends(get_website_id_from_header), ) -> SessionCompleteResponse: """ Complete a session by submitting answers and calculating CTT scores. Process: 1. Validate session exists and is not completed 2. For each answer: check is_correct, calculate bobot_earned 3. Save UserAnswer records 4. Calculate CTT scores (total_benar, total_bobot_earned, NM) 5. Update Session with CTT results 6. Update TryoutStats incrementally 7. Return session with scores Args: session_id: Unique session identifier request: Session completion request with end_time and user_answers db: Database session website_id: Website ID from header Returns: SessionCompleteResponse with CTT scores Raises: HTTPException: If session not found, already completed, or validation fails """ # Get session with tryout relationship result = await db.execute( select(Session) .options(selectinload(Session.tryout)) .where( Session.session_id == session_id, Session.website_id == website_id, ) ) session = result.scalar_one_or_none() if session is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {session_id} not found", ) if session.is_completed: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Session is already completed", ) # Get tryout configuration tryout = session.tryout # Get all items for this tryout to calculate bobot items_result = await db.execute( select(Item).where( Item.website_id == website_id, Item.tryout_id == session.tryout_id, ) ) items = {item.id: item for item in items_result.scalars().all()} # Process each answer total_benar = 0 total_bobot_earned = 0.0 user_answer_records = [] for answer_input in request.user_answers: item = items.get(answer_input.item_id) if item is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Item {answer_input.item_id} not found in tryout {session.tryout_id}", ) # Check if answer is correct is_correct = answer_input.response.upper() == item.correct_answer.upper() # Calculate bobot_earned (only if correct) bobot_earned = 0.0 if is_correct: total_benar += 1 if item.ctt_bobot is not None: bobot_earned = item.ctt_bobot total_bobot_earned += bobot_earned # Create UserAnswer record user_answer = UserAnswer( session_id=session.session_id, wp_user_id=session.wp_user_id, website_id=website_id, tryout_id=session.tryout_id, item_id=item.id, response=answer_input.response.upper(), is_correct=is_correct, time_spent=answer_input.time_spent, scoring_mode_used=session.scoring_mode_used, bobot_earned=bobot_earned, ) user_answer_records.append(user_answer) db.add(user_answer) # Calculate total_bobot_max for NM calculation try: total_bobot_max = await get_total_bobot_max( db, website_id, session.tryout_id, level="sedang" ) except ValueError: # Fallback: calculate from items we have total_bobot_max = sum( item.ctt_bobot or 0 for item in items.values() if item.level == "sedang" ) if total_bobot_max == 0: # If no bobot values, use count of questions total_bobot_max = len(items) # Calculate CTT NM (Nilai Mentah) nm = calculate_ctt_nm(total_bobot_earned, total_bobot_max) # Get normalization parameters based on tryout configuration if tryout.normalization_mode == "static": rataan = tryout.static_rataan sb = tryout.static_sb elif tryout.normalization_mode == "dynamic": # Get current stats for dynamic normalization stats_result = await db.execute( select(TryoutStats).where( TryoutStats.website_id == website_id, TryoutStats.tryout_id == session.tryout_id, ) ) stats = stats_result.scalar_one_or_none() if stats and stats.participant_count >= tryout.min_sample_for_dynamic: rataan = stats.rataan or tryout.static_rataan sb = stats.sb or tryout.static_sb else: # Not enough data, use static values rataan = tryout.static_rataan sb = tryout.static_sb else: # hybrid # Hybrid: use dynamic if enough data, otherwise static stats_result = await db.execute( select(TryoutStats).where( TryoutStats.website_id == website_id, TryoutStats.tryout_id == session.tryout_id, ) ) stats = stats_result.scalar_one_or_none() if stats and stats.participant_count >= tryout.min_sample_for_dynamic: rataan = stats.rataan or tryout.static_rataan sb = stats.sb or tryout.static_sb else: rataan = tryout.static_rataan sb = tryout.static_sb # Calculate CTT NN (Nilai Nasional) nn = calculate_ctt_nn(nm, rataan, sb) # Update session with results session.end_time = request.end_time session.is_completed = True session.total_benar = total_benar session.total_bobot_earned = total_bobot_earned session.NM = nm session.NN = nn session.rataan_used = rataan session.sb_used = sb # Update tryout stats incrementally await update_tryout_stats(db, website_id, session.tryout_id, nm) # Commit all changes await db.commit() # Refresh to get updated relationships await db.refresh(session) # Build response return SessionCompleteResponse( id=session.id, session_id=session.session_id, wp_user_id=session.wp_user_id, website_id=session.website_id, tryout_id=session.tryout_id, start_time=session.start_time, end_time=session.end_time, is_completed=session.is_completed, scoring_mode_used=session.scoring_mode_used, total_benar=session.total_benar, total_bobot_earned=session.total_bobot_earned, NM=session.NM, NN=session.NN, rataan_used=session.rataan_used, sb_used=session.sb_used, user_answers=[ UserAnswerOutput( id=ua.id, item_id=ua.item_id, response=ua.response, is_correct=ua.is_correct, time_spent=ua.time_spent, bobot_earned=ua.bobot_earned, scoring_mode_used=ua.scoring_mode_used, ) for ua in user_answer_records ], ) @router.get( "/{session_id}", response_model=SessionResponse, summary="Get session details", description="Retrieve session details including scores if completed.", ) async def get_session( session_id: str, db: AsyncSession = Depends(get_db), website_id: int = Depends(get_website_id_from_header), ) -> SessionResponse: """ Get session details. Args: session_id: Unique session identifier db: Database session website_id: Website ID from header Returns: SessionResponse with session details Raises: HTTPException: If session not found """ result = await db.execute( select(Session).where( Session.session_id == session_id, Session.website_id == website_id, ) ) session = result.scalar_one_or_none() if session is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {session_id} not found", ) return SessionResponse.model_validate(session) @router.post( "/", response_model=SessionResponse, status_code=status.HTTP_201_CREATED, summary="Create new session", description="Create a new tryout session for a student.", ) async def create_session( request: SessionCreateRequest, db: AsyncSession = Depends(get_db), ) -> SessionResponse: """ Create a new session. Args: request: Session creation request db: Database session Returns: SessionResponse with created session Raises: HTTPException: If tryout not found or session already exists """ # Verify tryout exists tryout_result = await db.execute( select(Tryout).where( Tryout.website_id == request.website_id, Tryout.tryout_id == request.tryout_id, ) ) tryout = tryout_result.scalar_one_or_none() if tryout is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tryout {request.tryout_id} not found for website {request.website_id}", ) # Check if session already exists existing_result = await db.execute( select(Session).where(Session.session_id == request.session_id) ) existing_session = existing_result.scalar_one_or_none() if existing_session: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Session {request.session_id} already exists", ) # Create new session session = Session( session_id=request.session_id, wp_user_id=request.wp_user_id, website_id=request.website_id, tryout_id=request.tryout_id, scoring_mode_used=request.scoring_mode, start_time=datetime.now(timezone.utc), is_completed=False, total_benar=0, total_bobot_earned=0.0, ) db.add(session) await db.commit() await db.refresh(session) return SessionResponse.model_validate(session)