"""Content domain models. Design principles (see docs/architecture from milestone #1): * **Container vs. version.** ``Problem``/``Course`` are stable identities that carry the slug, ownership, taxonomy and denormalised counters. All reviewable content lives on immutable ``ProblemVersion``/``CourseVersion`` rows. A container is *published* only when ``current_version`` points to an **accepted** (peer-reviewed) version — the platform invariant "every published problem/course must come from a reviewed version" is enforced in :meth:`Problem.publish` / :meth:`Course.publish`. * **Rollback** is simply re-publishing an earlier accepted version. * **Fork/remix with attribution** via ``forked_from`` plus version changelog. * **Structured content** is stored as JSON ("fable-doc"), validated by :mod:`apps.content.validators` and the JSON Schema suite under ``schemas/``. * **Soft delete** for containers (via ``apps.core``); versions are never soft-deleted — they are the audit trail. """ from __future__ import annotations import hashlib import json import uuid from django.conf import settings from django.core.exceptions import ValidationError from django.db import models, transaction from django.db.models import Q from django.utils import timezone from apps.core.models import SoftDeleteModel, TimeStampedModel from .validators import validate_answer_spec, validate_document # --------------------------------------------------------------------------- # Choice enumerations # --------------------------------------------------------------------------- class ContentStatus(models.TextChoices): """Lifecycle of a *container* (Problem/Course).""" DRAFT = "draft", "Draft" PUBLISHED = "published", "Published" ARCHIVED = "archived", "Archived" class VersionStatus(models.TextChoices): """Review pipeline state of a *version*.""" DRAFT = "draft", "Draft" SUBMITTED = "submitted", "Submitted" IN_REVIEW = "in_review", "In review" CHANGES_REQUESTED = "changes_requested", "Changes requested" ACCEPTED = "accepted", "Accepted" REJECTED = "rejected", "Rejected" #: Allowed review-state transitions. Accepted/rejected versions are terminal; #: further edits require a brand-new version (immutability is also enforced in #: ``save()`` via the content checksum). VERSION_TRANSITIONS: dict[str, frozenset] = { VersionStatus.DRAFT: frozenset({VersionStatus.SUBMITTED}), VersionStatus.SUBMITTED: frozenset({VersionStatus.IN_REVIEW, VersionStatus.DRAFT}), VersionStatus.IN_REVIEW: frozenset( { VersionStatus.ACCEPTED, VersionStatus.REJECTED, VersionStatus.CHANGES_REQUESTED, } ), VersionStatus.CHANGES_REQUESTED: frozenset( {VersionStatus.SUBMITTED, VersionStatus.DRAFT} ), VersionStatus.ACCEPTED: frozenset(), VersionStatus.REJECTED: frozenset(), } #: Once a version reaches one of these states its content may not change. IMMUTABLE_VERSION_STATUSES = frozenset({VersionStatus.ACCEPTED, VersionStatus.REJECTED}) class ProblemType(models.TextChoices): MULTIPLE_CHOICE = "multiple_choice", "Multiple choice" NUMERIC = "numeric", "Numeric answer" SYMBOLIC = "symbolic", "Symbolic expression" PROOF = "proof", "Proof / free response" CODE = "code", "Code challenge" ORDERING = "ordering", "Ordering" MATCHING = "matching", "Matching" WIDGET = "widget", "Interactive widget" class Difficulty(models.IntegerChoices): INTRO = 1, "Introductory" EASY = 2, "Easy" MEDIUM = 3, "Medium" HARD = 4, "Hard" EXPERT = 5, "Expert" class ContentLicense(models.TextChoices): CC_BY_SA_4 = "CC-BY-SA-4.0", "Creative Commons Attribution-ShareAlike 4.0" CC_BY_4 = "CC-BY-4.0", "Creative Commons Attribution 4.0" CC0_1 = "CC0-1.0", "CC0 1.0 Public Domain Dedication" class LessonType(models.TextChoices): LESSON = "lesson", "Lesson" PROBLEM_SET = "problem_set", "Problem set" QUIZ = "quiz", "Quiz" PROJECT = "project", "Project" class GradingStatus(models.TextChoices): PENDING = "pending", "Pending" GRADED = "graded", "Graded" MANUAL_REVIEW = "manual_review", "Awaiting manual review" ERROR = "error", "Grading error" # --------------------------------------------------------------------------- # Managers / shared behaviour # --------------------------------------------------------------------------- class PublishedManager(models.Manager): """Only published, non-deleted containers — the public catalogue.""" def get_queryset(self): return ( super() .get_queryset() .filter(status=ContentStatus.PUBLISHED, deleted_at__isnull=True) ) class VersionWorkflow: """Review-state machine shared by ProblemVersion and CourseVersion.""" def transition(self, to_status: str) -> None: """Move this version through the review pipeline. Raises ``ValidationError`` for illegal transitions. Timestamps for submission and review decisions are maintained automatically. Permission checks and audit logging happen at the API layer. """ allowed = VERSION_TRANSITIONS.get(self.status, frozenset()) if to_status not in allowed: raise ValidationError( f"Illegal transition {self.status!r} → {to_status!r}; " f"allowed: {sorted(allowed)}", code="illegal_transition", ) self.status = to_status now = timezone.now() if to_status == VersionStatus.SUBMITTED: self.submitted_at = now if to_status in { VersionStatus.ACCEPTED, VersionStatus.REJECTED, VersionStatus.CHANGES_REQUESTED, }: self.reviewed_at = now self.save( update_fields=["status", "submitted_at", "reviewed_at", "updated_at"] ) def _canonical_checksum(payload: dict) -> str: encoded = json.dumps( payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False ).encode("utf-8") return hashlib.sha256(encoded).hexdigest() # --------------------------------------------------------------------------- # Problems # --------------------------------------------------------------------------- class Problem(TimeStampedModel, SoftDeleteModel): """Stable identity of a community problem. The reviewable payload (statement, answer spec, hints, solutions) lives on :class:`ProblemVersion`; ``current_version`` is the published one. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) slug = models.SlugField(max_length=160, unique=True) title = models.CharField(max_length=255) # denormalised from current version owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="owned_problems", ) problem_type = models.CharField(max_length=32, choices=ProblemType.choices) difficulty = models.PositiveSmallIntegerField( choices=Difficulty.choices, default=Difficulty.MEDIUM ) status = models.CharField( max_length=16, choices=ContentStatus.choices, default=ContentStatus.DRAFT ) license = models.CharField( max_length=32, choices=ContentLicense.choices, default=ContentLicense.CC_BY_SA_4, ) language = models.CharField(max_length=12, default="en") attempt_count = models.PositiveIntegerField(default=0) solve_count = models.PositiveIntegerField(default=0) vote_score = models.IntegerField(default=0) forked_from = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="forks", ) current_version = models.ForeignKey( "content.ProblemVersion", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) topics = models.ManyToManyField("taxonomy.Topic", blank=True, related_name="problems") tags = models.ManyToManyField("taxonomy.Tag", blank=True, related_name="problems") prerequisites = models.ManyToManyField( "self", symmetrical=False, blank=True, related_name="unlocked_by" ) published = PublishedManager() class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["status", "difficulty"], name="problem_status_diff_idx"), models.Index(fields=["problem_type"], name="problem_type_idx"), models.Index(fields=["language"], name="problem_language_idx"), models.Index(fields=["owner", "status"], name="problem_owner_status_idx"), ] constraints = [ models.CheckConstraint( condition=Q(difficulty__gte=1) & Q(difficulty__lte=5), name="problem_difficulty_range", ), ] def __str__(self) -> str: return f"{self.title} [{self.slug}]" # -- versioning ------------------------------------------------------- def next_version_number(self) -> int: latest = self.versions.aggregate(m=models.Max("version_number"))["m"] return (latest or 0) + 1 @transaction.atomic def start_new_version(self, *, author, changelog: str = "") -> "ProblemVersion": """Create the next draft version, seeded from the latest version.""" base = ( self.current_version or self.versions.order_by("-version_number").first() ) return ProblemVersion.objects.create( problem=self, version_number=self.next_version_number(), parent_version=base, author=author, status=VersionStatus.DRAFT, title=base.title if base else self.title, statement=base.statement if base else {"type": "doc", "version": 1, "blocks": []}, answer_spec=base.answer_spec if base else {}, widget_ref=base.widget_ref if base else "", changelog=changelog, ) @transaction.atomic def publish(self, version: "ProblemVersion") -> None: """Make ``version`` the live version. Enforces the core invariant: only **accepted** (peer-reviewed) versions may be published. Re-publishing an older accepted version implements rollback. """ if version.problem_id != self.id: raise ValidationError("Version does not belong to this problem.") if version.status != VersionStatus.ACCEPTED: raise ValidationError( "Only peer-reviewed (accepted) versions can be published.", code="unreviewed_publish", ) if version.published_at is None: version.published_at = timezone.now() version.save(update_fields=["published_at", "updated_at"]) self.current_version = version self.title = version.title self.status = ContentStatus.PUBLISHED self.save(update_fields=["current_version", "title", "status", "updated_at"]) @transaction.atomic def fork(self, *, owner, slug: str, title: str | None = None) -> "Problem": """Fork this problem for remixing; attribution via ``forked_from``.""" source_version = ( self.current_version or self.versions.order_by("-version_number").first() ) clone = Problem.objects.create( slug=slug, title=title or f"{self.title} (fork)", owner=owner, problem_type=self.problem_type, difficulty=self.difficulty, license=self.license, language=self.language, forked_from=self, ) clone.topics.set(self.topics.all()) clone.tags.set(self.tags.all()) clone.prerequisites.set(self.prerequisites.all()) if source_version is not None: ProblemVersion.objects.create( problem=clone, version_number=1, author=owner, status=VersionStatus.DRAFT, title=title or source_version.title, statement=source_version.statement, answer_spec=source_version.answer_spec, widget_ref=source_version.widget_ref, changelog=( f"Forked from '{self.slug}' " f"v{source_version.version_number} ({self.license})." ), ) return clone # -- denormalised counters --------------------------------------------- def bump_attempt_counters(self, *, solved: bool) -> None: Problem.objects.filter(pk=self.pk).update( attempt_count=models.F("attempt_count") + 1, solve_count=models.F("solve_count") + (1 if solved else 0), ) class ProblemVersion(VersionWorkflow, TimeStampedModel): """One immutable, reviewable revision of a problem.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) problem = models.ForeignKey( Problem, on_delete=models.CASCADE, related_name="versions" ) version_number = models.PositiveIntegerField() parent_version = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="children", ) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="authored_problem_versions", ) status = models.CharField( max_length=32, choices=VersionStatus.choices, default=VersionStatus.DRAFT ) title = models.CharField(max_length=255) statement = models.JSONField(validators=[validate_document]) answer_spec = models.JSONField() widget_ref = models.CharField(max_length=128, blank=True, default="") changelog = models.TextField(blank=True, default="") checksum = models.CharField(max_length=64, blank=True, editable=False) submitted_at = models.DateTimeField(null=True, blank=True) reviewed_at = models.DateTimeField(null=True, blank=True) published_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-version_number"] indexes = [ models.Index(fields=["problem", "status"], name="pversion_problem_status_idx"), models.Index(fields=["status"], name="pversion_status_idx"), ] constraints = [ models.UniqueConstraint( fields=["problem", "version_number"], name="problemversion_unique_number", ), models.CheckConstraint( condition=Q(version_number__gte=1), name="problemversion_number_gte_1", ), ] def __str__(self) -> str: return f"{self.problem.slug} v{self.version_number} ({self.status})" @property def is_current(self) -> bool: return self.problem.current_version_id == self.id def compute_checksum(self) -> str: return _canonical_checksum( { "title": self.title, "statement": self.statement, "answer_spec": self.answer_spec, "widget_ref": self.widget_ref, } ) def clean(self) -> None: super().clean() validate_document(self.statement) if self.problem_id: validate_answer_spec(self.problem.problem_type, self.answer_spec) def save(self, *args, **kwargs): new_checksum = self.compute_checksum() if not self._state.adding: original = ( type(self).objects.only("status", "checksum").get(pk=self.pk) ) if ( original.status in IMMUTABLE_VERSION_STATUSES and new_checksum != original.checksum ): raise ValidationError( "Reviewed versions are immutable; create a new version instead.", code="immutable_version", ) self.checksum = new_checksum update_fields = kwargs.get("update_fields") if update_fields is not None: kwargs["update_fields"] = list(set(update_fields) | {"checksum"}) super().save(*args, **kwargs) class Hint(TimeStampedModel): """Ordered, progressively revealed hint attached to a problem version.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) problem_version = models.ForeignKey( ProblemVersion, on_delete=models.CASCADE, related_name="hints" ) order = models.PositiveSmallIntegerField() body = models.JSONField(validators=[validate_document]) penalty_percent = models.PositiveSmallIntegerField(default=0) class Meta: ordering = ["order"] constraints = [ models.UniqueConstraint( fields=["problem_version", "order"], name="hint_unique_order" ), models.CheckConstraint( condition=Q(penalty_percent__lte=100), name="hint_penalty_lte_100" ), ] def __str__(self) -> str: return f"Hint {self.order} for {self.problem_version_id}" class Solution(TimeStampedModel): """A worked solution for a problem version. ``is_official`` solutions ship with the reviewed version; community solutions (``is_official=False``) can be contributed post-publication. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) problem_version = models.ForeignKey( ProblemVersion, on_delete=models.CASCADE, related_name="solutions" ) author = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="problem_solutions", ) order = models.PositiveSmallIntegerField(default=1) is_official = models.BooleanField(default=True) body = models.JSONField(validators=[validate_document]) class Meta: ordering = ["order"] constraints = [ models.UniqueConstraint( fields=["problem_version", "order"], name="solution_unique_order" ), ] def __str__(self) -> str: kind = "official" if self.is_official else "community" return f"{kind.title()} solution {self.order} for {self.problem_version_id}" class ProblemAttempt(TimeStampedModel): """A learner's submission against a specific problem version. Synchronous types (multiple choice, numeric, …) are graded inline; code and proof submissions go through asynchronous/manual grading, tracked by ``grading_status``. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="problem_attempts", ) problem = models.ForeignKey( Problem, on_delete=models.CASCADE, related_name="attempts" ) problem_version = models.ForeignKey( ProblemVersion, on_delete=models.PROTECT, related_name="attempts" ) attempt_number = models.PositiveIntegerField() submitted_answer = models.JSONField() is_correct = models.BooleanField(null=True, blank=True) grading_status = models.CharField( max_length=16, choices=GradingStatus.choices, default=GradingStatus.PENDING ) score = models.DecimalField( max_digits=6, decimal_places=4, null=True, blank=True ) hints_used = models.PositiveSmallIntegerField(default=0) time_spent_ms = models.PositiveIntegerField(null=True, blank=True) feedback = models.JSONField(null=True, blank=True) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["user", "problem"], name="attempt_user_problem_idx"), models.Index( fields=["problem", "is_correct"], name="attempt_problem_correct_idx" ), models.Index(fields=["grading_status"], name="attempt_grading_idx"), ] constraints = [ models.UniqueConstraint( fields=["user", "problem", "attempt_number"], name="attempt_unique_number", ), models.CheckConstraint( condition=Q(score__isnull=True) | (Q(score__gte=0) & Q(score__lte=1)), name="attempt_score_range", ), ] def __str__(self) -> str: return f"Attempt {self.attempt_number} by {self.user_id} on {self.problem_id}" # --------------------------------------------------------------------------- # Courses # --------------------------------------------------------------------------- class Course(TimeStampedModel, SoftDeleteModel): """Stable identity of a course; structure lives on :class:`CourseVersion`.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) slug = models.SlugField(max_length=160, unique=True) title = models.CharField(max_length=255) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="owned_courses", ) difficulty = models.PositiveSmallIntegerField( choices=Difficulty.choices, default=Difficulty.MEDIUM ) status = models.CharField( max_length=16, choices=ContentStatus.choices, default=ContentStatus.DRAFT ) license = models.CharField( max_length=32, choices=ContentLicense.choices, default=ContentLicense.CC_BY_SA_4, ) language = models.CharField(max_length=12, default="en") enrollment_count = models.PositiveIntegerField(default=0) vote_score = models.IntegerField(default=0) forked_from = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="forks", ) current_version = models.ForeignKey( "content.CourseVersion", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) topics = models.ManyToManyField("taxonomy.Topic", blank=True, related_name="courses") tags = models.ManyToManyField("taxonomy.Tag", blank=True, related_name="courses") published = PublishedManager() class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["status", "difficulty"], name="course_status_diff_idx"), models.Index(fields=["owner", "status"], name="course_owner_status_idx"), models.Index(fields=["language"], name="course_language_idx"), ] constraints = [ models.CheckConstraint( condition=Q(difficulty__gte=1) & Q(difficulty__lte=5), name="course_difficulty_range", ), ] def __str__(self) -> str: return f"{self.title} [{self.slug}]" def next_version_number(self) -> int: latest = self.versions.aggregate(m=models.Max("version_number"))["m"] return (latest or 0) + 1 @transaction.atomic def start_new_version(self, *, author, changelog: str = "") -> "CourseVersion": """Create the next draft version with a deep copy of the structure.""" base = ( self.current_version or self.versions.order_by("-version_number").first() ) version = CourseVersion.objects.create( course=self, version_number=self.next_version_number(), parent_version=base, author=author, status=VersionStatus.DRAFT, title=base.title if base else self.title, summary=base.summary if base else "", overview=base.overview if base else {}, estimated_hours=base.estimated_hours if base else None, changelog=changelog, ) if base is not None: base.copy_structure_to(version) return version @transaction.atomic def publish(self, version: "CourseVersion") -> None: """Publish an accepted version (also implements rollback).""" if version.course_id != self.id: raise ValidationError("Version does not belong to this course.") if version.status != VersionStatus.ACCEPTED: raise ValidationError( "Only peer-reviewed (accepted) versions can be published.", code="unreviewed_publish", ) if version.published_at is None: version.published_at = timezone.now() version.save(update_fields=["published_at", "updated_at"]) self.current_version = version self.title = version.title self.status = ContentStatus.PUBLISHED self.save(update_fields=["current_version", "title", "status", "updated_at"]) @transaction.atomic def fork(self, *, owner, slug: str, title: str | None = None) -> "Course": source_version = ( self.current_version or self.versions.order_by("-version_number").first() ) clone = Course.objects.create( slug=slug, title=title or f"{self.title} (fork)", owner=owner, difficulty=self.difficulty, license=self.license, language=self.language, forked_from=self, ) clone.topics.set(self.topics.all()) clone.tags.set(self.tags.all()) if source_version is not None: new_version = CourseVersion.objects.create( course=clone, version_number=1, author=owner, status=VersionStatus.DRAFT, title=title or source_version.title, summary=source_version.summary, overview=source_version.overview, estimated_hours=source_version.estimated_hours, changelog=( f"Forked from '{self.slug}' " f"v{source_version.version_number} ({self.license})." ), ) source_version.copy_structure_to(new_version) return clone class CourseVersion(VersionWorkflow, TimeStampedModel): """One immutable, reviewable revision of a course's structure.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) course = models.ForeignKey( Course, on_delete=models.CASCADE, related_name="versions" ) version_number = models.PositiveIntegerField() parent_version = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="children", ) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="authored_course_versions", ) status = models.CharField( max_length=32, choices=VersionStatus.choices, default=VersionStatus.DRAFT ) title = models.CharField(max_length=255) summary = models.CharField(max_length=500, blank=True, default="") overview = models.JSONField(blank=True, default=dict) estimated_hours = models.PositiveSmallIntegerField(null=True, blank=True) changelog = models.TextField(blank=True, default="") checksum = models.CharField(max_length=64, blank=True, editable=False) submitted_at = models.DateTimeField(null=True, blank=True) reviewed_at = models.DateTimeField(null=True, blank=True) published_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-version_number"] indexes = [ models.Index(fields=["course", "status"], name="cversion_course_status_idx"), ] constraints = [ models.UniqueConstraint( fields=["course", "version_number"], name="courseversion_unique_number", ), models.CheckConstraint( condition=Q(version_number__gte=1), name="courseversion_number_gte_1", ), ] def __str__(self) -> str: return f"{self.course.slug} v{self.version_number} ({self.status})" @property def is_current(self) -> bool: return self.course.current_version_id == self.id def compute_checksum(self) -> str: return _canonical_checksum( { "title": self.title, "summary": self.summary, "overview": self.overview, "estimated_hours": self.estimated_hours, } ) def clean(self) -> None: super().clean() if self.overview: validate_document(self.overview) def save(self, *args, **kwargs): new_checksum = self.compute_checksum() if not self._state.adding: original = ( type(self).objects.only("status", "checksum").get(pk=self.pk) ) if ( original.status in IMMUTABLE_VERSION_STATUSES and new_checksum != original.checksum ): raise ValidationError( "Reviewed versions are immutable; create a new version instead.", code="immutable_version", ) self.checksum = new_checksum update_fields = kwargs.get("update_fields") if update_fields is not None: kwargs["update_fields"] = list(set(update_fields) | {"checksum"}) super().save(*args, **kwargs) @transaction.atomic def copy_structure_to(self, target: "CourseVersion") -> None: """Deep-copy modules, lessons and lesson-problem links to ``target``.""" for module in self.modules.prefetch_related("lessons__lesson_problems"): new_module = Module.objects.create( course_version=target, order=module.order, title=module.title, summary=module.summary, ) for lesson in module.lessons.all(): new_lesson = Lesson.objects.create( module=new_module, order=lesson.order, title=lesson.title, lesson_type=lesson.lesson_type, body=lesson.body, duration_minutes=lesson.duration_minutes, ) LessonProblem.objects.bulk_create( [ LessonProblem( lesson=new_lesson, problem_id=link.problem_id, order=link.order, is_required=link.is_required, points=link.points, ) for link in lesson.lesson_problems.all() ] ) class Module(TimeStampedModel): """Ordered chapter inside a course version.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) course_version = models.ForeignKey( CourseVersion, on_delete=models.CASCADE, related_name="modules" ) order = models.PositiveSmallIntegerField() title = models.CharField(max_length=255) summary = models.CharField(max_length=500, blank=True, default="") class Meta: ordering = ["order"] constraints = [ models.UniqueConstraint( fields=["course_version", "order"], name="module_unique_order" ), ] def __str__(self) -> str: return f"Module {self.order}: {self.title}" class Lesson(TimeStampedModel): """Ordered unit inside a module: lesson, problem set, quiz or project.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name="lessons") order = models.PositiveSmallIntegerField() title = models.CharField(max_length=255) lesson_type = models.CharField( max_length=16, choices=LessonType.choices, default=LessonType.LESSON ) body = models.JSONField(blank=True, default=dict) duration_minutes = models.PositiveSmallIntegerField(null=True, blank=True) problems = models.ManyToManyField( Problem, through="content.LessonProblem", related_name="lessons", blank=True ) class Meta: ordering = ["order"] constraints = [ models.UniqueConstraint( fields=["module", "order"], name="lesson_unique_order" ), ] def __str__(self) -> str: return f"{self.get_lesson_type_display()} {self.order}: {self.title}" def clean(self) -> None: super().clean() if self.body: validate_document(self.body) class LessonProblem(models.Model): """Ordered link between a lesson and a problem (with scoring weight).""" lesson = models.ForeignKey( Lesson, on_delete=models.CASCADE, related_name="lesson_problems" ) problem = models.ForeignKey( Problem, on_delete=models.PROTECT, related_name="lesson_links" ) order = models.PositiveSmallIntegerField() is_required = models.BooleanField(default=True) points = models.PositiveSmallIntegerField(default=1) class Meta: ordering = ["order"] constraints = [ models.UniqueConstraint( fields=["lesson", "order"], name="lessonproblem_unique_order" ), models.UniqueConstraint( fields=["lesson", "problem"], name="lessonproblem_unique_problem" ), ] def __str__(self) -> str: return f"{self.lesson_id} → {self.problem_id} (#{self.order})"