Replace incompatible admin CRUD landing with SQLAlchemy-safe pages

This commit is contained in:
dwindown
2026-04-01 19:49:22 +07:00
parent cf0a62548a
commit d738d90cef

View File

@@ -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