"""Community models. Covers the peer-review workflow (Review, ReviewComment), per-content discussion (DiscussionThread, DiscussionComment), lightweight signals (Vote), moderation intake (Report) and user notifications (Notification). Design notes ------------ * All primary keys are UUIDs, consistent with the rest of the platform, so content can be exported/imported across instances without id collisions. * Reviews always target an immutable *version* (ProblemVersion or CourseVersion), never the mutable head record. This enforces the invariant that "every published problem/course comes from a reviewed version": the publishing service checks for accepted reviews on the exact version being published. * DiscussionThread, Vote and Report use generic relations so that any entity (problems, courses, lessons, comments, reviews) can be discussed, voted on or reported without schema churn. * User-authored text (threads, comments) is soft-deleted: rows are kept for audit/moderation purposes, with ``deleted_at`` set and bodies redacted at the API layer. * Author/reviewer foreign keys use ``PROTECT`` because account removal on the platform is a soft-delete/anonymisation flow (see ``apps.accounts``); hard-deleting a user with attributed community activity is intentionally impossible. """ from __future__ import annotations import uuid from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import F, Q from django.utils import timezone # --------------------------------------------------------------------------- # Local base classes (kept self-contained so the app has no import-order # coupling with other apps' module internals; FKs use string labels). # --------------------------------------------------------------------------- class CommunityBase(models.Model): """UUID primary key plus created/updated timestamps.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) created_at = models.DateTimeField(auto_now_add=True, db_index=True) updated_at = models.DateTimeField(auto_now=True) class Meta: abstract = True class SoftDeleteQuerySet(models.QuerySet): def alive(self): return self.filter(deleted_at__isnull=True) def dead(self): return self.filter(deleted_at__isnull=False) def delete(self): """Bulk soft delete.""" return super().update(deleted_at=timezone.now()) def hard_delete(self): return super().delete() class SoftDeleteManager(models.Manager.from_queryset(SoftDeleteQuerySet)): pass class SoftDeletableModel(CommunityBase): """Soft-deletable community content; bodies are redacted at API layer.""" deleted_at = models.DateTimeField(null=True, blank=True) objects = SoftDeleteManager() class Meta: abstract = True @property def is_deleted(self) -> bool: return self.deleted_at is not None def soft_delete(self) -> None: if self.deleted_at is None: self.deleted_at = timezone.now() self.save(update_fields=["deleted_at", "updated_at"]) def restore(self) -> None: if self.deleted_at is not None: self.deleted_at = None self.save(update_fields=["deleted_at", "updated_at"]) # --------------------------------------------------------------------------- # Peer review # --------------------------------------------------------------------------- class Review(CommunityBase): """A peer review of exactly one ProblemVersion *or* one CourseVersion. The contribution workflow (draft → submitted → peer review → accepted/rejected → published) consumes completed reviews: a version needs the configured number of ``APPROVE`` decisions (and no unresolved blocking comments) before it may be accepted. """ class Status(models.TextChoices): PENDING = "pending", "Pending" IN_PROGRESS = "in_progress", "In progress" COMPLETED = "completed", "Completed" WITHDRAWN = "withdrawn", "Withdrawn" class Decision(models.TextChoices): APPROVE = "approve", "Approve" REQUEST_CHANGES = "request_changes", "Request changes" REJECT = "reject", "Reject" reviewer = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="reviews", ) problem_version = models.ForeignKey( "content.ProblemVersion", on_delete=models.CASCADE, related_name="reviews", null=True, blank=True, ) course_version = models.ForeignKey( "content.CourseVersion", on_delete=models.CASCADE, related_name="reviews", null=True, blank=True, ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.PENDING ) decision = models.CharField( max_length=20, choices=Decision.choices, blank=True, default="" ) summary = models.TextField( blank=True, default="", help_text="Overall review summary shown to the author (Markdown subset).", ) rubric = models.JSONField( blank=True, default=dict, help_text=( "Structured rubric scores, e.g. {'correctness': 5, 'clarity': 4, " "'difficulty_calibration': 3, 'accessibility': 4}." ), ) assigned_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-created_at"] constraints = [ # Exactly one review target. models.CheckConstraint( condition=( Q(problem_version__isnull=False, course_version__isnull=True) | Q(problem_version__isnull=True, course_version__isnull=False) ), name="ck_review_one_target", ), # A completed review must carry a decision. models.CheckConstraint( condition=~Q(status="completed") | ~Q(decision=""), name="ck_review_completed_decision", ), # One non-withdrawn review per reviewer per version. models.UniqueConstraint( fields=["reviewer", "problem_version"], condition=Q(problem_version__isnull=False) & ~Q(status="withdrawn"), name="uq_review_problem_ver_active", ), models.UniqueConstraint( fields=["reviewer", "course_version"], condition=Q(course_version__isnull=False) & ~Q(status="withdrawn"), name="uq_review_course_ver_active", ), ] indexes = [ models.Index(fields=["status"], name="comm_review_status_idx"), models.Index(fields=["reviewer", "status"], name="comm_review_reviewer_idx"), ] def __str__(self) -> str: # pragma: no cover - repr helper target = self.problem_version_id or self.course_version_id return f"Review<{self.pk}> of {target} by {self.reviewer_id} [{self.status}]" @property def target_version_id(self): return self.problem_version_id or self.course_version_id def complete(self, decision: str, summary: str = "", rubric: dict | None = None): """Finalise the review with a decision.""" self.decision = decision if summary: self.summary = summary if rubric is not None: self.rubric = rubric self.status = self.Status.COMPLETED self.completed_at = timezone.now() self.save( update_fields=[ "decision", "summary", "rubric", "status", "completed_at", "updated_at", ] ) class ReviewComment(SoftDeletableModel): """A threaded comment on a review, optionally anchored to a content node. ``content_path`` is a JSON Pointer (RFC 6901) into the version's structured content document, e.g. ``/blocks/3/children/0`` — this is the review equivalent of a line comment in a code review. """ class Kind(models.TextChoices): COMMENT = "comment", "Comment" SUGGESTION = "suggestion", "Suggestion" BLOCKING = "blocking", "Blocking issue" review = models.ForeignKey( Review, on_delete=models.CASCADE, related_name="comments" ) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="review_comments", ) parent = models.ForeignKey( "self", on_delete=models.CASCADE, related_name="replies", null=True, blank=True, ) kind = models.CharField(max_length=20, choices=Kind.choices, default=Kind.COMMENT) body = models.TextField(help_text="Markdown subset with inline LaTeX.") content_path = models.CharField( max_length=255, blank=True, default="", help_text="JSON Pointer into the reviewed version's content document.", ) is_resolved = models.BooleanField(default=False) resolved_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="+", null=True, blank=True, ) resolved_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["created_at"] constraints = [ models.CheckConstraint( condition=~Q(parent=F("id")), name="ck_rcomment_no_self_parent" ), ] indexes = [ models.Index(fields=["review", "created_at"], name="comm_rcomment_review_idx"), ] def __str__(self) -> str: # pragma: no cover return f"ReviewComment<{self.pk}> on review {self.review_id}" def resolve(self, user) -> None: self.is_resolved = True self.resolved_by = user self.resolved_at = timezone.now() self.save(update_fields=["is_resolved", "resolved_by", "resolved_at", "updated_at"]) # --------------------------------------------------------------------------- # Discussion # --------------------------------------------------------------------------- class DiscussionThread(SoftDeletableModel): """A discussion thread attached to any content object (generic relation). Typical targets: Problem, Course, Lesson. Threads of kind ``solution`` are spoiler-gated in the UI until the learner has attempted the problem. """ class Kind(models.TextChoices): QUESTION = "question", "Question" DISCUSSION = "discussion", "Discussion" CLARIFICATION = "clarification", "Clarification request" SOLUTION = "solution", "Solution discussion" FEEDBACK = "feedback", "Feedback" content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.UUIDField() target = GenericForeignKey("content_type", "object_id") author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="discussion_threads", ) title = models.CharField(max_length=200) kind = models.CharField(max_length=20, choices=Kind.choices, default=Kind.DISCUSSION) is_pinned = models.BooleanField(default=False) is_locked = models.BooleanField(default=False) contains_spoilers = models.BooleanField( default=False, help_text="If true, the thread is hidden until the learner attempts the target.", ) comment_count = models.PositiveIntegerField(default=0) last_activity_at = models.DateTimeField(default=timezone.now) class Meta: ordering = ["-is_pinned", "-last_activity_at"] indexes = [ models.Index(fields=["content_type", "object_id"], name="comm_thread_target_idx"), models.Index(fields=["-last_activity_at"], name="comm_thread_activity_idx"), ] def __str__(self) -> str: # pragma: no cover return f"Thread<{self.pk}> {self.title!r}" def touch(self) -> None: """Record activity (called when a comment is added).""" self.last_activity_at = timezone.now() self.comment_count = self.comments.alive().count() self.save(update_fields=["last_activity_at", "comment_count", "updated_at"]) class DiscussionComment(SoftDeletableModel): """A threaded comment inside a discussion thread.""" thread = models.ForeignKey( DiscussionThread, on_delete=models.CASCADE, related_name="comments" ) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="discussion_comments", ) parent = models.ForeignKey( "self", on_delete=models.CASCADE, related_name="replies", null=True, blank=True, ) body = models.TextField(help_text="Markdown subset with inline LaTeX.") score = models.IntegerField( default=0, help_text="Denormalised vote tally, refreshed by background jobs." ) is_accepted = models.BooleanField( default=False, help_text="Accepted answer for threads of kind 'question'.", ) edited_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["created_at"] constraints = [ models.CheckConstraint( condition=~Q(parent=F("id")), name="ck_dcomment_no_self_parent" ), ] indexes = [ models.Index(fields=["thread", "created_at"], name="comm_dcomment_thread_idx"), ] def __str__(self) -> str: # pragma: no cover return f"Comment<{self.pk}> on thread {self.thread_id}" # --------------------------------------------------------------------------- # Votes # --------------------------------------------------------------------------- class Vote(CommunityBase): """An up/down vote on any votable object (comments, problems, solutions).""" UP = 1 DOWN = -1 voter = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes" ) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.UUIDField() target = GenericForeignKey("content_type", "object_id") value = models.SmallIntegerField(choices=[(UP, "Up"), (DOWN, "Down")]) class Meta: constraints = [ models.UniqueConstraint( fields=["voter", "content_type", "object_id"], name="uq_vote_voter_target", ), models.CheckConstraint( condition=Q(value__in=[-1, 1]), name="ck_vote_value_pm1" ), ] indexes = [ models.Index(fields=["content_type", "object_id"], name="comm_vote_target_idx"), ] def __str__(self) -> str: # pragma: no cover return f"Vote<{self.pk}> {self.value:+d} by {self.voter_id}" @classmethod def tally(cls, obj) -> int: """Return the live vote score for an object.""" ct = ContentType.objects.get_for_model(obj, for_concrete_model=False) agg = cls.objects.filter(content_type=ct, object_id=obj.pk).aggregate( score=models.Sum("value") ) return agg["score"] or 0 # --------------------------------------------------------------------------- # Moderation reports # --------------------------------------------------------------------------- class Report(CommunityBase): """A user-filed report against any object, handled by moderators. Resolution actions (content takedown, user sanction, etc.) are recorded in ``core.AuditLog`` by the moderation service; the report row keeps the human-readable resolution note. """ class Reason(models.TextChoices): SPAM = "spam", "Spam" PLAGIARISM = "plagiarism", "Plagiarism / missing attribution" INCORRECT = "incorrect_content", "Incorrect content" HARASSMENT = "harassment", "Harassment or abuse" COPYRIGHT = "copyright", "Copyright violation" OFF_TOPIC = "off_topic", "Off topic" SECURITY = "security", "Security issue (e.g. malicious widget)" OTHER = "other", "Other" class Status(models.TextChoices): OPEN = "open", "Open" TRIAGED = "triaged", "Triaged" ACTION_TAKEN = "action_taken", "Action taken" DISMISSED = "dismissed", "Dismissed" DUPLICATE = "duplicate", "Duplicate" reporter = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="reports_filed", null=True, blank=True, ) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.UUIDField() target = GenericForeignKey("content_type", "object_id") reason = models.CharField(max_length=30, choices=Reason.choices) details = models.TextField(blank=True, default="") status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN) handled_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="reports_handled", null=True, blank=True, ) resolution_note = models.TextField(blank=True, default="") resolved_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-created_at"] constraints = [ # A reporter may have at most one open report per target. models.UniqueConstraint( fields=["reporter", "content_type", "object_id"], condition=Q(status="open"), name="uq_report_open_per_target", ), ] indexes = [ models.Index(fields=["status", "created_at"], name="comm_report_status_idx"), models.Index(fields=["content_type", "object_id"], name="comm_report_target_idx"), ] def __str__(self) -> str: # pragma: no cover return f"Report<{self.pk}> {self.reason} [{self.status}]" def resolve(self, moderator, status: str, note: str = "") -> None: self.status = status self.handled_by = moderator self.resolution_note = note self.resolved_at = timezone.now() self.save( update_fields=[ "status", "handled_by", "resolution_note", "resolved_at", "updated_at", ] ) # --------------------------------------------------------------------------- # Notifications # --------------------------------------------------------------------------- class Notification(CommunityBase): """An in-app notification; also drives email/webhook digests.""" class Verb(models.TextChoices): REVIEW_REQUESTED = "review_requested", "Review requested" REVIEW_COMPLETED = "review_completed", "Review completed" REVIEW_COMMENT = "review_comment", "New review comment" CONTENT_PUBLISHED = "content_published", "Content published" CONTENT_REJECTED = "content_rejected", "Content rejected" DISCUSSION_REPLY = "discussion_reply", "New reply in thread" COMMENT_REPLY = "comment_reply", "Reply to your comment" ANSWER_ACCEPTED = "answer_accepted", "Answer accepted" MENTION = "mention", "You were mentioned" VOTE_MILESTONE = "vote_milestone", "Vote milestone reached" REPORT_RESOLVED = "report_resolved", "Your report was resolved" MODERATION_ACTION = "moderation_action", "Moderation action" SYSTEM = "system", "System announcement" recipient = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications", ) actor = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="+", null=True, blank=True, ) verb = models.CharField(max_length=40, choices=Verb.choices) content_type = models.ForeignKey( ContentType, on_delete=models.SET_NULL, null=True, blank=True ) object_id = models.UUIDField(null=True, blank=True) target = GenericForeignKey("content_type", "object_id") payload = models.JSONField( blank=True, default=dict, help_text="Render-ready context: titles, slugs, snippet text, URLs.", ) read_at = models.DateTimeField(null=True, blank=True) emailed = models.BooleanField(default=False) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["recipient", "read_at"], name="comm_notif_recipient_idx"), models.Index(fields=["recipient", "-created_at"], name="comm_notif_recent_idx"), ] def __str__(self) -> str: # pragma: no cover return f"Notification<{self.pk}> {self.verb} -> {self.recipient_id}" @property def is_read(self) -> bool: return self.read_at is not None def mark_read(self) -> None: if self.read_at is None: self.read_at = timezone.now() self.save(update_fields=["read_at", "updated_at"])