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