Complete Section 1 security/auth hardening

This commit is contained in:
dwindown
2026-04-30 11:35:56 +07:00
parent 432ffbcdb9
commit 12d2d9458f
15 changed files with 863 additions and 232 deletions

View File

@@ -7,11 +7,18 @@ Admin endpoints for AI question generation playground.
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.auth import (
AuthContext,
ensure_website_scope_matches,
get_auth_context,
require_website_auth,
)
from app.core.rate_limit import enforce_rate_limit
from app.database import get_db
from app.models.item import Item
from app.schemas.ai import (
@@ -58,8 +65,10 @@ router = APIRouter(prefix="/admin/ai", tags=["admin", "ai-generation"])
},
)
async def generate_preview(
request_http: Request,
request: AIGeneratePreviewRequest,
db: Annotated[AsyncSession, Depends(get_db)],
auth: AuthContext = Depends(get_auth_context),
) -> AIGeneratePreviewResponse:
"""
Generate AI question preview (no database save).
@@ -68,6 +77,14 @@ async def generate_preview(
- **target_level**: Target difficulty (mudah/sulit)
- **ai_model**: OpenRouter model to use (default: qwen/qwen2.5-32b-instruct)
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request_http,
scope="ai.generate_preview",
max_requests=40,
window_seconds=300,
)
# Validate AI model
if not validate_ai_model(request.ai_model):
supported = ", ".join(SUPPORTED_MODELS.keys())
@@ -88,6 +105,7 @@ async def generate_preview(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Basis item not found: {request.basis_item_id}",
)
ensure_website_scope_matches(website_id, basis_item.website_id)
# Validate basis item is sedang level
if basis_item.level != "sedang":
@@ -158,8 +176,10 @@ async def generate_preview(
},
)
async def generate_save(
request_http: Request,
request: AISaveRequest,
db: Annotated[AsyncSession, Depends(get_db)],
auth: AuthContext = Depends(get_auth_context),
) -> AISaveResponse:
"""
Save AI-generated question to database.
@@ -175,6 +195,15 @@ async def generate_save(
- **level**: Difficulty level
- **ai_model**: AI model used for generation
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request_http,
scope="ai.generate_save",
max_requests=40,
window_seconds=300,
)
ensure_website_scope_matches(website_id, request.website_id)
# Verify basis item exists
basis_result = await db.execute(
select(Item).where(Item.id == request.basis_item_id)
@@ -186,6 +215,7 @@ async def generate_save(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Basis item not found: {request.basis_item_id}",
)
ensure_website_scope_matches(website_id, basis_item.website_id)
# Check for duplicate (same tryout, website, slot, level)
existing_result = await db.execute(
@@ -256,10 +286,12 @@ async def generate_save(
)
async def get_stats(
db: Annotated[AsyncSession, Depends(get_db)],
auth: AuthContext = Depends(get_auth_context),
) -> AIStatsResponse:
"""
Get AI generation statistics.
"""
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
stats = await get_ai_stats(db)
return AIStatsResponse(
@@ -276,10 +308,11 @@ async def get_stats(
summary="List supported AI models",
description="Returns list of supported AI models for question generation.",
)
async def list_models() -> dict:
async def list_models(auth: AuthContext = Depends(get_auth_context)) -> dict:
"""
List supported AI models.
"""
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
return {
"models": [
{

View File

@@ -12,12 +12,12 @@ Endpoints:
import os
import tempfile
import json
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import AuthContext, get_auth_context, require_website_auth
from app.core.rate_limit import enforce_rate_limit
from app.database import get_db
from app.models import Website
from app.services.excel_import import (
@@ -35,35 +35,6 @@ from app.services.tryout_json_import import (
router = APIRouter(prefix="/api/v1/import-export", tags=["import-export"])
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",
)
async def ensure_website_exists(
website_id: int,
db: AsyncSession,
@@ -85,8 +56,9 @@ async def ensure_website_exists(
description="Parse Excel file and return preview without saving to database.",
)
async def preview_import(
request: Request,
file: UploadFile = File(..., description="Excel file (.xlsx)"),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> dict:
"""
Preview Excel import without saving to database.
@@ -104,6 +76,14 @@ async def preview_import(
Raises:
HTTPException: If file format is invalid or parsing fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request,
scope="import.preview",
max_requests=30,
window_seconds=300,
)
# Validate file format
if not file.filename or not file.filename.lower().endswith('.xlsx'):
raise HTTPException(
@@ -173,8 +153,9 @@ async def preview_import(
description="Parse Excel file and import questions to database with 100% data integrity.",
)
async def import_questions(
request: Request,
file: UploadFile = File(..., description="Excel file (.xlsx)"),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
tryout_id: str = Form(..., description="Tryout identifier"),
db: AsyncSession = Depends(get_db),
) -> dict:
@@ -199,6 +180,14 @@ async def import_questions(
Raises:
HTTPException: If file format is invalid, validation fails, or import fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request,
scope="import.questions",
max_requests=20,
window_seconds=300,
)
# Validate file format
if not file.filename or not file.filename.lower().endswith('.xlsx'):
raise HTTPException(
@@ -297,7 +286,7 @@ async def import_questions(
)
async def export_questions(
tryout_id: str,
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
) -> FileResponse:
"""
@@ -320,6 +309,8 @@ async def export_questions(
Raises:
HTTPException: If tryout has no questions or export fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
try:
# Export questions to Excel
output_path = await export_questions_to_excel(
@@ -354,10 +345,18 @@ async def export_questions(
description="Parse a Sejoli tryout export JSON file and show snapshot diff without writing to database.",
)
async def preview_tryout_json(
request: Request,
file: UploadFile = File(..., description="Sejoli tryout export JSON"),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
) -> dict:
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request,
scope="import.tryout_json_preview",
max_requests=30,
window_seconds=300,
)
if not file.filename or not file.filename.lower().endswith(".json"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -389,10 +388,18 @@ async def preview_tryout_json(
description="Store Sejoli tryout export JSON as read-only snapshot data and upsert normalized reference questions.",
)
async def import_tryout_json(
request: Request,
file: UploadFile = File(..., description="Sejoli tryout export JSON"),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
) -> dict:
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request,
scope="import.tryout_json",
max_requests=20,
window_seconds=300,
)
if not file.filename or not file.filename.lower().endswith(".json"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -14,11 +14,17 @@ import os
from datetime import datetime
from typing import List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.core.auth import (
AuthContext,
ensure_website_scope_matches,
get_auth_context,
require_website_auth,
)
from app.schemas.report import (
StudentPerformanceReportOutput,
AggregatePerformanceStatsOutput,
@@ -55,35 +61,6 @@ from app.services.reporting import (
router = APIRouter(prefix="/reports", tags=["reports"])
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",
)
# =============================================================================
# Student Performance Report Endpoints
# =============================================================================
@@ -97,7 +74,7 @@ def get_website_id_from_header(
async def get_student_performance_report(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
date_start: Optional[datetime] = None,
date_end: Optional[datetime] = None,
format_type: Literal["individual", "aggregate", "both"] = "both",
@@ -107,6 +84,7 @@ async def get_student_performance_report(
Returns individual student records and/or aggregate statistics.
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
date_range = None
if date_start or date_end:
date_range = {}
@@ -190,7 +168,7 @@ def _convert_student_performance_report(report: StudentPerformanceReport) -> Stu
async def get_item_analysis_report(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
) -> ItemAnalysisReportOutput:
@@ -199,6 +177,7 @@ async def get_item_analysis_report(
Returns item difficulty, discrimination, and information function data.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
report = await generate_item_analysis_report(
tryout_id=tryout_id,
website_id=website_id,
@@ -248,13 +227,14 @@ async def get_item_analysis_report(
async def get_calibration_status_report(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> CalibrationStatusReportOutput:
"""
Get calibration status report.
Returns calibration progress, items awaiting calibration, and IRT readiness status.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
report = await generate_calibration_status_report(
tryout_id=tryout_id,
website_id=website_id,
@@ -313,7 +293,7 @@ async def get_calibration_status_report(
async def get_tryout_comparison_report(
tryout_ids: str, # Comma-separated list
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
group_by: Literal["date", "subject"] = "date",
) -> TryoutComparisonReportOutput:
"""
@@ -321,6 +301,7 @@ async def get_tryout_comparison_report(
Compares tryouts across dates or subjects.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
if len(tryout_id_list) < 2:
@@ -371,12 +352,15 @@ async def get_tryout_comparison_report(
async def create_report_schedule(
request: ReportScheduleRequest,
db: AsyncSession = Depends(get_db),
auth: AuthContext = Depends(get_auth_context),
) -> ReportScheduleResponse:
"""
Schedule a report.
Creates a scheduled report that will be generated automatically.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
ensure_website_scope_matches(website_id, request.website_id)
schedule_id = schedule_report(
report_type=request.report_type,
schedule=request.schedule,
@@ -403,13 +387,14 @@ async def create_report_schedule(
)
async def get_scheduled_report_details(
schedule_id: str,
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> ReportScheduleOutput:
"""
Get scheduled report details.
Returns the configuration and status of a scheduled report.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
scheduled = get_scheduled_report(schedule_id)
if not scheduled:
@@ -446,13 +431,14 @@ async def get_scheduled_report_details(
description="List all scheduled reports for a website.",
)
async def list_scheduled_reports_endpoint(
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> List[ReportScheduleOutput]:
"""
List all scheduled reports.
Returns all scheduled reports for the current website.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
reports = list_scheduled_reports(website_id=website_id)
return [
@@ -480,13 +466,14 @@ async def list_scheduled_reports_endpoint(
)
async def cancel_scheduled_report_endpoint(
schedule_id: str,
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> dict:
"""
Cancel a scheduled report.
Removes the scheduled report from the system.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
scheduled = get_scheduled_report(schedule_id)
if not scheduled:
@@ -528,13 +515,14 @@ async def export_scheduled_report(
schedule_id: str,
format: Literal["csv", "xlsx", "pdf"],
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""
Export a scheduled report.
Generates the report and returns it as a file download.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
scheduled = get_scheduled_report(schedule_id)
if not scheduled:
@@ -628,11 +616,12 @@ async def export_student_performance_direct(
format: Literal["csv", "xlsx", "pdf"],
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
date_start: Optional[datetime] = None,
date_end: Optional[datetime] = None,
):
"""Export student performance report directly."""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
date_range = None
if date_start or date_end:
date_range = {}
@@ -676,11 +665,12 @@ async def export_item_analysis_direct(
format: Literal["csv", "xlsx", "pdf"],
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
):
"""Export item analysis report directly."""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
report = await generate_item_analysis_report(
tryout_id=tryout_id,
website_id=website_id,
@@ -717,9 +707,10 @@ async def export_calibration_status_direct(
format: Literal["csv", "xlsx", "pdf"],
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""Export calibration status report directly."""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
report = await generate_calibration_status_report(
tryout_id=tryout_id,
website_id=website_id,
@@ -754,10 +745,11 @@ async def export_tryout_comparison_direct(
format: Literal["csv", "xlsx", "pdf"],
tryout_ids: str, # Comma-separated
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
group_by: Literal["date", "subject"] = "date",
):
"""Export tryout comparison report directly."""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
if len(tryout_id_list) < 2:

View File

@@ -8,14 +8,18 @@ Endpoints:
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.core.auth import (
AuthContext,
ensure_website_scope_matches,
get_auth_context,
require_website_auth,
)
from app.models.item import Item
from app.models.session import Session
from app.models.tryout import Tryout
@@ -39,35 +43,6 @@ from app.services.ctt_scoring import (
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,
@@ -78,7 +53,7 @@ async def complete_session(
session_id: str,
request: SessionCompleteRequest,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionCompleteResponse:
"""
Complete a session by submitting answers and calculating CTT scores.
@@ -104,6 +79,8 @@ async def complete_session(
Raises:
HTTPException: If session not found, already completed, or validation fails
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get session with tryout relationship
result = await db.execute(
select(Session)
@@ -126,6 +103,11 @@ async def complete_session(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session is already completed",
)
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Session does not belong to this authenticated user",
)
# Get tryout configuration
tryout = session.tryout
@@ -298,7 +280,7 @@ async def complete_session(
async def get_session(
session_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionResponse:
"""
Get session details.
@@ -314,6 +296,8 @@ async def get_session(
Raises:
HTTPException: If session not found
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
result = await db.execute(
select(Session).where(
Session.session_id == session_id,
@@ -327,6 +311,11 @@ async def get_session(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found",
)
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Session does not belong to this authenticated user",
)
return SessionResponse.model_validate(session)
@@ -341,7 +330,7 @@ async def get_session(
async def create_session(
request: SessionCreateRequest,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionResponse:
"""
Create a new session.
@@ -356,13 +345,13 @@ async def create_session(
Raises:
HTTPException: If tryout not found or session already exists
"""
if request.website_id != website_id:
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
ensure_website_scope_matches(website_id, request.website_id)
if auth.role == "student" and request.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Website mismatch between payload and X-Website-ID header: "
f"{request.website_id} != {website_id}"
),
status_code=status.HTTP_403_FORBIDDEN,
detail="wp_user_id must match authenticated user",
)
# Verify tryout exists

View File

@@ -7,14 +7,15 @@ Endpoints:
- GET /tryout: List tryouts for a website
"""
from typing import List, Optional
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Integer, cast, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.core.auth import AuthContext, get_auth_context, require_website_auth
from app.models.item import Item
from app.models.tryout import Tryout
from app.models.tryout_stats import TryoutStats
@@ -29,35 +30,6 @@ from app.schemas.tryout import (
router = APIRouter(prefix="/tryout", tags=["tryouts"])
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.get(
"/{tryout_id}/config",
response_model=TryoutConfigResponse,
@@ -67,7 +39,7 @@ def get_website_id_from_header(
async def get_tryout_config(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> TryoutConfigResponse:
"""
Get tryout configuration.
@@ -78,6 +50,8 @@ async def get_tryout_config(
Raises:
HTTPException: If tryout not found
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get tryout with stats
result = await db.execute(
select(Tryout)
@@ -140,7 +114,7 @@ async def update_normalization(
tryout_id: str,
request: NormalizationUpdateRequest,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> NormalizationUpdateResponse:
"""
Update normalization settings for a tryout.
@@ -157,6 +131,8 @@ async def update_normalization(
Raises:
HTTPException: If tryout not found or validation fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
# Get tryout
result = await db.execute(
select(Tryout).where(
@@ -214,7 +190,7 @@ async def update_normalization(
)
async def list_tryouts(
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> List[TryoutConfigBrief]:
"""
List all tryouts for a website.
@@ -226,6 +202,8 @@ async def list_tryouts(
Returns:
List of TryoutConfigBrief
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get tryouts with stats
result = await db.execute(
select(Tryout)
@@ -255,7 +233,7 @@ async def list_tryouts(
async def get_calibration_status(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""
Get calibration status for items in a tryout.
@@ -273,6 +251,8 @@ async def get_calibration_status(
Raises:
HTTPException: If tryout not found
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
# Verify tryout exists
tryout_result = await db.execute(
select(Tryout).where(
@@ -324,7 +304,7 @@ async def get_calibration_status(
async def trigger_calibration(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""
Trigger IRT calibration for all items in a tryout.
@@ -343,6 +323,8 @@ async def trigger_calibration(
Raises:
HTTPException: If tryout not found or calibration fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
from app.services.irt_calibration import (
calibrate_all,
CALIBRATION_SAMPLE_THRESHOLD,
@@ -391,7 +373,7 @@ async def trigger_item_calibration(
tryout_id: str,
item_id: int,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""
Trigger IRT calibration for a single item.
@@ -408,6 +390,8 @@ async def trigger_item_calibration(
Raises:
HTTPException: If tryout or item not found
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
from app.services.irt_calibration import calibrate_item, CALIBRATION_SAMPLE_THRESHOLD
# Verify tryout exists

View File

@@ -10,11 +10,12 @@ Endpoints:
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.core.auth import issue_access_token
from app.models.user import User
from app.models.website import Website
from app.schemas.wordpress import (
@@ -36,6 +37,7 @@ from app.services.wordpress_auth import (
WordPressTokenInvalidError,
WebsiteNotFoundError,
)
from app.core.rate_limit import enforce_rate_limit
logger = logging.getLogger(__name__)
@@ -104,6 +106,7 @@ async def get_valid_website(
description="Fetch all users from WordPress API and sync to local database. Requires admin WordPress token.",
)
async def sync_users_endpoint(
request: Request,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
authorization: Optional[str] = Header(None, alias="Authorization"),
@@ -129,6 +132,13 @@ async def sync_users_endpoint(
Raises:
HTTPException: If website not found, token invalid, or API error
"""
enforce_rate_limit(
request,
scope="wordpress.sync_users",
max_requests=20,
window_seconds=300,
)
# Validate website exists
await get_valid_website(website_id, db)
@@ -196,6 +206,7 @@ async def sync_users_endpoint(
description="Verify WordPress JWT token and user identity.",
)
async def verify_session_endpoint(
http_request: Request,
request: VerifySessionRequest,
db: AsyncSession = Depends(get_db),
) -> VerifySessionResponse:
@@ -219,6 +230,13 @@ async def verify_session_endpoint(
Raises:
HTTPException: If website not found or API error
"""
enforce_rate_limit(
http_request,
scope="wordpress.verify_session",
max_requests=60,
window_seconds=300,
)
# Validate website exists
await get_valid_website(request.website_id, db)
@@ -253,6 +271,12 @@ async def verify_session_endpoint(
"display_name": wp_user_info.display_name,
"roles": wp_user_info.roles,
},
access_token=issue_access_token(
website_id=request.website_id,
role="student",
wp_user_id=request.wp_user_id,
expires_in_seconds=3600 * 24,
),
)
except WordPressTokenInvalidError as e: