learning-website-django1-3 Exercise 2: The Course and Page Models ================================================================= The models were written by make_content_app.py (Exercise 1). Read them, create the migration, and test them with a real (in-memory) database. Save as tests/test_content_models.py: from django.core.exceptions import ValidationError from django.db import IntegrityError, transaction from django.test import TestCase from apps.content.models import Course, Page def make_page(**kw): values = dict(path="hungary/hungarian-basic-3/hungarian_basic_conversation_3_1.html", site="languages", kind=Page.Kind.COURSE_CHAPTER, title="Buying Clothes") values.update(kw) return Page(**values) class PageModelTests(TestCase): def test_url_path_is_the_address_form_of_the_path(self): page = make_page() self.assertEqual(page.url_path, "/hungary/hungarian-basic-3/hungarian_basic_conversation_3_1/") def test_a_path_can_only_be_stored_once(self): make_page().save() with self.assertRaises(IntegrityError), transaction.atomic(): make_page(title="Again").save() def test_chapters_sort_as_numbers(self): course = Course.objects.create(site="languages", folder="hungary/hungarian-basic-3", name="Hungarian Basic Conversation 3") for n in (10, 2, 1, 11): make_page(path=f"hungary/hungarian-basic-3/hungarian_basic_conversation_3_{n}.html", course=course, chapter_no=n, title=f"Chapter {n}").save() self.assertEqual([p.chapter_no for p in Page.objects.all()], [1, 2, 10, 11]) def test_deleting_a_course_keeps_its_pages(self): course = Course.objects.create(site="languages", folder="hungary/x", name="X") make_page(course=course).save() course.delete() self.assertIsNone(Page.objects.get().course) def test_the_site_must_match_the_path(self): with self.assertRaises(ValidationError) as ctx: make_page(site="systems").full_clean() self.assertIn("site", ctx.exception.message_dict) def test_a_path_in_no_site_is_rejected(self): with self.assertRaises(ValidationError) as ctx: make_page(path="nonsense/x.html").full_clean() self.assertIn("path", ctx.exception.message_dict) def test_unsafe_paths_are_rejected(self): for bad in ("../secret.html", "/hungary/x.html", "hungary\\x.html", "hungary/x.txt"): with self.assertRaises(ValidationError, msg=bad): make_page(path=bad).full_clean() def test_a_good_page_validates(self): make_page().full_clean() Create the tables: python manage.py makemigrations content python manage.py migrate Checking that no further migration is needed afterwards: python manage.py makemigrations --check --dry-run No changes detected The tests (all of them, with the parsing tests from Exercise 1): Found 53 test(s). System check identified no issues (0 silenced). Creating test database for alias 'default'... ..................................................... ---------------------------------------------------------------------- Ran 53 tests in 0.061s OK Destroying test database for alias 'default'... The SQL Django generates (python manage.py sqlmigrate content 0001): BEGIN; -- -- Create model Course -- CREATE TABLE "content_course" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "site" varchar(40) NOT NULL, "folder" varchar(400) NOT NULL UNIQUE, "name" varchar(200) NOT NULL, "course_no" smallint unsigned NULL CHECK ("course_no" >= 0)); -- -- Create model Page -- CREATE TABLE "content_page" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "path" varchar(500) NOT NULL UNIQUE, "site" varchar(40) NOT NULL, "kind" varchar(20) NOT NULL, "title" varchar(300) NOT NULL, "chapter_no" smallint unsigned NULL CHECK ("chapter_no" >= 0), "created" date NULL, "updated" date NULL, "course_id" bigint NULL REFERENCES "content_course" ("id") DEFERRABLE INITIALLY DEFERRED); CREATE INDEX "content_page_site_244782af" ON "content_page" ("site"); CREATE INDEX "content_page_course_id_0d1c8bfd" ON "content_page" ("course_id"); CREATE INDEX "content_pag_site_c4b642_idx" ON "content_page" ("site", "kind"); COMMIT; The models ---------- from django.core.exceptions import ValidationError from django.db import models from config.sites_config import NoSiteError, SITES, site_for_path SITE_CHOICES = [(name, info["title"]) for name, info in SITES.items()] def validate_content_path(value): """A content path is relative, uses forward slashes, ends in .html and never climbs out of the tree.""" if "\\" in value or value.startswith("/") or ".." in value.split("/"): raise ValidationError("A content path must be relative, use forward slashes and contain no '..'.") if not value.endswith(".html"): raise ValidationError("A content path must end in .html.") class Course(models.Model): """A folder of numbered chapters, such as hungary/hungarian-basic-3.""" site = models.CharField(max_length=40, choices=SITE_CHOICES) folder = models.CharField(max_length=400, unique=True) name = models.CharField(max_length=200) course_no = models.PositiveSmallIntegerField(null=True, blank=True) class Meta: ordering = ["site", "folder"] def __str__(self): return self.name class Page(models.Model): class Kind(models.TextChoices): COURSE_CHAPTER = "course_chapter", "Course chapter" SIDEBAR = "sidebar", "Sidebar page" LESSON = "lesson", "Standalone lesson or reference" FULL_PAGE = "full_page", "Complete HTML page" OTHER = "other", "Other" path = models.CharField(max_length=500, unique=True, validators=[validate_content_path]) site = models.CharField(max_length=40, choices=SITE_CHOICES, db_index=True) kind = models.CharField(max_length=20, choices=Kind.choices) title = models.CharField(max_length=300) course = models.ForeignKey(Course, null=True, blank=True, on_delete=models.SET_NULL, related_name="pages") chapter_no = models.PositiveSmallIntegerField(null=True, blank=True) created = models.DateField(null=True, blank=True) updated = models.DateField(null=True, blank=True) class Meta: # chapter_no is a NUMBER, so chapter 2 sorts before chapter 10 (a text sort would not) ordering = ["course_id", "chapter_no", "path"] indexes = [models.Index(fields=["site", "kind"])] def __str__(self): return self.title @property def url_path(self): """/hungary/hungarian-basic-3/hungarian_basic_conversation_3_1/ (the live site's address form)""" return "/" + self.path[: -len(".html")] + "/" def clean(self): super().clean() try: expected = site_for_path(self.path) except NoSiteError: raise ValidationError({"path": "This path belongs to no site."}) if self.site != expected: raise ValidationError({"site": f"This path belongs to the {expected} site, not {self.site}."}) Why they look like this ----------------------- - path is the identity of a page: unique, relative to the content folder, with forward slashes. validate_content_path refuses an absolute path, a backslash or '..', so a path can never point outside the content folder. The same string becomes the page's address (url_path). - site is stored, and clean() checks it equals the site the path belongs to, so the table cannot contradict the site map. - A Course is just a folder of numbered chapters. Deleting a course keeps its pages (on_delete=SET_NULL), because a page is content in its own right. - chapter_no is a number, and the default ordering uses it. The test inserts chapters 10, 2, 1 and 11 and reads them back as 1, 2, 10, 11. A text sort of the file names would give 1, 10, 11, 2. - Dates are optional, because 58% of pages have none (Framework course, Chapter 7). - There is no field for the page's HTML. The files stay the source, and Chapter 4 decides how to store or read them. WHY THIS WORKS AS AN ANSWER --------------------------- The database holds what the site must QUERY: which pages exist, their site, course, order and dates. The tests prove the properties that matter (one row per path, numeric order, no unsafe paths, site and path agree) before a single real page is imported.