Replace incompatible admin CRUD landing with SQLAlchemy-safe pages
This commit is contained in:
203
app/admin.py
203
app/admin.py
@@ -24,9 +24,8 @@ from fastapi_admin.resources import (
|
||||
from fastapi_admin.template import templates
|
||||
from fastapi_admin.widgets import displays, inputs
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -121,30 +120,7 @@ class EnvCredentialProvider(Provider):
|
||||
|
||||
@staticmethod
|
||||
def _admin_home(request: Request) -> str:
|
||||
"""
|
||||
Resolve a concrete admin page path.
|
||||
|
||||
This project uses SQLAlchemy models, while fastapi-admin's built-in
|
||||
Model CRUD pages are Tortoise-oriented. Prefer custom Link resources
|
||||
and known safe admin pages.
|
||||
"""
|
||||
admin_path = request.app.admin_path.rstrip("/")
|
||||
for resource in getattr(request.app, "resources", []):
|
||||
try:
|
||||
if issubclass(resource, Model):
|
||||
model_name = resource.model.__name__.lower()
|
||||
return f"{admin_path}/{model_name}/list"
|
||||
except TypeError:
|
||||
continue
|
||||
for resource in getattr(request.app, "resources", []):
|
||||
try:
|
||||
if issubclass(resource, Link):
|
||||
url = getattr(resource, "url", "")
|
||||
if isinstance(url, str) and url.startswith("/"):
|
||||
return url
|
||||
except TypeError:
|
||||
continue
|
||||
return f"{admin_path}/password"
|
||||
return request.app.admin_path.rstrip("/") + "/dashboard"
|
||||
|
||||
@staticmethod
|
||||
def _login_url(request: Request) -> str:
|
||||
@@ -768,6 +744,153 @@ class SessionOverviewLink(Link):
|
||||
# Initialize FastAPI Admin
|
||||
# =============================================================================
|
||||
|
||||
def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fb; color: #162033; }}
|
||||
.layout {{ display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }}
|
||||
.sidebar {{ background: #0f172a; color: #e2e8f0; padding: 24px 16px; }}
|
||||
.sidebar h1 {{ font-size: 18px; margin: 0 0 24px; }}
|
||||
.sidebar a {{ display: block; color: #cbd5e1; text-decoration: none; padding: 10px 12px; border-radius: 8px; margin-bottom: 8px; }}
|
||||
.sidebar a:hover {{ background: #1e293b; color: #fff; }}
|
||||
.content {{ padding: 32px; }}
|
||||
.card {{ background: #fff; border-radius: 14px; padding: 24px; box-shadow: 0 8px 30px rgba(15, 23, 42, 0.08); }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin-top: 16px; background: #fff; border-radius: 12px; overflow: hidden; }}
|
||||
th, td {{ padding: 12px 14px; border-bottom: 1px solid #e5e7eb; text-align: left; vertical-align: top; font-size: 14px; }}
|
||||
th {{ background: #f8fafc; font-weight: 600; }}
|
||||
.muted {{ color: #64748b; font-size: 14px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<h1>IRT Bank Soal Admin</h1>
|
||||
<a href="/admin/dashboard">Dashboard</a>
|
||||
<a href="/admin/calibration_status">Calibration Status</a>
|
||||
<a href="/admin/item_statistics">Item Statistics</a>
|
||||
<a href="/admin/session_overview">Session Overview</a>
|
||||
<a href="/admin/password">Password Info</a>
|
||||
<a href="/admin/logout">Logout</a>
|
||||
</aside>
|
||||
<main class="content">
|
||||
<div class="card">
|
||||
<h2>{page_title}</h2>
|
||||
{body}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
def _table(headers: list[str], rows: list[list[Any]]) -> str:
|
||||
head = "".join(f"<th>{header}</th>" for header in headers)
|
||||
body_rows = []
|
||||
for row in rows:
|
||||
cols = "".join(f"<td>{value}</td>" for value in row)
|
||||
body_rows.append(f"<tr>{cols}</tr>")
|
||||
body = "".join(body_rows) or f"<tr><td colspan=\"{len(headers)}\">No data</td></tr>"
|
||||
return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
|
||||
|
||||
|
||||
def _prune_incompatible_admin_routes() -> None:
|
||||
admin_app.router.routes[:] = [
|
||||
route
|
||||
for route in admin_app.router.routes
|
||||
if not getattr(route, "path", "").startswith("/{resource}/")
|
||||
]
|
||||
|
||||
|
||||
async def dashboard_view(request: Request, admin: AdminPrincipal = Depends(get_current_admin)):
|
||||
_ = admin
|
||||
body = (
|
||||
'<p class="muted">This admin runs in SQLAlchemy-safe mode. '
|
||||
"The original fastapi-admin CRUD pages depend on Tortoise ORM and were removed.</p>"
|
||||
"<p>Use the navigation links to inspect operational data.</p>"
|
||||
)
|
||||
return _render_admin_page("IRT Bank Soal Admin", "Dashboard", body)
|
||||
|
||||
|
||||
async def calibration_status_view(
|
||||
request: Request,
|
||||
admin: AdminPrincipal = Depends(get_current_admin),
|
||||
):
|
||||
_ = admin
|
||||
data = await CalibrationDashboardLink().get(request)
|
||||
rows = [
|
||||
[
|
||||
item["tryout_id"],
|
||||
item["name"],
|
||||
item["total_items"],
|
||||
item["calibrated_items"],
|
||||
f'{item["calibration_percentage"]:.2f}%',
|
||||
"Yes" if item["ready_for_irt"] else "No",
|
||||
]
|
||||
for item in data["data"]
|
||||
]
|
||||
body = _table(
|
||||
["Tryout ID", "Name", "Total Items", "Calibrated", "Calibration %", "Ready for IRT"],
|
||||
rows,
|
||||
)
|
||||
return _render_admin_page("Calibration Status", "Calibration Status", body)
|
||||
|
||||
|
||||
async def item_statistics_view(
|
||||
request: Request,
|
||||
admin: AdminPrincipal = Depends(get_current_admin),
|
||||
):
|
||||
_ = admin
|
||||
data = await ItemStatisticsLink().get(request)
|
||||
rows = [
|
||||
[
|
||||
item["level"],
|
||||
item["total_items"],
|
||||
item["calibrated_items"],
|
||||
f'{item["calibration_percentage"]:.2f}%',
|
||||
item["total_responses"],
|
||||
f'{item["avg_correctness"]:.4f}',
|
||||
]
|
||||
for item in data["data"]
|
||||
]
|
||||
body = _table(
|
||||
["Level", "Total Items", "Calibrated", "Calibration %", "Responses", "Avg Correctness"],
|
||||
rows,
|
||||
)
|
||||
return _render_admin_page("Item Statistics", "Item Statistics", body)
|
||||
|
||||
|
||||
async def session_overview_view(
|
||||
request: Request,
|
||||
admin: AdminPrincipal = Depends(get_current_admin),
|
||||
):
|
||||
_ = admin
|
||||
data = await SessionOverviewLink().get(request)
|
||||
rows = [
|
||||
[
|
||||
item["session_id"],
|
||||
item["wp_user_id"],
|
||||
item["tryout_id"],
|
||||
"Yes" if item["is_completed"] else "No",
|
||||
item["scoring_mode_used"],
|
||||
item["total_benar"],
|
||||
item["NM"],
|
||||
item["NN"],
|
||||
item["theta"],
|
||||
]
|
||||
for item in data["data"]
|
||||
]
|
||||
body = _table(
|
||||
["Session ID", "WP User", "Tryout", "Completed", "Mode", "Benar", "NM", "NN", "Theta"],
|
||||
rows,
|
||||
)
|
||||
return _render_admin_page("Session Overview", "Session Overview", body)
|
||||
|
||||
def create_admin_app() -> Any:
|
||||
"""
|
||||
Create and configure FastAPI Admin application.
|
||||
@@ -784,23 +907,29 @@ def create_admin_app() -> Any:
|
||||
# NOTE: fastapi-admin 1.0.4 requires provider registration via app.configure(...).
|
||||
# Keep provider implementation here for future integration during startup configure.
|
||||
|
||||
# NOTE:
|
||||
# fastapi-admin Model resources rely on Tortoise ORM query APIs.
|
||||
# This codebase uses SQLAlchemy, so register only Link resources here.
|
||||
# Keep Model resource classes in source for future migration work.
|
||||
# Reset singleton registries so stale state cannot survive restarts.
|
||||
admin_app.resources = []
|
||||
admin_app.model_resources = {}
|
||||
_prune_incompatible_admin_routes()
|
||||
|
||||
# Register dashboard links (safe for SQLAlchemy-backed custom views)
|
||||
admin_app.register(CalibrationDashboardLink)
|
||||
admin_app.register(ItemStatisticsLink)
|
||||
admin_app.register(SessionOverviewLink)
|
||||
|
||||
calibration_link = CalibrationDashboardLink()
|
||||
item_stats_link = ItemStatisticsLink()
|
||||
session_overview_link = SessionOverviewLink()
|
||||
admin_app.get("/dashboard", dependencies=[Depends(get_current_admin)])(dashboard_view)
|
||||
admin_app.get("/calibration_status", dependencies=[Depends(get_current_admin)])(calibration_status_view)
|
||||
admin_app.get("/item_statistics", dependencies=[Depends(get_current_admin)])(item_statistics_view)
|
||||
admin_app.get("/session_overview", dependencies=[Depends(get_current_admin)])(session_overview_view)
|
||||
|
||||
admin_app.get("/calibration_status", dependencies=[Depends(get_current_admin)])(calibration_link.get)
|
||||
admin_app.get("/item_statistics", dependencies=[Depends(get_current_admin)])(item_stats_link.get)
|
||||
admin_app.get("/session_overview", dependencies=[Depends(get_current_admin)])(session_overview_link.get)
|
||||
# Preserve previously exposed broken list URLs and redirect them to the safe dashboard.
|
||||
for legacy_path in (
|
||||
"/tryout/list",
|
||||
"/item/list",
|
||||
"/user/list",
|
||||
"/session/list",
|
||||
"/tryoutstats/list",
|
||||
):
|
||||
admin_app.get(legacy_path, dependencies=[Depends(get_current_admin)])(dashboard_view)
|
||||
|
||||
return admin_app
|
||||
|
||||
|
||||
Reference in New Issue
Block a user