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)
|
||||
|
||||
Reference in New Issue
Block a user