learning-website-django1-10 Exercise 1: The Admin on Its Own Host, Read-Only
=============================================================================
Django 6.1.2. Give the owner a way to LOOK at the 4,408 imported pages, without a second
way to change them.
Two decisions:
1. The admin lives on its own host (admin.osztromok.com; admin.localhost in development) and
has its own URL configuration. It is not mounted on any content site, so /admin/ is a 404 on
languages.osztromok.com and a visitor cannot even see that it exists.
2. It is READ-ONLY. The files are the source of truth, and the importer overwrites the database
from them, so an edit made in the admin would silently vanish at the next import. A form
that loses your work without telling you is worse than no form.
Save as config/urls_admin.py:
"""The URL configuration of the admin host (admin.osztromok.com): the admin and nothing else."""
from django.contrib import admin
from django.urls import path
urlpatterns = [path("", admin.site.urls)]
The middleware sends the admin host to that URL configuration (this is the only change):
Save as apps/core/middleware.py:
from django.conf import settings
from django.http import HttpResponseNotFound
from config.sites_config import SITES
class SiteMiddleware:
"""Decide which site a request is for, from its Host header.
Sets request.site (the site name), request.site_info (its entry in the site map) and
request.urlconf (the URL configuration that only contains that site's routes)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().split(":")[0].lower() # get_host() also enforces ALLOWED_HOSTS
if host == getattr(settings, "ADMIN_HOST", None):
# the admin has its own host and its own URL configuration: no content site is involved
request.site = None
request.urlconf = "config.urls_admin"
return self.get_response(request)
site = settings.HOST_TO_SITE.get(host)
if site is None:
return HttpResponseNotFound("Unknown site", content_type="text/plain")
request.site = site
request.site_info = SITES[site]
request.urlconf = f"config.urlconfs.{site}" # Django uses this instead of ROOT_URLCONF
return self.get_response(request)
Settings added (development; production has the same with admin.osztromok.com):
ADMIN_HOST = "admin.localhost"
ALLOWED_HOSTS = site_hosts("dev", PINNED_SITE) + [ADMIN_HOST]
and in base.py: django.contrib.admin, auth, contenttypes, sessions and messages are installed,
with the session, authentication and message middleware and the auth and messages context
processors the admin needs. Production also lists the admin host in CSRF_TRUSTED_ORIGINS.
Save as apps/content/admin.py:
from django.conf import settings
from django.contrib import admin
from django.utils.html import format_html
from .models import Course, Page
class ReadOnlyAdmin(admin.ModelAdmin):
"""The files are the source of truth and the importer overwrites the database from them, so an edit made here
would silently disappear at the next import. The admin is therefore a way to LOOK, not to edit."""
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(Course)
class CourseAdmin(ReadOnlyAdmin):
list_display = ("name", "site", "folder", "course_no")
list_filter = ("site",)
search_fields = ("name", "folder")
@admin.register(Page)
class PageAdmin(ReadOnlyAdmin):
list_display = ("title", "site", "kind", "course", "chapter_no", "updated", "on_site")
list_filter = ("site", "kind")
search_fields = ("title", "path")
list_select_related = ("course",)
list_per_page = 50
# the body is left out of the form: it is large, and showing it would only invite editing it
exclude = ("fragment",)
ordering = ("site", "path")
def get_queryset(self, request):
return super().get_queryset(request).defer("fragment") # a list never needs the body
@admin.display(description="View")
def on_site(self, page):
return format_html('open', settings.SITE_URL_TEMPLATE.format(site=page.site) + page.url_path)
Save as apps/progress/admin.py:
from django.contrib import admin
from apps.content.admin import ReadOnlyAdmin
from .models import PageProgress
@admin.register(PageProgress)
class PageProgressAdmin(ReadOnlyAdmin):
list_display = ("user", "path", "completed_at")
list_filter = ("user",)
search_fields = ("path",)
date_hierarchy = "completed_at"
The page list never loads the page body (get_queryset defers it, and the form excludes it), so
listing 4,408 pages stays cheap.
Run on the real database (a superuser made for the run with a random password that is never
printed, deleted afterwards):
anon progress: 302 /accounts/login/?next=/progress/
login 302 /hungary/hungarian-basic-3/
Hungarian Basic Conversation 3 12 chapters
button shows finished: True | second chapter: False
overview 13 ms: 5 chapters finished [('5', '12')]
courses on site: 20
/content/page/: 200 130 ms 36 KB 4408 page
/content/page/?site=languages&kind=course_chapter: 200 75 ms 41 KB 246 result
/content/page/?q=szeretnek: 200 617 ms 11 KB 0 result
/content/page/?site=ai&q=decorator: 200 15 ms 11 KB 0 result
detail 200 False True
post edit: 403
admin on a site host: 200 404
anon admin: 302
cleanup users left: 0
(Times are the Django test client, so they leave out the network. "detail 200 False True": no