403 lines
12 KiB
Python
403 lines
12 KiB
Python
"""
|
|
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)
|