"""Seed the database with a coherent, schema-validated demonstration dataset. Usage: python manage.py seed_demo The command is idempotent: re-running it updates the same demo rows instead of duplicating them. All structured content documents are loaded from ``seeds/content`` and validated against the JSON Schemas in ``schemas/v1`` before anything is written, so schema drift fails loudly rather than producing invalid rows. """ from __future__ import annotations import json from pathlib import Path from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.utils import timezone from jsonschema import Draft202012Validator from jsonschema.exceptions import ValidationError from referencing import Registry, Resource from referencing.jsonschema import DRAFT202012 from apps.accounts.models import Profile, Role from apps.community.models import ( Bookmark, DiscussionComment, DiscussionThread, Notification, Report, Review, ReviewComment, Vote, ) from apps.content.models import ( Course, CourseVersion, Hint, Lesson, Module, Problem, ProblemVersion, Solution, ) from apps.core.models import AuditLog from apps.learning.models import Enrollment, Progress, ProblemAttempt from apps.taxonomy.models import Tag, Topic SCHEMA_DIR = Path(settings.BASE_DIR) / "schemas" / "v1" SEED_DIR = Path(settings.BASE_DIR) / "seeds" / "content" DEMO_PASSWORD = "fablepool-demo" # noqa: S105 — local demo credential, documented in README PROBLEM_SPECS = [ { "slug": "quadratic-vertex", "file": "problems/quadratic-vertex.json", "title": "Finding the Vertex of a Parabola", "summary": "Locate the vertex of a quadratic given in standard form.", "problem_type": "multiple_choice", "difficulty": 2, "topic": "algebra", "tags": ["quadratics", "graphing"], "changelog": "Initial version.", }, { "slug": "projectile-range", "file": "problems/projectile-range.json", "title": "Range of a Projectile", "summary": "Compute the horizontal range of an ideal projectile.", "problem_type": "numeric", "difficulty": 3, "topic": "mechanics", "tags": ["kinematics", "quadratics"], "changelog": "Initial version.", "prerequisites": ["quadratic-vertex"], }, { "slug": "binary-search-bug", "file": "problems/binary-search-bug.json", "title": "Fix the Binary Search", "summary": "Debug an off-by-one error in a classic binary search.", "problem_type": "code", "difficulty": 2, "topic": "algorithms", "tags": ["binary-search", "debugging"], "changelog": "Initial version.", }, ] class Command(BaseCommand): help = "Populate the database with schema-validated demonstration content." # ------------------------------------------------------------------ schemas def _build_validators(self) -> dict[str, Draft202012Validator]: """Load every schema in schemas/v1 into a shared registry keyed by $id.""" if not SCHEMA_DIR.is_dir(): raise CommandError(f"Schema directory not found: {SCHEMA_DIR}") registry = Registry() schemas: dict[str, dict] = {} for path in sorted(SCHEMA_DIR.glob("*.json")): contents = json.loads(path.read_text(encoding="utf-8")) resource = Resource.from_contents(contents, default_specification=DRAFT202012) uri = contents.get("$id", path.name) registry = registry.with_resource(uri=uri, resource=resource) schemas[path.stem] = contents return { name: Draft202012Validator(schema, registry=registry) for name, schema in schemas.items() } def _load_document(self, relative_path: str, validator_name: str) -> dict: path = SEED_DIR / relative_path if not path.is_file(): raise CommandError(f"Seed document not found: {path}") document = json.loads(path.read_text(encoding="utf-8")) validator = self.validators.get(validator_name) if validator is None: raise CommandError( f"No schema named '{validator_name}' in {SCHEMA_DIR} " f"(found: {', '.join(sorted(self.validators))})" ) try: validator.validate(document) except ValidationError as exc: raise CommandError( f"{relative_path} fails schema '{validator_name}': " f"{exc.message} (at {'/'.join(str(p) for p in exc.absolute_path)})" ) from exc return document def _validate_inline(self, document: dict, validator_name: str, label: str) -> dict: validator = self.validators[validator_name] try: validator.validate(document) except ValidationError as exc: raise CommandError(f"{label} fails schema '{validator_name}': {exc.message}") from exc return document # ------------------------------------------------------------------ handle @transaction.atomic def handle(self, *args, **options): self.validators = self._build_validators() self.stdout.write(f"Loaded {len(self.validators)} JSON Schemas from {SCHEMA_DIR}") users = self._seed_users() topics = self._seed_taxonomy() problems = self._seed_problems(users, topics) course = self._seed_course(users, topics, problems) self._seed_learning(users, course, problems) self._seed_community(users, course, problems) self.stdout.write(self.style.SUCCESS("Demo data seeded.")) self.stdout.write( "Counts: " f"users={get_user_model().objects.count()} " f"problems={Problem.objects.count()} " f"problem_versions={ProblemVersion.objects.count()} " f"courses={Course.objects.count()} " f"lessons={Lesson.objects.count()} " f"attempts={ProblemAttempt.objects.count()} " f"reviews={Review.objects.count()}" ) self.stdout.write( f"All demo accounts use the password '{DEMO_PASSWORD}'. " "Never enable these accounts in production." ) # ------------------------------------------------------------------ users def _seed_users(self): User = get_user_model() role_rows = [ ("learner", "Learner", "Default role for every account."), ("contributor", "Contributor", "May create and submit content versions."), ("reviewer", "Reviewer", "May review submitted versions."), ("moderator", "Moderator", "Handles reports, flags, and locks."), ("admin", "Admin", "Full administrative access."), ] roles = {} for slug, name, description in role_rows: role, _ = Role.objects.update_or_create( slug=slug, defaults={"name": name, "description": description} ) roles[slug] = role user_rows = [ ("admin", "admin@fablepool.test", "Site Admin", ["admin", "moderator"], 0, True), ("amara", "amara@fablepool.test", "Amara Okafor", ["contributor", "learner"], 42, False), ("kenji", "kenji@fablepool.test", "Kenji Watanabe", ["reviewer", "contributor", "learner"], 87, False), ("priya", "priya@fablepool.test", "Priya Raman", ["learner"], 5, False), ] users = {} for username, email, display_name, role_slugs, reputation, is_admin in user_rows: user, created = User.objects.get_or_create( username=username, defaults={ "email": email, "display_name": display_name, "reputation": reputation, }, ) user.email = email user.display_name = display_name user.reputation = reputation if is_admin: user.is_staff = True user.is_superuser = True if created: user.set_password(DEMO_PASSWORD) user.save() user.roles.set([roles[s] for s in role_slugs]) users[username] = user profile_rows = { "amara": "Math teacher writing interactive algebra problems.", "kenji": "Physics PhD; reviews mechanics and algorithms content.", "priya": "CS undergrad working through the algebra track.", "admin": "Instance administrator.", } for username, bio in profile_rows.items(): Profile.objects.update_or_create(user=users[username], defaults={"bio": bio}) self.stdout.write(f"Seeded {len(users)} users and {len(roles)} roles.") return users # ------------------------------------------------------------------ taxonomy def _seed_taxonomy(self): topic_rows = [ ("mathematics", "Mathematics", None), ("algebra", "Algebra", "mathematics"), ("computer-science", "Computer Science", None), ("algorithms", "Algorithms", "computer-science"), ("physics", "Physics", None), ("mechanics", "Mechanics", "physics"), ] topics = {} for slug, name, parent_slug in topic_rows: topic, _ = Topic.objects.update_or_create( slug=slug, defaults={ "name": name, "description": f"{name} problems and courses.", "parent": topics.get(parent_slug), }, ) topics[slug] = topic tag_rows = [ ("quadratics", "Quadratics", "algebra"), ("graphing", "Graphing", "algebra"), ("kinematics", "Kinematics", "mechanics"), ("binary-search", "Binary Search", "algorithms"), ("debugging", "Debugging", "algorithms"), ] for slug, name, topic_slug in tag_rows: Tag.objects.update_or_create( slug=slug, defaults={ "name": name, "description": f"Problems involving {name.lower()}.", "topic": topics[topic_slug], }, ) self.stdout.write(f"Seeded {len(topic_rows)} topics and {len(tag_rows)} tags.") return topics # ------------------------------------------------------------------ problems def _seed_problems(self, users, topics): problems = {} for spec in PROBLEM_SPECS: document = self._load_document(spec["file"], "problem-content") problem, _ = Problem.objects.update_or_create( slug=spec["slug"], defaults={ "owner": users["amara"], "topic": topics[spec["topic"]], "license": "CC-BY-SA-4.0", }, ) problem.tags.set(Tag.objects.filter(slug__in=spec["tags"])) version, _ = ProblemVersion.objects.update_or_create( problem=problem, version_number=1, defaults={ "title": spec["title"], "summary": spec["summary"], "problem_type": spec["problem_type"], "difficulty": spec["difficulty"], "content": document, "answer_spec": document["answer"], "status": "published", "author": users["amara"], "changelog": spec["changelog"], }, ) version.hints.all().delete() for order, hint_doc in enumerate(document.get("hints", []), start=1): Hint.objects.create(version=version, order=order, content=hint_doc) version.solutions.all().delete() if "solution" in document: Solution.objects.create( version=version, author=users["amara"], content=document["solution"], is_official=True, ) problem.published_version = version problem.save(update_fields=["published_version"]) problems[spec["slug"]] = problem # Prerequisites (second pass, once every problem exists). for spec in PROBLEM_SPECS: prereq_slugs = spec.get("prerequisites", []) if prereq_slugs: version = problems[spec["slug"]].published_version version.prerequisites.set( [problems[s] for s in prereq_slugs if s in problems] ) self.stdout.write(f"Seeded {len(problems)} published problems (version 1 each).") return problems # ------------------------------------------------------------------ course def _seed_course(self, users, topics, problems): lesson_doc = self._load_document("lessons/intro-to-quadratics.json", "lesson-content") course, _ = Course.objects.update_or_create( slug="foundations-of-quadratics", defaults={ "owner": users["amara"], "topic": topics["algebra"], "license": "CC-BY-SA-4.0", }, ) course.tags.set(Tag.objects.filter(slug__in=["quadratics", "graphing"])) course_version, _ = CourseVersion.objects.update_or_create( course=course, version_number=1, defaults={ "title": "Foundations of Quadratics", "summary": "From standard form to applied projectile motion.", "status": "published", "author": users["amara"], "changelog": "Initial version.", }, ) module, _ = Module.objects.update_or_create( course_version=course_version, order=1, defaults={ "title": "Working with Quadratics", "summary": "Vertex form, graphs, and one applied problem set.", }, ) lesson, _ = Lesson.objects.update_or_create( module=module, order=1, defaults={ "title": "Introduction to Quadratics", "kind": "lesson", "content": lesson_doc, }, ) lesson.problems.set([problems["quadratic-vertex"]]) problem_set_doc = self._validate_inline( { "schema_version": 1, "body": { "type": "doc", "blocks": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Apply what you learned. Solve every problem to complete the module.", } ], } ], }, "problems": ["quadratic-vertex", "projectile-range", "binary-search-bug"], }, "lesson-content", "inline problem-set lesson", ) problem_set, _ = Lesson.objects.update_or_create( module=module, order=2, defaults={ "title": "Quadratics Problem Set", "kind": "problem_set", "content": problem_set_doc, }, ) problem_set.problems.set(list(problems.values())) course.published_version = course_version course.save(update_fields=["published_version"]) self.stdout.write("Seeded 1 published course (1 module, 2 lessons).") return course # ------------------------------------------------------------------ learning def _seed_learning(self, users, course, problems): priya = users["priya"] enrollment, _ = Enrollment.objects.update_or_create( user=priya, course=course, defaults={ "course_version": course.published_version, "status": "active", }, ) first_lesson = ( Lesson.objects.filter(module__course_version=course.published_version) .order_by("module__order", "order") .first() ) Progress.objects.update_or_create( user=priya, lesson=first_lesson, defaults={"state": "completed", "completed_at": timezone.now()}, ) quadratic_version = problems["quadratic-vertex"].published_version ProblemAttempt.objects.get_or_create( user=priya, problem_version=quadratic_version, attempt_number=1, defaults={ "submitted_answer": {"type": "multiple_choice", "selected": ["d"]}, "is_correct": False, "hints_used": 0, "time_spent_ms": 64000, }, ) ProblemAttempt.objects.get_or_create( user=priya, problem_version=quadratic_version, attempt_number=2, defaults={ "submitted_answer": {"type": "multiple_choice", "selected": ["a"]}, "is_correct": True, "hints_used": 1, "time_spent_ms": 41000, }, ) self.stdout.write("Seeded enrollment, lesson progress, and 2 problem attempts.") # ------------------------------------------------------------------ community def _seed_community(self, users, course, problems): kenji, amara, priya, admin = users["kenji"], users["amara"], users["priya"], users["admin"] quadratic = problems["quadratic-vertex"] quadratic_version = quadratic.published_version review, _ = Review.objects.update_or_create( reviewer=kenji, problem_version=quadratic_version, defaults={ "verdict": "approve", "summary": "Clear prompt, distractors test real misconceptions, official solution checks out.", }, ) ReviewComment.objects.get_or_create( review=review, author=kenji, body="Consider adding a graph widget in a future version to make the vertex visible.", defaults={"block_ref": "prompt/blocks/0", "resolved": True}, ) ct_problem = ContentType.objects.get_for_model(Problem) thread, _ = DiscussionThread.objects.get_or_create( content_type=ct_problem, object_id=quadratic.pk, title="Why is the x-coordinate -b/2a?", defaults={"author": priya}, ) DiscussionComment.objects.get_or_create( thread=thread, author=amara, body=( "Complete the square on ax^2 + bx + c and the squared term becomes " "(x + b/2a)^2 — it is minimised exactly when x = -b/2a." ), ) Vote.objects.get_or_create( user=priya, content_type=ct_problem, object_id=quadratic.pk, defaults={"value": 1}, ) Bookmark.objects.get_or_create( user=priya, content_type=ct_problem, object_id=problems["binary-search-bug"].pk, defaults={"note": "Retry this one without hints."}, ) projectile = problems["projectile-range"] report, _ = Report.objects.get_or_create( reporter=kenji, content_type=ct_problem, object_id=projectile.pk, defaults={ "reason": "plagiarism", "details": "Wording resembles a common textbook exercise; please verify originality.", "status": "dismissed", }, ) report.status = "dismissed" report.handled_by = admin report.resolution_note = ( "Standard physics setup with original wording, numbers, and solution; not plagiarism." ) report.save() Notification.objects.get_or_create( user=amara, kind="review.approved", defaults={ "payload": { "problem": quadratic.slug, "version": quadratic_version.version_number, "reviewer": kenji.username, } }, ) ct_pv = ContentType.objects.get_for_model(ProblemVersion) AuditLog.objects.get_or_create( actor=admin, action="problem.published", content_type=ct_pv, object_id=quadratic_version.pk, defaults={ "metadata": { "problem": quadratic.slug, "version": 1, "previous_status": "accepted", "new_status": "published", } }, ) ct_course = ContentType.objects.get_for_model(Course) AuditLog.objects.get_or_create( actor=admin, action="course.published", content_type=ct_course, object_id=course.pk, defaults={"metadata": {"course": course.slug, "version": 1}}, ) self.stdout.write("Seeded review, discussion, votes, bookmark, report, notification, audit log.")