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