mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
- Created a base email template (base.html) for consistent styling across all emails. - Added moderation approval email template (moderation_approved.html) to notify users of approved submissions. - Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions. - Created password reset email template (password_reset.html) for users requesting to reset their passwords. - Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
478 lines
14 KiB
Python
478 lines
14 KiB
Python
"""
|
|
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
|