Complete Section 1 security/auth hardening
This commit is contained in:
241
app/admin_web.py
241
app/admin_web.py
@@ -20,7 +20,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
|
||||
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED, HTTP_429_TOO_MANY_REQUESTS
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.database import get_db
|
||||
@@ -53,9 +53,13 @@ settings = get_settings()
|
||||
router = APIRouter(prefix="/admin", tags=["admin-web"])
|
||||
|
||||
SESSION_COOKIE = "access_token"
|
||||
CSRF_COOKIE = "admin_csrf_token"
|
||||
SESSION_PREFIX = "admin:session:"
|
||||
IMPORT_PREVIEW_PREFIX = "admin:import-preview:"
|
||||
IMPORT_PREVIEW_TTL_SECONDS = 900
|
||||
LOGIN_RATE_LIMIT_PREFIX = "admin:login:attempts:"
|
||||
LOGIN_RATE_LIMIT_MAX_ATTEMPTS = 10
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS = 300
|
||||
|
||||
_admin_redis = None
|
||||
|
||||
@@ -153,10 +157,27 @@ def _render_auth_page(
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html, status_code=status_code)
|
||||
csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||||
csrf_input = f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
|
||||
html = re.sub(
|
||||
r'(<form[^>]*method="post"[^>]*>)',
|
||||
r"\1" + csrf_input,
|
||||
html,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
response = HTMLResponse(html, status_code=status_code)
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE,
|
||||
csrf_token,
|
||||
path="/admin",
|
||||
httponly=False,
|
||||
secure=settings.ENVIRONMENT == "production",
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
|
||||
def _render_admin_page(request: Request, title: str, page_title: str, body: str) -> HTMLResponse:
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -213,7 +234,46 @@ def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html)
|
||||
csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||||
csrf_input = f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
|
||||
html = re.sub(
|
||||
r'(<form[^>]*method="post"[^>]*>)',
|
||||
r"\1" + csrf_input,
|
||||
html,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
response = HTMLResponse(html)
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE,
|
||||
csrf_token,
|
||||
path="/admin",
|
||||
httponly=False,
|
||||
secure=settings.ENVIRONMENT == "production",
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _verify_csrf(request: Request, csrf_token: str | None) -> None:
|
||||
cookie_token = request.cookies.get(CSRF_COOKIE)
|
||||
if not cookie_token or not csrf_token:
|
||||
raise HTTPException(status_code=403, detail="CSRF validation failed")
|
||||
if not secrets.compare_digest(cookie_token, csrf_token):
|
||||
raise HTTPException(status_code=403, detail="CSRF validation failed")
|
||||
|
||||
|
||||
async def _enforce_csrf(request: Request) -> None:
|
||||
form = await request.form()
|
||||
_verify_csrf(request, form.get("csrf_token"))
|
||||
|
||||
|
||||
async def _csrf_route_guard(request: Request) -> None:
|
||||
if request.method.upper() != "POST":
|
||||
return
|
||||
await _enforce_csrf(request)
|
||||
|
||||
|
||||
router.dependencies.append(Depends(_csrf_route_guard))
|
||||
|
||||
|
||||
def _table(headers: list[str], rows: list[list[Any]]) -> str:
|
||||
@@ -1052,10 +1112,58 @@ async def login_submit(
|
||||
password: str = Form(...),
|
||||
remember_me: str | None = Form(None),
|
||||
):
|
||||
|
||||
if _admin_redis is None:
|
||||
body = """
|
||||
<div class="error">Admin backend is temporarily unavailable. Please try again.</div>
|
||||
<form method="post" action="/admin/login" autocomplete="off">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password">
|
||||
<label class="row"><input type="checkbox" name="remember_me" __REMEMBER_ME_CHECKED__> Remember me</label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
"""
|
||||
return _render_auth_page(
|
||||
request,
|
||||
"Admin Login",
|
||||
"Use the configured admin credentials to access the dashboard.",
|
||||
body,
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rate_limit_key = f"{LOGIN_RATE_LIMIT_PREFIX}{client_ip}"
|
||||
attempts_raw = await _admin_redis.get(rate_limit_key)
|
||||
attempts = int(attempts_raw) if attempts_raw else 0
|
||||
if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS:
|
||||
body = """
|
||||
<div class="error">Too many login attempts. Please wait a few minutes and try again.</div>
|
||||
<form method="post" action="/admin/login" autocomplete="off">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password">
|
||||
<label class="row"><input type="checkbox" name="remember_me" __REMEMBER_ME_CHECKED__> Remember me</label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
"""
|
||||
return _render_auth_page(
|
||||
request,
|
||||
"Admin Login",
|
||||
"Use the configured admin credentials to access the dashboard.",
|
||||
body,
|
||||
status_code=HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
if not (
|
||||
secrets.compare_digest(username, settings.ADMIN_USERNAME)
|
||||
and secrets.compare_digest(password, settings.ADMIN_PASSWORD)
|
||||
):
|
||||
attempts = await _admin_redis.incr(rate_limit_key)
|
||||
if attempts == 1:
|
||||
await _admin_redis.expire(rate_limit_key, LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
body = f"""
|
||||
<div class="error">Invalid username or password.</div>
|
||||
<form method="post" action="/admin/login" autocomplete="off">
|
||||
@@ -1075,11 +1183,21 @@ async def login_submit(
|
||||
status_code=HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
await _admin_redis.delete(rate_limit_key)
|
||||
|
||||
expire = settings.ADMIN_SESSION_EXPIRE_SECONDS
|
||||
response = _dashboard_redirect()
|
||||
secure_cookie = settings.ENVIRONMENT == "production"
|
||||
if remember_me == "on":
|
||||
expire = max(expire, 3600 * 24 * 30)
|
||||
response.set_cookie("remember_me", "on", expires=expire, path="/admin")
|
||||
response.set_cookie(
|
||||
"remember_me",
|
||||
"on",
|
||||
expires=expire,
|
||||
path="/admin",
|
||||
secure=secure_cookie,
|
||||
samesite="lax",
|
||||
)
|
||||
else:
|
||||
response.delete_cookie("remember_me", path="/admin")
|
||||
|
||||
@@ -1090,6 +1208,7 @@ async def login_submit(
|
||||
expires=expire,
|
||||
path="/admin",
|
||||
httponly=True,
|
||||
secure=secure_cookie,
|
||||
samesite="lax",
|
||||
)
|
||||
await _admin_redis.set(f"{SESSION_PREFIX}{token}", settings.ADMIN_USERNAME, ex=expire)
|
||||
@@ -1179,7 +1298,7 @@ async def dashboard_view(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
</div>
|
||||
<p style="margin-top:20px"><a href="/admin/ai-playground">Open AI Playground</a></p>
|
||||
"""
|
||||
return _render_admin_page("IRT Bank Soal Admin", "Dashboard", body)
|
||||
return _render_admin_page(request, "IRT Bank Soal Admin", "Dashboard", body)
|
||||
|
||||
|
||||
@router.get("/websites", include_in_schema=False)
|
||||
@@ -1191,7 +1310,7 @@ async def websites_view(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||
websites = list(result.scalars().all())
|
||||
body = _websites_form_body(websites)
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
|
||||
@router.post("/websites", include_in_schema=False)
|
||||
@@ -1217,7 +1336,7 @@ async def websites_submit(
|
||||
site_name=site_name,
|
||||
site_url=site_url,
|
||||
)
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
if not normalized_url.startswith(("http://", "https://")):
|
||||
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||
@@ -1228,7 +1347,7 @@ async def websites_submit(
|
||||
site_name=site_name,
|
||||
site_url=site_url,
|
||||
)
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
website = Website(site_name=normalized_name, site_url=normalized_url)
|
||||
db.add(website)
|
||||
@@ -1244,7 +1363,7 @@ async def websites_submit(
|
||||
site_name=site_name,
|
||||
site_url=site_url,
|
||||
)
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||
websites = list(result.scalars().all())
|
||||
@@ -1252,7 +1371,7 @@ async def websites_submit(
|
||||
websites,
|
||||
success=f"Website added successfully with ID {website.id}.",
|
||||
)
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
|
||||
@router.get("/websites/{website_id}/edit", include_in_schema=False)
|
||||
@@ -1270,10 +1389,10 @@ async def website_edit_view(
|
||||
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||
websites = list(result.scalars().all())
|
||||
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
body = _website_edit_form_body(website)
|
||||
return _render_admin_page("Edit Website", "Edit Website", body)
|
||||
return _render_admin_page(request, "Edit Website", "Edit Website", body)
|
||||
|
||||
|
||||
@router.post("/websites/{website_id}/edit", include_in_schema=False)
|
||||
@@ -1293,7 +1412,7 @@ async def website_edit_submit(
|
||||
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||
websites = list(result.scalars().all())
|
||||
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
normalized_name = site_name.strip()
|
||||
normalized_url = site_url.strip().rstrip("/")
|
||||
@@ -1305,7 +1424,7 @@ async def website_edit_submit(
|
||||
site_name=site_name,
|
||||
site_url=site_url,
|
||||
)
|
||||
return _render_admin_page("Edit Website", "Edit Website", body)
|
||||
return _render_admin_page(request, "Edit Website", "Edit Website", body)
|
||||
|
||||
if not normalized_url.startswith(("http://", "https://")):
|
||||
body = _website_edit_form_body(
|
||||
@@ -1314,7 +1433,7 @@ async def website_edit_submit(
|
||||
site_name=site_name,
|
||||
site_url=site_url,
|
||||
)
|
||||
return _render_admin_page("Edit Website", "Edit Website", body)
|
||||
return _render_admin_page(request, "Edit Website", "Edit Website", body)
|
||||
|
||||
website.site_name = normalized_name
|
||||
website.site_url = normalized_url
|
||||
@@ -1328,14 +1447,14 @@ async def website_edit_submit(
|
||||
site_name=site_name,
|
||||
site_url=site_url,
|
||||
)
|
||||
return _render_admin_page("Edit Website", "Edit Website", body)
|
||||
return _render_admin_page(request, "Edit Website", "Edit Website", body)
|
||||
|
||||
await db.refresh(website)
|
||||
body = _website_edit_form_body(
|
||||
website,
|
||||
success=f"Website #{website.id} updated successfully.",
|
||||
)
|
||||
return _render_admin_page("Edit Website", "Edit Website", body)
|
||||
return _render_admin_page(request, "Edit Website", "Edit Website", body)
|
||||
|
||||
|
||||
@router.post("/websites/{website_id}/delete", include_in_schema=False)
|
||||
@@ -1353,7 +1472,7 @@ async def website_delete_submit(
|
||||
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||
websites = list(result.scalars().all())
|
||||
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
deleted_label = f"{website.site_name} ({website.site_url})"
|
||||
await db.delete(website)
|
||||
@@ -1365,7 +1484,7 @@ async def website_delete_submit(
|
||||
websites,
|
||||
success=f"Website deleted successfully: {deleted_label}",
|
||||
)
|
||||
return _render_admin_page("Websites", "Websites", body)
|
||||
return _render_admin_page(request, "Websites", "Websites", body)
|
||||
|
||||
|
||||
@router.get("/tryout-import", include_in_schema=False)
|
||||
@@ -1377,7 +1496,7 @@ async def tryout_import_view(request: Request, db: AsyncSession = Depends(get_db
|
||||
websites = await _load_websites(db)
|
||||
snapshots = await _recent_snapshots(db)
|
||||
body = _tryout_import_form_body(websites, snapshots)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
|
||||
@router.post("/tryout-import/preview", include_in_schema=False)
|
||||
@@ -1401,7 +1520,7 @@ async def tryout_import_preview(
|
||||
error="File must be .json format.",
|
||||
selected_website_id=website_id,
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
try:
|
||||
payload_bytes = await file.read()
|
||||
@@ -1414,7 +1533,7 @@ async def tryout_import_preview(
|
||||
error="File must be UTF-8 encoded JSON.",
|
||||
selected_website_id=website_id,
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
except json.JSONDecodeError as exc:
|
||||
body = _tryout_import_form_body(
|
||||
websites,
|
||||
@@ -1422,7 +1541,7 @@ async def tryout_import_preview(
|
||||
error=f"Invalid JSON file: {exc}",
|
||||
selected_website_id=website_id,
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
try:
|
||||
preview = await preview_tryout_json_import(payload, website_id, db)
|
||||
@@ -1433,7 +1552,7 @@ async def tryout_import_preview(
|
||||
error=str(exc),
|
||||
selected_website_id=website_id,
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
preview_token = uuid.uuid4().hex
|
||||
await _admin_redis.set(
|
||||
@@ -1449,7 +1568,7 @@ async def tryout_import_preview(
|
||||
preview_token=preview_token,
|
||||
upload_filename=file.filename or "",
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
|
||||
@router.post("/tryout-import", include_in_schema=False)
|
||||
@@ -1474,7 +1593,7 @@ async def tryout_import_submit(
|
||||
error="Preview token expired. Upload the JSON again and preview before importing.",
|
||||
selected_website_id=website_id,
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_text)
|
||||
@@ -1488,7 +1607,7 @@ async def tryout_import_submit(
|
||||
error=str(exc),
|
||||
selected_website_id=website_id,
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -1506,7 +1625,7 @@ async def tryout_import_submit(
|
||||
),
|
||||
selected_website_id=website_id,
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
|
||||
@router.get("/snapshot-questions", include_in_schema=False)
|
||||
@@ -1528,11 +1647,11 @@ async def snapshot_questions_view(
|
||||
snapshots,
|
||||
error=f"Snapshot not found: {snapshot_id}",
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
|
||||
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot)
|
||||
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
|
||||
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
|
||||
|
||||
|
||||
@router.post("/snapshot-questions/promote-bulk", include_in_schema=False)
|
||||
@@ -1555,7 +1674,7 @@ async def snapshot_question_promote_bulk(
|
||||
snapshots,
|
||||
error=f"Snapshot not found: {snapshot_id}",
|
||||
)
|
||||
return _render_admin_page("Tryout Import", "Tryout Import", body)
|
||||
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
|
||||
|
||||
if not snapshot_question_ids:
|
||||
questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
|
||||
@@ -1565,7 +1684,7 @@ async def snapshot_question_promote_bulk(
|
||||
promoted_items_by_slot,
|
||||
error="Select at least one snapshot question to promote.",
|
||||
)
|
||||
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
|
||||
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
|
||||
|
||||
question_result = await db.execute(
|
||||
select(TryoutSnapshotQuestion).where(
|
||||
@@ -1607,7 +1726,7 @@ async def snapshot_question_promote_bulk(
|
||||
success_message += f" Latest basis item ID: {created_items[-1].id}."
|
||||
|
||||
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot, success=success_message)
|
||||
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
|
||||
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
|
||||
|
||||
|
||||
@router.get("/calibration-status", include_in_schema=False)
|
||||
@@ -1637,7 +1756,7 @@ async def calibration_status_view(request: Request, db: AsyncSession = Depends(g
|
||||
["Tryout ID", "Name", "Total Items", "Calibrated", "Calibration %", "Ready for IRT"],
|
||||
rows,
|
||||
)
|
||||
return _render_admin_page("Calibration Status", "Calibration Status", body)
|
||||
return _render_admin_page(request, "Calibration Status", "Calibration Status", body)
|
||||
|
||||
|
||||
@router.get("/item-statistics", include_in_schema=False)
|
||||
@@ -1672,7 +1791,7 @@ async def item_statistics_view(request: Request, db: AsyncSession = Depends(get_
|
||||
["Level", "Total Items", "Calibrated", "Calibration %", "Responses", "Avg Correctness"],
|
||||
rows,
|
||||
)
|
||||
return _render_admin_page("Item Statistics", "Item Statistics", body)
|
||||
return _render_admin_page(request, "Item Statistics", "Item Statistics", body)
|
||||
|
||||
|
||||
@router.get("/session-overview", include_in_schema=False)
|
||||
@@ -1702,7 +1821,7 @@ async def session_overview_view(request: Request, db: AsyncSession = Depends(get
|
||||
["Session ID", "WP User", "Tryout", "Completed", "Mode", "Benar", "NM", "NN", "Theta"],
|
||||
rows,
|
||||
)
|
||||
return _render_admin_page("Session Overview", "Session Overview", body)
|
||||
return _render_admin_page(request, "Session Overview", "Session Overview", body)
|
||||
|
||||
|
||||
@router.get("/basis-items", include_in_schema=False)
|
||||
@@ -1719,7 +1838,7 @@ async def basis_items_view(request: Request, db: AsyncSession = Depends(get_db))
|
||||
)
|
||||
basis_items = list(result.scalars().all())
|
||||
body = _basis_items_list_body(basis_items)
|
||||
return _render_admin_page("Basis Items", "Basis Items", body)
|
||||
return _render_admin_page(request, "Basis Items", "Basis Items", body)
|
||||
|
||||
|
||||
@router.get("/basis-items/{basis_item_id}", include_in_schema=False)
|
||||
@@ -1752,7 +1871,7 @@ async def basis_item_workspace_view(
|
||||
.limit(200)
|
||||
)
|
||||
body = _basis_items_list_body(list(result.scalars().all()))
|
||||
return _render_admin_page("Basis Items", "Basis Items", body)
|
||||
return _render_admin_page(request, "Basis Items", "Basis Items", body)
|
||||
|
||||
run_result = await db.execute(
|
||||
select(AIGenerationRun)
|
||||
@@ -1794,7 +1913,7 @@ async def basis_item_workspace_view(
|
||||
family_stats,
|
||||
filters,
|
||||
)
|
||||
return _render_admin_page(
|
||||
return _render_admin_page(request,
|
||||
f"Basis Item #{basis_item.id}",
|
||||
f"Basis Item Workspace #{basis_item.id}",
|
||||
body,
|
||||
@@ -1856,7 +1975,7 @@ async def basis_item_generate_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page(
|
||||
return _render_admin_page(request,
|
||||
f"Basis Item #{basis_item.id}",
|
||||
f"Basis Item Workspace #{basis_item.id}",
|
||||
body,
|
||||
@@ -1951,7 +2070,7 @@ async def basis_item_generate_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page(
|
||||
return _render_admin_page(request,
|
||||
f"Basis Item #{basis_item.id}",
|
||||
f"Basis Item Workspace #{basis_item.id}",
|
||||
body,
|
||||
@@ -2016,7 +2135,7 @@ async def basis_item_review_bulk(
|
||||
filters,
|
||||
success=f"Applied status '{action}' to selected variants.",
|
||||
)
|
||||
return _render_admin_page(
|
||||
return _render_admin_page(request,
|
||||
f"Basis Item #{basis_item.id}",
|
||||
f"Basis Item Workspace #{basis_item.id}",
|
||||
body,
|
||||
@@ -2202,7 +2321,7 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
|
||||
generated_variants=generated_variants,
|
||||
basis_item_id=str(basis_item_id or ""),
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
|
||||
@router.post("/ai-playground/seed-demo", include_in_schema=False)
|
||||
@@ -2225,7 +2344,7 @@ async def ai_playground_seed_demo(request: Request, db: AsyncSession = Depends(g
|
||||
generated_variants=generated_variants,
|
||||
basis_item_id=str(demo_item.id),
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
|
||||
@router.post("/ai-playground", include_in_schema=False)
|
||||
@@ -2268,7 +2387,7 @@ async def ai_playground_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
if target_level not in {"mudah", "sulit"}:
|
||||
body = _ai_form_body(
|
||||
@@ -2286,7 +2405,7 @@ async def ai_playground_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
if not validate_ai_model(ai_model):
|
||||
body = _ai_form_body(
|
||||
@@ -2304,7 +2423,7 @@ async def ai_playground_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
result = await db.execute(select(Item).where(Item.id == basis_item_id))
|
||||
basis_item = result.scalar_one_or_none()
|
||||
@@ -2324,7 +2443,7 @@ async def ai_playground_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
if basis_item.level != "sedang":
|
||||
body = _ai_form_body(
|
||||
@@ -2342,7 +2461,7 @@ async def ai_playground_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
if generation_count < 1 or generation_count > 50:
|
||||
body = _ai_form_body(
|
||||
@@ -2360,7 +2479,7 @@ async def ai_playground_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
run_id = await create_generation_run(
|
||||
basis_item_id=basis_item.id,
|
||||
@@ -2428,7 +2547,7 @@ async def ai_playground_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
body = _ai_form_body(
|
||||
True,
|
||||
@@ -2451,7 +2570,7 @@ async def ai_playground_submit(
|
||||
include_note_for_admin=note_for_admin,
|
||||
include_note_in_prompt=note_in_prompt,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
|
||||
@router.post("/ai-playground/save", include_in_schema=False)
|
||||
@@ -2483,7 +2602,7 @@ async def ai_playground_save(
|
||||
error="Only mudah or sulit generated items can be saved from the playground.",
|
||||
basis_items=basis_items,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
try:
|
||||
options = json.loads(options_json)
|
||||
@@ -2494,7 +2613,7 @@ async def ai_playground_save(
|
||||
error="Generated options payload is invalid.",
|
||||
basis_items=basis_items,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
from app.schemas.ai import GeneratedQuestion
|
||||
|
||||
@@ -2521,7 +2640,7 @@ async def ai_playground_save(
|
||||
error="Failed to save generated item. Check server logs for the database error.",
|
||||
basis_items=basis_items,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
await db.commit()
|
||||
updated_stats = await get_ai_stats(db)
|
||||
@@ -2535,7 +2654,7 @@ async def ai_playground_save(
|
||||
target_level=target_level,
|
||||
ai_model=ai_model,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
|
||||
@router.post("/ai-playground/review-bulk", include_in_schema=False)
|
||||
@@ -2564,7 +2683,7 @@ async def ai_playground_review_bulk(
|
||||
generation_runs=generation_runs,
|
||||
generated_variants=generated_variants,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
if not item_ids:
|
||||
body = _ai_form_body(
|
||||
@@ -2575,7 +2694,7 @@ async def ai_playground_review_bulk(
|
||||
generation_runs=generation_runs,
|
||||
generated_variants=generated_variants,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
result = await db.execute(
|
||||
select(Item).where(Item.id.in_(item_ids), Item.generated_by == "ai")
|
||||
@@ -2590,7 +2709,7 @@ async def ai_playground_review_bulk(
|
||||
generation_runs=generation_runs,
|
||||
generated_variants=generated_variants,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
reviewed_at = datetime.now(timezone.utc)
|
||||
for item in items:
|
||||
@@ -2612,7 +2731,7 @@ async def ai_playground_review_bulk(
|
||||
generation_runs=updated_runs,
|
||||
generated_variants=updated_variants,
|
||||
)
|
||||
return _render_admin_page("AI Playground", "AI Playground", body)
|
||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||
|
||||
|
||||
@router.get("/tryout/list", include_in_schema=False)
|
||||
|
||||
@@ -14,6 +14,12 @@ from sqlalchemy import select
|
||||
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.models import Item, Session, Tryout
|
||||
from app.services.cat_selection import (
|
||||
CATSelectionError,
|
||||
@@ -106,7 +112,8 @@ class CATTestResponse(BaseModel):
|
||||
)
|
||||
async def get_next_item_endpoint(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> NextItemResponse:
|
||||
"""
|
||||
Get the next item for a session.
|
||||
@@ -116,8 +123,13 @@ async def get_next_item_endpoint(
|
||||
Calls appropriate selection function based on selection_mode.
|
||||
Returns item or completion status.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
@@ -126,6 +138,11 @@ async def get_next_item_endpoint(
|
||||
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",
|
||||
)
|
||||
|
||||
if session.is_completed:
|
||||
return NextItemResponse(
|
||||
@@ -214,7 +231,8 @@ async def get_next_item_endpoint(
|
||||
async def submit_answer_endpoint(
|
||||
session_id: str,
|
||||
request: SubmitAnswerRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SubmitAnswerResponse:
|
||||
"""
|
||||
Submit an answer for an item.
|
||||
@@ -224,8 +242,13 @@ async def submit_answer_endpoint(
|
||||
Updates theta estimate.
|
||||
Records response time.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
@@ -234,6 +257,11 @@ async def submit_answer_endpoint(
|
||||
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",
|
||||
)
|
||||
|
||||
if session.is_completed:
|
||||
raise HTTPException(
|
||||
@@ -242,7 +270,11 @@ async def submit_answer_endpoint(
|
||||
)
|
||||
|
||||
# Get item
|
||||
item_query = select(Item).where(Item.id == request.item_id)
|
||||
item_query = select(Item).where(
|
||||
Item.id == request.item_id,
|
||||
Item.website_id == session.website_id,
|
||||
Item.tryout_id == session.tryout_id,
|
||||
)
|
||||
item_result = await db.execute(item_query)
|
||||
item = item_result.scalar_one_or_none()
|
||||
|
||||
@@ -296,7 +328,8 @@ async def submit_answer_endpoint(
|
||||
)
|
||||
async def test_cat_endpoint(
|
||||
request: CATTestRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> CATTestResponse:
|
||||
"""
|
||||
Test CAT selection algorithm.
|
||||
@@ -304,10 +337,13 @@ async def test_cat_endpoint(
|
||||
Simulates CAT selection for a tryout and returns
|
||||
the sequence of selected items with theta progression.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
|
||||
# Verify tryout exists
|
||||
tryout_query = select(Tryout).where(
|
||||
Tryout.tryout_id == request.tryout_id,
|
||||
Tryout.website_id == request.website_id
|
||||
Tryout.website_id == website_id
|
||||
)
|
||||
tryout_result = await db.execute(tryout_query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
@@ -315,14 +351,14 @@ async def test_cat_endpoint(
|
||||
if not tryout:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {request.tryout_id} not found for website {request.website_id}"
|
||||
detail=f"Tryout {request.tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
# Run simulation
|
||||
result = await simulate_cat_selection(
|
||||
db,
|
||||
tryout_id=request.tryout_id,
|
||||
website_id=request.website_id,
|
||||
website_id=website_id,
|
||||
initial_theta=request.initial_theta,
|
||||
selection_mode=request.selection_mode,
|
||||
max_items=request.max_items,
|
||||
@@ -346,13 +382,19 @@ async def test_cat_endpoint(
|
||||
)
|
||||
async def get_session_status_endpoint(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""
|
||||
Get session status for admin monitoring.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
|
||||
144
app/core/auth.py
Normal file
144
app/core/auth.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Token-based authentication helpers for website-scoped access control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
website_id: int
|
||||
role: str
|
||||
wp_user_id: Optional[str] = None
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(raw: str) -> bytes:
|
||||
padding = "=" * (-len(raw) % 4)
|
||||
return base64.urlsafe_b64decode((raw + padding).encode("ascii"))
|
||||
|
||||
|
||||
def issue_access_token(
|
||||
website_id: int,
|
||||
role: str = "student",
|
||||
wp_user_id: str | None = None,
|
||||
expires_in_seconds: int = 3600,
|
||||
) -> str:
|
||||
payload = {
|
||||
"website_id": int(website_id),
|
||||
"role": role,
|
||||
"wp_user_id": wp_user_id,
|
||||
"exp": int(time.time()) + int(expires_in_seconds),
|
||||
}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
payload_b64 = _b64url_encode(payload_bytes)
|
||||
sig = hmac.new(settings.SECRET_KEY.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{payload_b64}.{_b64url_encode(sig)}"
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> AuthContext:
|
||||
try:
|
||||
payload_b64, sig_b64 = token.split(".", 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token format",
|
||||
) from exc
|
||||
|
||||
expected_sig = hmac.new(
|
||||
settings.SECRET_KEY.encode("utf-8"),
|
||||
payload_b64.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
provided_sig = _b64url_decode(sig_b64)
|
||||
if not hmac.compare_digest(provided_sig, expected_sig):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token signature",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token payload",
|
||||
) from exc
|
||||
|
||||
exp = int(payload.get("exp", 0))
|
||||
if exp <= int(time.time()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token has expired",
|
||||
)
|
||||
|
||||
website_id = payload.get("website_id")
|
||||
role = payload.get("role")
|
||||
if website_id is None or not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token missing required claims",
|
||||
)
|
||||
|
||||
return AuthContext(
|
||||
website_id=int(website_id),
|
||||
role=str(role),
|
||||
wp_user_id=payload.get("wp_user_id"),
|
||||
)
|
||||
|
||||
|
||||
def get_auth_context(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
) -> AuthContext:
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization header is required",
|
||||
)
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization header format. Use: Bearer {token}",
|
||||
)
|
||||
return decode_access_token(parts[1])
|
||||
|
||||
|
||||
def require_website_auth(
|
||||
auth: AuthContext,
|
||||
allowed_roles: set[str] | None = None,
|
||||
) -> int:
|
||||
if allowed_roles is not None and auth.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions for this endpoint",
|
||||
)
|
||||
return auth.website_id
|
||||
|
||||
|
||||
def ensure_website_scope_matches(
|
||||
auth_website_id: int,
|
||||
payload_website_id: int,
|
||||
) -> None:
|
||||
if int(auth_website_id) != int(payload_website_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="website_id in payload must match authenticated website scope",
|
||||
)
|
||||
45
app/core/rate_limit.py
Normal file
45
app/core/rate_limit.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Lightweight in-process rate limiting helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
_lock = threading.Lock()
|
||||
_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
if request.client and request.client.host:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def enforce_rate_limit(
|
||||
request: Request,
|
||||
*,
|
||||
scope: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
ip = _client_ip(request)
|
||||
key = f"{scope}:{ip}"
|
||||
cutoff = now - window_seconds
|
||||
|
||||
with _lock:
|
||||
dq = _hits[key]
|
||||
while dq and dq[0] <= cutoff:
|
||||
dq.popleft()
|
||||
if len(dq) >= max_requests:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many requests for {scope}. Please try again later.",
|
||||
)
|
||||
dq.append(now)
|
||||
|
||||
29
app/main.py
29
app/main.py
@@ -40,6 +40,33 @@ from app.routers import (
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def validate_security_config() -> None:
|
||||
"""
|
||||
Enforce minimum security requirements for production deployments.
|
||||
"""
|
||||
if settings.ENVIRONMENT != "production":
|
||||
return
|
||||
|
||||
insecure_secret_values = {
|
||||
"",
|
||||
"dev-secret-key-change-in-production",
|
||||
"your-secret-key-here-change-in-production",
|
||||
}
|
||||
if settings.SECRET_KEY in insecure_secret_values:
|
||||
raise RuntimeError(
|
||||
"In production, SECRET_KEY must be set to a strong non-default value."
|
||||
)
|
||||
|
||||
if settings.ENABLE_ADMIN and (
|
||||
not settings.ADMIN_USERNAME
|
||||
or not settings.ADMIN_PASSWORD
|
||||
or settings.ADMIN_PASSWORD == "change-me"
|
||||
):
|
||||
raise RuntimeError(
|
||||
"In production with ENABLE_ADMIN=true, ADMIN_USERNAME and ADMIN_PASSWORD must be configured securely."
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""
|
||||
@@ -47,6 +74,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
validate_security_config()
|
||||
|
||||
# Startup: Initialize database
|
||||
await init_db()
|
||||
if settings.ENABLE_ADMIN:
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -41,6 +41,10 @@ class VerifySessionResponse(BaseModel):
|
||||
wp_user_info: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="WordPress user info from API"
|
||||
)
|
||||
access_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Signed API access token for authenticated website-scoped calls",
|
||||
)
|
||||
|
||||
|
||||
class SyncUsersRequest(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user