""" Moderation models for ThrillWiki. This module implements the content submission and approval workflow with: - State machine using django-fsm - Atomic transaction support for approvals - 15-minute review lock mechanism - Selective approval of individual items """ import uuid from django.db import models from django.utils import timezone from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django_fsm import FSMField, transition from apps.core.models import BaseModel class ContentSubmission(BaseModel): """ Main submission model with FSM state machine. Represents a batch of changes submitted by a user for moderation. Can contain multiple SubmissionItem objects representing individual field changes. """ # State choices for FSM STATE_DRAFT = 'draft' STATE_PENDING = 'pending' STATE_REVIEWING = 'reviewing' STATE_APPROVED = 'approved' STATE_REJECTED = 'rejected' STATE_CHOICES = [ (STATE_DRAFT, 'Draft'), (STATE_PENDING, 'Pending Review'), (STATE_REVIEWING, 'Under Review'), (STATE_APPROVED, 'Approved'), (STATE_REJECTED, 'Rejected'), ] # FSM State field status = FSMField( max_length=20, choices=STATE_CHOICES, default=STATE_DRAFT, db_index=True, protected=True, # Prevents direct status changes help_text="Current submission state (managed by FSM)" ) # Submitter user = models.ForeignKey( 'users.User', on_delete=models.CASCADE, related_name='submissions', help_text="User who submitted the changes" ) # Entity being modified (generic relation) entity_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, help_text="Type of entity being modified" ) entity_id = models.UUIDField( help_text="ID of the entity being modified" ) entity = GenericForeignKey('entity_type', 'entity_id') # Submission type SUBMISSION_TYPE_CHOICES = [ ('create', 'Create'), ('update', 'Update'), ('delete', 'Delete'), ] submission_type = models.CharField( max_length=20, choices=SUBMISSION_TYPE_CHOICES, db_index=True, help_text="Type of operation (create, update, delete)" ) # Submission details title = models.CharField( max_length=255, help_text="Brief description of changes" ) description = models.TextField( blank=True, help_text="Detailed description of changes" ) # Review lock mechanism (15-minute lock) locked_by = models.ForeignKey( 'users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='locked_submissions', help_text="Moderator currently reviewing this submission" ) locked_at = models.DateTimeField( null=True, blank=True, help_text="When the submission was locked for review" ) # Review details reviewed_by = models.ForeignKey( 'users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_submissions', help_text="Moderator who reviewed this submission" ) reviewed_at = models.DateTimeField( null=True, blank=True, help_text="When the submission was reviewed" ) rejection_reason = models.TextField( blank=True, help_text="Reason for rejection (if rejected)" ) # Metadata source = models.CharField( max_length=50, default='web', help_text="Source of submission (web, api, mobile, etc.)" ) ip_address = models.GenericIPAddressField( null=True, blank=True, help_text="IP address of submitter" ) user_agent = models.CharField( max_length=500, blank=True, help_text="User agent of submitter" ) # Additional data metadata = models.JSONField( default=dict, blank=True, help_text="Additional submission metadata" ) class Meta: db_table = 'content_submissions' ordering = ['-created'] indexes = [ models.Index(fields=['status', 'created']), models.Index(fields=['user', 'status']), models.Index(fields=['entity_type', 'entity_id']), models.Index(fields=['locked_by', 'locked_at']), ] verbose_name = 'Content Submission' verbose_name_plural = 'Content Submissions' def __str__(self): return f"{self.get_submission_type_display()} - {self.title} ({self.get_status_display()})" # FSM Transitions @transition(field=status, source=STATE_DRAFT, target=STATE_PENDING) def submit(self): """Submit for review - moves from draft to pending""" pass @transition(field=status, source=STATE_PENDING, target=STATE_REVIEWING) def start_review(self, reviewer): """Lock submission for review""" self.locked_by = reviewer self.locked_at = timezone.now() @transition(field=status, source=STATE_REVIEWING, target=STATE_APPROVED) def approve(self, reviewer): """Approve submission""" self.reviewed_by = reviewer self.reviewed_at = timezone.now() self.locked_by = None self.locked_at = None @transition(field=status, source=STATE_REVIEWING, target=STATE_REJECTED) def reject(self, reviewer, reason): """Reject submission""" self.reviewed_by = reviewer self.reviewed_at = timezone.now() self.rejection_reason = reason self.locked_by = None self.locked_at = None @transition(field=status, source=STATE_REVIEWING, target=STATE_PENDING) def unlock(self): """Unlock submission (timeout or manual unlock)""" self.locked_by = None self.locked_at = None # Helper methods def is_locked(self): """Check if submission is currently locked""" if not self.locked_by or not self.locked_at: return False # Check if lock has expired (15 minutes) lock_duration = timezone.now() - self.locked_at return lock_duration.total_seconds() < 15 * 60 def can_review(self, user): """Check if user can review this submission""" if self.status != self.STATE_REVIEWING: return False # Check if locked by another user if self.locked_by and self.locked_by != user: return not self.is_locked() return True def get_items_count(self): """Get count of submission items""" return self.items.count() def get_approved_items_count(self): """Get count of approved items""" return self.items.filter(status='approved').count() def get_rejected_items_count(self): """Get count of rejected items""" return self.items.filter(status='rejected').count() class SubmissionItem(BaseModel): """ Individual change within a submission. Represents a single field change (or entity creation/deletion). Supports selective approval - each item can be approved/rejected independently. """ STATUS_CHOICES = [ ('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected'), ] # Parent submission submission = models.ForeignKey( ContentSubmission, on_delete=models.CASCADE, related_name='items', help_text="Parent submission" ) # Item details field_name = models.CharField( max_length=100, help_text="Name of the field being changed" ) field_label = models.CharField( max_length=200, blank=True, help_text="Human-readable field label" ) # Values (stored as JSON for flexibility) old_value = models.JSONField( null=True, blank=True, help_text="Previous value (null for new fields)" ) new_value = models.JSONField( null=True, blank=True, help_text="New value (null for deletions)" ) # Item status (for selective approval) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True, help_text="Status of this individual item" ) # Review details (for selective approval) reviewed_by = models.ForeignKey( 'users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_items', help_text="Moderator who reviewed this item" ) reviewed_at = models.DateTimeField( null=True, blank=True, help_text="When this item was reviewed" ) rejection_reason = models.TextField( blank=True, help_text="Reason for rejecting this specific item" ) # Metadata change_type = models.CharField( max_length=20, choices=[ ('add', 'Add'), ('modify', 'Modify'), ('remove', 'Remove'), ], default='modify', help_text="Type of change" ) is_required = models.BooleanField( default=False, help_text="Whether this change is required for the submission" ) order = models.IntegerField( default=0, help_text="Display order within submission" ) class Meta: db_table = 'submission_items' ordering = ['submission', 'order', 'created'] indexes = [ models.Index(fields=['submission', 'status']), models.Index(fields=['status']), ] verbose_name = 'Submission Item' verbose_name_plural = 'Submission Items' def __str__(self): return f"{self.submission.title} - {self.field_label or self.field_name}" def approve(self, reviewer): """Approve this item""" self.status = 'approved' self.reviewed_by = reviewer self.reviewed_at = timezone.now() self.save(update_fields=['status', 'reviewed_by', 'reviewed_at', 'modified']) def reject(self, reviewer, reason=''): """Reject this item""" self.status = 'rejected' self.reviewed_by = reviewer self.reviewed_at = timezone.now() self.rejection_reason = reason self.save(update_fields=['status', 'reviewed_by', 'reviewed_at', 'rejection_reason', 'modified']) def get_display_value(self, value): """Get human-readable display value""" if value is None: return 'None' if isinstance(value, bool): return 'Yes' if value else 'No' if isinstance(value, (list, dict)): return str(value) return str(value) @property def old_value_display(self): """Human-readable old value""" return self.get_display_value(self.old_value) @property def new_value_display(self): """Human-readable new value""" return self.get_display_value(self.new_value) class ModerationLock(BaseModel): """ Lock record for submissions under review. Provides additional tracking beyond the ContentSubmission lock fields. Helps with monitoring and debugging lock issues. """ submission = models.OneToOneField( ContentSubmission, on_delete=models.CASCADE, related_name='lock_record', help_text="Submission that is locked" ) locked_by = models.ForeignKey( 'users.User', on_delete=models.CASCADE, related_name='moderation_locks', help_text="User who holds the lock" ) locked_at = models.DateTimeField( auto_now_add=True, help_text="When the lock was acquired" ) expires_at = models.DateTimeField( help_text="When the lock expires" ) is_active = models.BooleanField( default=True, db_index=True, help_text="Whether the lock is currently active" ) released_at = models.DateTimeField( null=True, blank=True, help_text="When the lock was released" ) class Meta: db_table = 'moderation_locks' ordering = ['-locked_at'] indexes = [ models.Index(fields=['is_active', 'expires_at']), models.Index(fields=['locked_by', 'is_active']), ] verbose_name = 'Moderation Lock' verbose_name_plural = 'Moderation Locks' def __str__(self): return f"Lock on {self.submission.title} by {self.locked_by.email}" def is_expired(self): """Check if lock has expired""" return timezone.now() > self.expires_at def release(self): """Release the lock""" self.is_active = False self.released_at = timezone.now() self.save(update_fields=['is_active', 'released_at', 'modified']) def extend(self, minutes=15): """Extend the lock duration""" from datetime import timedelta self.expires_at = timezone.now() + timedelta(minutes=minutes) self.save(update_fields=['expires_at', 'modified']) @classmethod def cleanup_expired(cls): """Cleanup expired locks (for periodic task)""" expired_locks = cls.objects.filter( is_active=True, expires_at__lt=timezone.now() ) count = 0 for lock in expired_locks: # Release lock lock.release() # Unlock submission if still in reviewing state submission = lock.submission if submission.status == ContentSubmission.STATE_REVIEWING: submission.unlock() submission.save() count += 1 return count