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.template import templates
from fastapi_admin.widgets import displays, inputs from fastapi_admin.widgets import displays, inputs
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 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 starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
from app.core.config import get_settings from app.core.config import get_settings
@@ -121,30 +120,7 @@ class EnvCredentialProvider(Provider):
@staticmethod @staticmethod
def _admin_home(request: Request) -> str: def _admin_home(request: Request) -> str:
""" return request.app.admin_path.rstrip("/") + "/dashboard"
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"
@staticmethod @staticmethod
def _login_url(request: Request) -> str: def _login_url(request: Request) -> str:
@@ -768,6 +744,153 @@ class SessionOverviewLink(Link):
# Initialize FastAPI Admin # 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: def create_admin_app() -> Any:
""" """
Create and configure FastAPI Admin application. 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(...). # NOTE: fastapi-admin 1.0.4 requires provider registration via app.configure(...).
# Keep provider implementation here for future integration during startup configure. # Keep provider implementation here for future integration during startup configure.
# NOTE: # Reset singleton registries so stale state cannot survive restarts.
# fastapi-admin Model resources rely on Tortoise ORM query APIs. admin_app.resources = []
# This codebase uses SQLAlchemy, so register only Link resources here. admin_app.model_resources = {}
# Keep Model resource classes in source for future migration work. _prune_incompatible_admin_routes()
# Register dashboard links (safe for SQLAlchemy-backed custom views)
admin_app.register(CalibrationDashboardLink) admin_app.register(CalibrationDashboardLink)
admin_app.register(ItemStatisticsLink) admin_app.register(ItemStatisticsLink)
admin_app.register(SessionOverviewLink) admin_app.register(SessionOverviewLink)
calibration_link = CalibrationDashboardLink() admin_app.get("/dashboard", dependencies=[Depends(get_current_admin)])(dashboard_view)
item_stats_link = ItemStatisticsLink() admin_app.get("/calibration_status", dependencies=[Depends(get_current_admin)])(calibration_status_view)
session_overview_link = SessionOverviewLink() 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) # Preserve previously exposed broken list URLs and redirect them to the safe dashboard.
admin_app.get("/item_statistics", dependencies=[Depends(get_current_admin)])(item_stats_link.get) for legacy_path in (
admin_app.get("/session_overview", dependencies=[Depends(get_current_admin)])(session_overview_link.get) "/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 return admin_app