learning-website-django1-2 Exercise 1: Pick the Site From the Host Name ========================================================================= Add subdomain routing to the Chapter 1 project: a middleware that decides the site from the Host header, one URL configuration per site built from the site map, a placeholder view, and tests. Re-run the Chapter 1 script first if you have not kept the project. The script changes five things and is safe to run twice: 1. sites_config.py gains host_map(), a {host: site} lookup 2. apps/core/middleware.py: SiteMiddleware 3. apps/core/views.py: placeholder views (Chapters 3 and 4 replace them) 4. config/urlconfs/__init__.py: one URL configuration per site 5. settings: the middleware is added (BEFORE CommonMiddleware) and HOST_TO_SITE is set Save as make_routing.py and run it with the project folder: python make_routing.py . """make_routing.py Adds Chapter 2 (subdomain routing) to the project created in Chapter 1: a site middleware, one URL configuration per site, a placeholder view and the settings changes.""" import os, sys root = sys.argv[1] def path(*parts): return os.path.join(root, *parts) def write(rel, text): p = path(*rel.split("/")) os.makedirs(os.path.dirname(p), exist_ok=True) with open(p, "w", encoding="utf-8", newline="\n") as fh: fh.write(text) def patch(rel, old, new): p = path(*rel.split("/")) text = open(p, encoding="utf-8").read() if new in text: return # already patched: safe to run twice assert old in text, f"{rel}: expected text not found" open(p, "w", encoding="utf-8", newline="\n").write(text.replace(old, new, 1)) # 1. the site map gains a host -> site lookup ------------------------------------------------- patch("config/sites_config.py", ''' return [f"{name}.{suffix}" for name in names] ''', ''' return [f"{name}.{suffix}" for name in names] def host_map(env, only=None): """{host name: site name}, for example {"languages.localhost": "languages"}.""" names = [only] if only else list(SITES) return dict(zip(site_hosts(env, only), names)) ''') # 2. the middleware ------------------------------------------------------------------------ write("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 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) ''') # 3. the placeholder views ---------------------------------------------------------------------- write("apps/core/views.py", '''from django.http import HttpResponse def home(request, site): return HttpResponse(f"{request.site_info['title']} (site: {site})", content_type="text/plain") def page(request, site, page_path): # Chapter 3 and 4 replace this with a real page from the content folder. return HttpResponse(f"site={site} path={page_path}", content_type="text/plain") ''') # 4. one URL configuration per site, created from the site map -------------------------------------- write("config/urlconfs/__init__.py", '''"""One URL configuration per site, created from the site map. Django accepts any importable module path in request.urlconf, so each module is built here and registered in sys.modules under config.urlconfs.. A site's configuration only contains routes for ITS folders, so /linux/ is a 404 on the languages site.""" import re import sys import types from django.urls import re_path from apps.core import views from config.sites_config import SITES def build(site): folders = "|".join(re.escape(f) for f in SITES[site]["folders"]) module = types.ModuleType(f"config.urlconfs.{site}") module.urlpatterns = [ re_path(r"^$", views.home, {"site": site}, name="home"), re_path(rf"^(?P(?:{folders})(?:/.*)?)$", views.page, {"site": site}, name="page"), ] sys.modules[module.__name__] = module return module for _site in SITES: build(_site) ''') # 5. settings ------------------------------------------------------------------------------------- patch("config/settings/base.py", ''' "django.middleware.security.SecurityMiddleware", ''', ''' "django.middleware.security.SecurityMiddleware", "apps.core.middleware.SiteMiddleware", # must come before CommonMiddleware (it reads request.urlconf) ''') patch("config/settings/dev.py", 'from config.sites_config import site_hosts\n', 'from config.sites_config import host_map, site_hosts\n') patch("config/settings/dev.py", 'ALLOWED_HOSTS = site_hosts("dev", PINNED_SITE)', 'HOST_TO_SITE = host_map("dev", PINNED_SITE)\nALLOWED_HOSTS = site_hosts("dev", PINNED_SITE)') patch("config/settings/prod.py", 'from config.sites_config import site_hosts\n', 'from config.sites_config import host_map, site_hosts\n') patch("config/settings/prod.py", 'ALLOWED_HOSTS = site_hosts("prod", PINNED_SITE)', 'HOST_TO_SITE = host_map("prod", PINNED_SITE)\nALLOWED_HOSTS = site_hosts("prod", PINNED_SITE)') print("routing added to", root) Save as tests/test_routing.py: from django.test import Client, SimpleTestCase, override_settings from config.sites_config import SITES def get(host, path="/"): return Client().get(path, HTTP_HOST=host) class SiteSelectionTests(SimpleTestCase): def test_every_site_answers_on_its_own_host(self): for name, info in SITES.items(): response = get(f"{name}.localhost") self.assertEqual(response.status_code, 200, name) self.assertIn(f"(site: {name})", response.content.decode()) def test_the_port_in_the_host_header_is_ignored(self): response = get("languages.localhost:8000") self.assertEqual(response.status_code, 200) self.assertIn("site: languages", response.content.decode()) def test_host_names_are_case_insensitive(self): self.assertEqual(get("LANGUAGES.LOCALHOST").status_code, 200) def test_an_unknown_host_is_refused_before_any_site_code_runs(self): # not in ALLOWED_HOSTS, so Django answers 400 (DisallowedHost) self.assertEqual(get("evil.example.org").status_code, 400) def test_an_allowed_but_unmapped_host_gets_the_unknown_site_page(self): with override_settings(ALLOWED_HOSTS=["spare.localhost"]): response = get("spare.localhost") self.assertEqual(response.status_code, 404) self.assertEqual(response.content, b"Unknown site") class RoutesBelongToTheSiteTests(SimpleTestCase): def test_a_site_serves_its_own_folders(self): response = get("languages.localhost", "/hungary/hungarian-basic-3/chapter-1/") self.assertEqual(response.status_code, 200) self.assertEqual(response.content.decode(), "site=languages path=hungary/hungarian-basic-3/chapter-1/") def test_a_site_does_not_serve_another_sites_folders(self): self.assertEqual(get("languages.localhost", "/linux/shell-and-scripting/vim/").status_code, 404) self.assertEqual(get("systems.localhost", "/linux/shell-and-scripting/vim/").status_code, 200) def test_a_folder_name_must_match_exactly(self): # "hungary-extra" starts with "hungary" but is not that folder self.assertEqual(get("languages.localhost", "/hungary-extra/").status_code, 404) def test_every_folder_in_the_map_routes_to_its_own_site_only(self): for site, info in SITES.items(): for folder in info["folders"]: self.assertEqual(get(f"{site}.localhost", f"/{folder}/x/").status_code, 200, (site, folder)) other = next(s for s in SITES if s != site) self.assertEqual(get(f"{other}.localhost", f"/{folder}/x/").status_code, 404, (other, folder)) Run all the tests (the ten from Chapter 1 plus the new ones), Django 6.1.2: python manage.py test tests Found 24 test(s). System check identified no issues (0 silenced). ........................ ---------------------------------------------------------------------- Ran 24 tests in 0.043s OK How the routing works --------------------- - Django lets a middleware set request.urlconf, and uses that module's urlpatterns instead of ROOT_URLCONF for the request. Each site's module is created from the site map and registered in sys.modules, so Django can import it by name. The languages site has routes for france, germany, hungary, japan and culture, and nothing else. That is why /linux/... is a 404 there. - request.get_host() both returns the host name and enforces ALLOWED_HOSTS, so an unknown name gets Django's 400 before any site logic runs. The port is stripped and the name lower-cased before the lookup. - Order matters: the middleware must come before CommonMiddleware, which reads request.urlconf (for example when it adds a missing slash). - The test for "hungary-extra" shows why the folder pattern ends with (?:/.*)?$ rather than just matching the start of the path. WHY THIS WORKS AS AN ANSWER --------------------------- The site is chosen in one place, from data, and every other part of the project can rely on request.site. Giving each site its own URL configuration makes the routing itself enforce the site boundaries, and the last test checks every folder of every site against every other site.