Django admin is fantastic for quick starts – free UI generated from django models. At our online school, we use it for platform monitoring, course creation, and debugging.
But over time, cracks appeared as our models grew more complex:
- Navigation: Employees kept clicking at the wrong place: between User/UserProfile models, or between FileAttempt/Attempt models (yes, we have UserProfile to extend User with more fields, and FileAttempt to store individual files of an attempt – try explaining that to non-tech people)
- Number of clicks: Tracking activity required 3+ model hops. If you wanted to know the last 10 attempts of a user, you had to click 3 times: User -> UserProfile -> Attempt. Then click on each attempt to see the details.
- Inlines don’t scale: If you want to show multiple data models in one view you can use inlines. But nested inlines are a pain to navigate – you don’t know the direction of the nested inline or what will happen when you save
- Risk of bad behavior: You can click on other pages, delete or see data that you shouldn’t be able to access in the first place
“The transaction cost of context switching between admin sections became higher than the value they provided.” As a result, no-one wanted to use the admin dashboard.
We don’t need a general solution like Django Admin. We just need a dashboard to monitor the platform. so lets’ build a purpose-built monitoring dashboard.
Building a Purpose-Built Monitoring Dashboard
Three core metrics needed atomic monitoring:
- Daily active users (learner engagement)
- Exercise attempts (content effectiveness)
- Expiring subscriptions (business health)
Here’s the visual solution I built in 142 lines of Python:
Daily Users
Daily Attempts
Subscriptions
Design Philosophy
- Vertical slices: Each card shows complete context for one entity
- Direct manipulation: Edit Python code instead of admin checkboxes
The user card implementation looks like a pandas dataframe operation:
def render_user(user: User, attempts_user: List[Attempt]):
# Calculate engagement metrics using native Python collections
stats = {
'success': sum(1 for a in attempts_user if a.is_success),
'failed': len(attempts_user) - sum(1 for a in attempts_user if a.is_success),
'exercises': len({a.exercice.id for a in attempts_user}),
'languages': {a.exercice.language for a in attempts_user}
}
# Time delta calculation without ORM dependencies
duration = max(a.created for a in attempts_user) - min(a.created for a in attempts_user)
return Card(
DivLAligned(
DiceBearAvatar(user.email, h=24, w=24),
Div(cls="space-y-2")(
H3(user.email.split("@")[0], cls="truncate max-w-[300px]"),
DivHStacked(tag_school(user), Tags(stats['languages']), cls="gap-2")
)
),
Div(cls="space-y-2 mb-4")(
P(f"Success: {stats['success']}", cls="text-green-600 font-medium"),
P(f"Failed: {stats['failed']}", cls="text-red-600 font-medium"),
P(f"Exercises: {stats['exercises']}", cls="font-medium")
),
footer=DivFullySpaced(
P(f"{duration.seconds//3600}h{(duration.seconds//60)%60}min", cls="font-bold"),
UkIconLink("mail", height=16, href=f"mailto:{user.email}")
),
cls=CardT.hover + " " + ("bg-white" if tag_school(user) is None else "bg-blue-50")
)
Since LLMs are great at generating html code, this code was quite quick to generate and iterate on. I really like having pydantic / dataclass types as arguments of a frontend rendering function.
Smart Refresh Strategy
HTMX’s hx-trigger
enabled 5-minute auto-refresh while avoiding thundering herd problems:
last_refresh = datetime.datetime.now()
@app.get("/api/refresh")
def refresh():
global last_refresh
if needs_refresh(last_refresh): # Global timestamp prevents stampede
fetch_fresh_data() # Single atomic update
last_refresh = datetime.now()
return dashboard()
What is happening here:
- Coordinated updates: Global timestamp prevents concurrent refreshes
- Cached snapshots: Data persists between refresh intervals
- Full page updates: our HTMX swaps entire dashboard, but visually only the visible part is updated. we should probably do partial updates in the future.
Reusable Refresh Component to be used anywhere
This python function returning a paragraph P
will make any page call the /api/refresh
endpoint every 5 minutes.
def rerender_refresh():
return P(
UkIcon("refresh-cw", height=20),
"Auto-refresh every 5 minutes",
cls="text-sm text-gray-600 flex items-center gap-2",
hx_get="/api/refresh",
hx_target="body",
hx_trigger="every 5m"
)
For example, this function will display a grid of users, and will refresh the page every 5 minutes because of the rerender_refresh
component being used.
def render_users(users, attempts):
return Div(
H1(f"{len(users)} utilisateurs, {len(attempts)} tentatives"),
rerender_refresh(), # Adds auto-refresh every 5min via HTMX
Grid(
*(render_user(user, [a for a in attempts if a.user.id == user.id])
for user in users),
gap=4, cols_xl=4, cols_lg=3, cols_md=2, cols_sm=1, cols_xs=1
),
cls="space-y-4"
)
Simple Authentication
For internal dashboards and dev tools, a simple cookie-based auth is much faster to build than integrating OAuth providers:
beforeware = Beforeware(
user_auth_before,
skip=[r'/favicon.ico', r'/static/.*', r'.*.css', r'.*.js', "https://simn.fr/login", ]
)
@rt("https://simn.fr/login")
def post(password: str, session):
if password == SHARED_PASSWORD:
session['auth'] = 'authenticated' # Simple session storage
return RedirectResponse("https://simn.fr/", status_code=303)
return Container(
DivCentered(
Alert("Incorrect password", cls=AlertT.error),
A("Try again", href="https://simn.fr/login", cls=AT.primary)
)
)
A Beforeware is just a middleware, that runs before the request from the client is processed by the python server. It checks if the user is authenticated by checking the session cookie inside the user_auth_before
function.
My Takeaways
It’s great to have the freedom from not having to use a general purpose framework and use python. It enabled rapid development – I built a complete dashboard in just one day, with features like color-coded urgency indicators for subscriptions. The simplicity of using plain Python dictionaries for state, HTMX for refreshes, and basic session auth meant the entire codebase stayed under 400 lines across just two files.
Python’s built-in collection utilities handled data transformations more elegantly than JavaScript’s functional methods. Global variables for state management proved to be a pragmatic short-term solution that got the job done.