Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

@@ -0,0 +1,424 @@
"""
Django admin for moderation models.
"""
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from django.utils import timezone
from unfold.admin import ModelAdmin
from unfold.decorators import display
from apps.moderation.models import ContentSubmission, SubmissionItem, ModerationLock
@admin.register(ContentSubmission)
class ContentSubmissionAdmin(ModelAdmin):
"""Admin for ContentSubmission model."""
list_display = [
'title_with_icon',
'status_badge',
'entity_info',
'user',
'items_summary',
'locked_info',
'created',
]
list_filter = [
'status',
'submission_type',
'entity_type',
'created',
]
search_fields = [
'title',
'description',
'user__email',
'user__username',
]
readonly_fields = [
'id',
'status',
'entity_type',
'entity_id',
'locked_by',
'locked_at',
'reviewed_by',
'reviewed_at',
'created',
'modified',
]
fieldsets = (
('Submission Info', {
'fields': (
'id',
'title',
'description',
'submission_type',
'status',
)
}),
('Entity', {
'fields': (
'entity_type',
'entity_id',
)
}),
('User Info', {
'fields': (
'user',
'source',
'ip_address',
'user_agent',
)
}),
('Review Info', {
'fields': (
'locked_by',
'locked_at',
'reviewed_by',
'reviewed_at',
'rejection_reason',
)
}),
('Metadata', {
'fields': (
'metadata',
'created',
'modified',
),
'classes': ('collapse',)
}),
)
@display(description='Title', ordering='title')
def title_with_icon(self, obj):
"""Display title with submission type icon."""
icons = {
'create': '',
'update': '✏️',
'delete': '🗑️',
}
icon = icons.get(obj.submission_type, '📝')
return f"{icon} {obj.title}"
@display(description='Status', ordering='status')
def status_badge(self, obj):
"""Display colored status badge."""
colors = {
'draft': 'gray',
'pending': 'blue',
'reviewing': 'orange',
'approved': 'green',
'rejected': 'red',
}
color = colors.get(obj.status, 'gray')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 3px; font-size: 11px; font-weight: bold;">{}</span>',
color,
obj.get_status_display()
)
@display(description='Entity')
def entity_info(self, obj):
"""Display entity type and ID."""
return f"{obj.entity_type.model} #{str(obj.entity_id)[:8]}"
@display(description='Items')
def items_summary(self, obj):
"""Display item counts."""
total = obj.get_items_count()
approved = obj.get_approved_items_count()
rejected = obj.get_rejected_items_count()
pending = total - approved - rejected
return format_html(
'<span title="Pending">{}</span> / '
'<span style="color: green;" title="Approved">{}</span> / '
'<span style="color: red;" title="Rejected">{}</span>',
pending, approved, rejected
)
@display(description='Lock Status')
def locked_info(self, obj):
"""Display lock information."""
if obj.locked_by:
is_expired = not obj.is_locked()
status = '🔓 Expired' if is_expired else '🔒 Locked'
return f"{status} by {obj.locked_by.email}"
return '✅ Unlocked'
def get_queryset(self, request):
"""Optimize queryset with select_related."""
qs = super().get_queryset(request)
return qs.select_related(
'user',
'entity_type',
'locked_by',
'reviewed_by'
).prefetch_related('items')
class SubmissionItemInline(admin.TabularInline):
"""Inline admin for submission items."""
model = SubmissionItem
extra = 0
fields = [
'field_label',
'old_value_display',
'new_value_display',
'change_type',
'status',
'reviewed_by',
]
readonly_fields = [
'field_label',
'old_value_display',
'new_value_display',
'change_type',
'status',
'reviewed_by',
]
can_delete = False
def has_add_permission(self, request, obj=None):
return False
@admin.register(SubmissionItem)
class SubmissionItemAdmin(ModelAdmin):
"""Admin for SubmissionItem model."""
list_display = [
'field_label',
'submission_title',
'change_type_badge',
'status_badge',
'old_value_display',
'new_value_display',
'reviewed_by',
]
list_filter = [
'status',
'change_type',
'is_required',
'created',
]
search_fields = [
'field_name',
'field_label',
'submission__title',
]
readonly_fields = [
'id',
'submission',
'field_name',
'field_label',
'old_value',
'new_value',
'old_value_display',
'new_value_display',
'status',
'reviewed_by',
'reviewed_at',
'created',
'modified',
]
fieldsets = (
('Item Info', {
'fields': (
'id',
'submission',
'field_name',
'field_label',
'change_type',
'is_required',
'order',
)
}),
('Values', {
'fields': (
'old_value',
'new_value',
'old_value_display',
'new_value_display',
)
}),
('Review Info', {
'fields': (
'status',
'reviewed_by',
'reviewed_at',
'rejection_reason',
)
}),
('Timestamps', {
'fields': (
'created',
'modified',
)
}),
)
@display(description='Submission')
def submission_title(self, obj):
"""Display submission title with link."""
url = reverse('admin:moderation_contentsubmission_change', args=[obj.submission.id])
return format_html('<a href="{}">{}</a>', url, obj.submission.title)
@display(description='Type', ordering='change_type')
def change_type_badge(self, obj):
"""Display colored change type badge."""
colors = {
'add': 'green',
'modify': 'blue',
'remove': 'red',
}
color = colors.get(obj.change_type, 'gray')
return format_html(
'<span style="background-color: {}; color: white; padding: 2px 6px; '
'border-radius: 3px; font-size: 10px;">{}</span>',
color,
obj.get_change_type_display()
)
@display(description='Status', ordering='status')
def status_badge(self, obj):
"""Display colored status badge."""
colors = {
'pending': 'orange',
'approved': 'green',
'rejected': 'red',
}
color = colors.get(obj.status, 'gray')
return format_html(
'<span style="background-color: {}; color: white; padding: 2px 6px; '
'border-radius: 3px; font-size: 10px;">{}</span>',
color,
obj.get_status_display()
)
def get_queryset(self, request):
"""Optimize queryset with select_related."""
qs = super().get_queryset(request)
return qs.select_related('submission', 'reviewed_by')
@admin.register(ModerationLock)
class ModerationLockAdmin(ModelAdmin):
"""Admin for ModerationLock model."""
list_display = [
'submission_title',
'locked_by',
'locked_at',
'expires_at',
'status_indicator',
'lock_duration',
]
list_filter = [
'is_active',
'locked_at',
'expires_at',
]
search_fields = [
'submission__title',
'locked_by__email',
'locked_by__username',
]
readonly_fields = [
'id',
'submission',
'locked_by',
'locked_at',
'expires_at',
'is_active',
'released_at',
'lock_duration',
'is_expired_display',
'created',
'modified',
]
fieldsets = (
('Lock Info', {
'fields': (
'id',
'submission',
'locked_by',
'is_active',
)
}),
('Timing', {
'fields': (
'locked_at',
'expires_at',
'released_at',
'lock_duration',
'is_expired_display',
)
}),
('Timestamps', {
'fields': (
'created',
'modified',
)
}),
)
@display(description='Submission')
def submission_title(self, obj):
"""Display submission title with link."""
url = reverse('admin:moderation_contentsubmission_change', args=[obj.submission.id])
return format_html('<a href="{}">{}</a>', url, obj.submission.title)
@display(description='Status')
def status_indicator(self, obj):
"""Display lock status."""
if not obj.is_active:
return format_html(
'<span style="color: gray;">🔓 Released</span>'
)
elif obj.is_expired():
return format_html(
'<span style="color: orange;">⏰ Expired</span>'
)
else:
return format_html(
'<span style="color: green;">🔒 Active</span>'
)
@display(description='Duration')
def lock_duration(self, obj):
"""Display lock duration."""
if obj.released_at:
duration = obj.released_at - obj.locked_at
else:
duration = timezone.now() - obj.locked_at
minutes = int(duration.total_seconds() / 60)
return f"{minutes} minutes"
@display(description='Expired?')
def is_expired_display(self, obj):
"""Display if lock is expired."""
if not obj.is_active:
return 'N/A (Released)'
return 'Yes' if obj.is_expired() else 'No'
def get_queryset(self, request):
"""Optimize queryset with select_related."""
qs = super().get_queryset(request)
return qs.select_related('submission', 'locked_by')

View File

@@ -0,0 +1,11 @@
"""
Moderation app configuration.
"""
from django.apps import AppConfig
class ModerationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.moderation'
verbose_name = 'Moderation'

View File

@@ -0,0 +1,454 @@
# Generated by Django 4.2.8 on 2025-11-08 17:40
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import django_fsm
import django_lifecycle.mixins
import model_utils.fields
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="ContentSubmission",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"status",
django_fsm.FSMField(
choices=[
("draft", "Draft"),
("pending", "Pending Review"),
("reviewing", "Under Review"),
("approved", "Approved"),
("rejected", "Rejected"),
],
db_index=True,
default="draft",
help_text="Current submission state (managed by FSM)",
max_length=20,
protected=True,
),
),
(
"entity_id",
models.UUIDField(help_text="ID of the entity being modified"),
),
(
"submission_type",
models.CharField(
choices=[
("create", "Create"),
("update", "Update"),
("delete", "Delete"),
],
db_index=True,
help_text="Type of operation (create, update, delete)",
max_length=20,
),
),
(
"title",
models.CharField(
help_text="Brief description of changes", max_length=255
),
),
(
"description",
models.TextField(
blank=True, help_text="Detailed description of changes"
),
),
(
"locked_at",
models.DateTimeField(
blank=True,
help_text="When the submission was locked for review",
null=True,
),
),
(
"reviewed_at",
models.DateTimeField(
blank=True,
help_text="When the submission was reviewed",
null=True,
),
),
(
"rejection_reason",
models.TextField(
blank=True, help_text="Reason for rejection (if rejected)"
),
),
(
"source",
models.CharField(
default="web",
help_text="Source of submission (web, api, mobile, etc.)",
max_length=50,
),
),
(
"ip_address",
models.GenericIPAddressField(
blank=True, help_text="IP address of submitter", null=True
),
),
(
"user_agent",
models.CharField(
blank=True, help_text="User agent of submitter", max_length=500
),
),
(
"metadata",
models.JSONField(
blank=True,
default=dict,
help_text="Additional submission metadata",
),
),
(
"entity_type",
models.ForeignKey(
help_text="Type of entity being modified",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"locked_by",
models.ForeignKey(
blank=True,
help_text="Moderator currently reviewing this submission",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="locked_submissions",
to=settings.AUTH_USER_MODEL,
),
),
(
"reviewed_by",
models.ForeignKey(
blank=True,
help_text="Moderator who reviewed this submission",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="reviewed_submissions",
to=settings.AUTH_USER_MODEL,
),
),
(
"user",
models.ForeignKey(
help_text="User who submitted the changes",
on_delete=django.db.models.deletion.CASCADE,
related_name="submissions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Content Submission",
"verbose_name_plural": "Content Submissions",
"db_table": "content_submissions",
"ordering": ["-created"],
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.CreateModel(
name="SubmissionItem",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"field_name",
models.CharField(
help_text="Name of the field being changed", max_length=100
),
),
(
"field_label",
models.CharField(
blank=True,
help_text="Human-readable field label",
max_length=200,
),
),
(
"old_value",
models.JSONField(
blank=True,
help_text="Previous value (null for new fields)",
null=True,
),
),
(
"new_value",
models.JSONField(
blank=True,
help_text="New value (null for deletions)",
null=True,
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("approved", "Approved"),
("rejected", "Rejected"),
],
db_index=True,
default="pending",
help_text="Status of this individual item",
max_length=20,
),
),
(
"reviewed_at",
models.DateTimeField(
blank=True, help_text="When this item was reviewed", null=True
),
),
(
"rejection_reason",
models.TextField(
blank=True, help_text="Reason for rejecting this specific item"
),
),
(
"change_type",
models.CharField(
choices=[
("add", "Add"),
("modify", "Modify"),
("remove", "Remove"),
],
default="modify",
help_text="Type of change",
max_length=20,
),
),
(
"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"
),
),
(
"reviewed_by",
models.ForeignKey(
blank=True,
help_text="Moderator who reviewed this item",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="reviewed_items",
to=settings.AUTH_USER_MODEL,
),
),
(
"submission",
models.ForeignKey(
help_text="Parent submission",
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="moderation.contentsubmission",
),
),
],
options={
"verbose_name": "Submission Item",
"verbose_name_plural": "Submission Items",
"db_table": "submission_items",
"ordering": ["submission", "order", "created"],
"indexes": [
models.Index(
fields=["submission", "status"],
name="submission__submiss_71cf2f_idx",
),
models.Index(
fields=["status"], name="submission__status_61deb1_idx"
),
],
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.CreateModel(
name="ModerationLock",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"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(
db_index=True,
default=True,
help_text="Whether the lock is currently active",
),
),
(
"released_at",
models.DateTimeField(
blank=True, help_text="When the lock was released", null=True
),
),
(
"locked_by",
models.ForeignKey(
help_text="User who holds the lock",
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_locks",
to=settings.AUTH_USER_MODEL,
),
),
(
"submission",
models.OneToOneField(
help_text="Submission that is locked",
on_delete=django.db.models.deletion.CASCADE,
related_name="lock_record",
to="moderation.contentsubmission",
),
),
],
options={
"verbose_name": "Moderation Lock",
"verbose_name_plural": "Moderation Locks",
"db_table": "moderation_locks",
"ordering": ["-locked_at"],
"indexes": [
models.Index(
fields=["is_active", "expires_at"],
name="moderation__is_acti_ecf427_idx",
),
models.Index(
fields=["locked_by", "is_active"],
name="moderation__locked__d5cdfb_idx",
),
],
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.AddIndex(
model_name="contentsubmission",
index=models.Index(
fields=["status", "created"], name="content_sub_status_a8d552_idx"
),
),
migrations.AddIndex(
model_name="contentsubmission",
index=models.Index(
fields=["user", "status"], name="content_sub_user_id_019595_idx"
),
),
migrations.AddIndex(
model_name="contentsubmission",
index=models.Index(
fields=["entity_type", "entity_id"],
name="content_sub_entity__d0f313_idx",
),
),
migrations.AddIndex(
model_name="contentsubmission",
index=models.Index(
fields=["locked_by", "locked_at"], name="content_sub_locked__feb2b3_idx"
),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.8 on 2025-11-09 15:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="contentsubmission",
name="submission_type",
field=models.CharField(
choices=[
("create", "Create"),
("update", "Update"),
("delete", "Delete"),
("review", "Review"),
],
db_index=True,
help_text="Type of operation (create, update, delete)",
max_length=20,
),
),
]

View File

@@ -0,0 +1,478 @@
"""
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'),
('review', 'Review'),
]
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

View File

@@ -0,0 +1,638 @@
"""
Moderation services for ThrillWiki.
This module provides business logic for the content moderation workflow:
- Creating submissions
- Starting reviews with locks
- Approving submissions with atomic transactions
- Selective approval of individual items
- Rejecting submissions
- Unlocking expired submissions
"""
import logging
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError, PermissionDenied
from apps.moderation.models import ContentSubmission, SubmissionItem, ModerationLock
logger = logging.getLogger(__name__)
class ModerationService:
"""
Service class for moderation operations.
All public methods use atomic transactions to ensure data integrity.
"""
@staticmethod
@transaction.atomic
def create_submission(
user,
entity,
submission_type,
title,
description='',
items_data=None,
metadata=None,
auto_submit=True,
**kwargs
):
"""
Create a new content submission with items.
Args:
user: User creating the submission
entity: Entity being modified (Park, Ride, Company, etc.)
submission_type: 'create', 'update', or 'delete'
title: Brief description of changes
description: Detailed description (optional)
items_data: List of dicts with item details:
[
{
'field_name': 'name',
'field_label': 'Park Name',
'old_value': 'Old Name',
'new_value': 'New Name',
'change_type': 'modify',
'is_required': False,
'order': 0
},
...
]
metadata: Additional metadata dict
auto_submit: Whether to automatically submit (move to pending state)
**kwargs: Additional submission fields (source, ip_address, user_agent)
Returns:
ContentSubmission instance
Raises:
ValidationError: If validation fails
"""
# Get ContentType for entity
entity_type = ContentType.objects.get_for_model(entity)
# Create submission
submission = ContentSubmission.objects.create(
user=user,
entity_type=entity_type,
entity_id=entity.id,
submission_type=submission_type,
title=title,
description=description,
metadata=metadata or {},
source=kwargs.get('source', 'web'),
ip_address=kwargs.get('ip_address'),
user_agent=kwargs.get('user_agent', '')
)
# Create submission items
if items_data:
for item_data in items_data:
SubmissionItem.objects.create(
submission=submission,
field_name=item_data['field_name'],
field_label=item_data.get('field_label', item_data['field_name']),
old_value=item_data.get('old_value'),
new_value=item_data.get('new_value'),
change_type=item_data.get('change_type', 'modify'),
is_required=item_data.get('is_required', False),
order=item_data.get('order', 0)
)
# Auto-submit if requested
if auto_submit:
submission.submit()
submission.save()
return submission
@staticmethod
@transaction.atomic
def start_review(submission_id, reviewer):
"""
Start reviewing a submission (lock it).
Args:
submission_id: UUID of submission
reviewer: User starting the review
Returns:
ContentSubmission instance
Raises:
ValidationError: If submission cannot be reviewed
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check if user has permission to review
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission is in correct state
if submission.status != ContentSubmission.STATE_PENDING:
raise ValidationError(f"Submission must be pending to start review (current: {submission.status})")
# Check if already locked by another user
if submission.locked_by and submission.locked_by != reviewer:
if submission.is_locked():
raise ValidationError(f"Submission is locked by {submission.locked_by.email}")
# Start review (FSM transition)
submission.start_review(reviewer)
submission.save()
# Create lock record
expires_at = timezone.now() + timedelta(minutes=15)
ModerationLock.objects.update_or_create(
submission=submission,
defaults={
'locked_by': reviewer,
'expires_at': expires_at,
'is_active': True,
'released_at': None
}
)
return submission
@staticmethod
@transaction.atomic
def approve_submission(submission_id, reviewer):
"""
Approve an entire submission and apply all changes.
This method uses atomic transactions to ensure all-or-nothing behavior.
If any part fails, the entire operation is rolled back.
Handles different submission types polymorphically:
- 'review': Delegates to ReviewSubmissionService to create Review record
- 'create'/'update'/'delete': Applies changes to entity directly
Args:
submission_id: UUID of submission
reviewer: User approving the submission
Returns:
ContentSubmission instance
Raises:
ValidationError: If submission cannot be approved
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission can be reviewed
if not submission.can_review(reviewer):
raise ValidationError("Submission cannot be reviewed at this time")
# Get all pending items
items = submission.items.filter(status='pending')
# POLYMORPHIC HANDLING BASED ON SUBMISSION TYPE
if submission.submission_type == 'review':
# Handle review submissions - delegate to ReviewSubmissionService
logger.info(f"Approving review submission {submission_id}")
from apps.reviews.services import ReviewSubmissionService
review = ReviewSubmissionService.apply_review_approval(submission)
# Mark all items as approved
for item in items:
item.approve(reviewer)
logger.info(f"Review created: {review.id} from submission {submission_id}")
elif submission.submission_type in ['create', 'update', 'delete']:
# Handle entity submissions
entity = submission.entity
if not entity:
raise ValidationError("Entity no longer exists")
logger.info(f"Approving {submission.submission_type} submission {submission_id}")
if submission.submission_type == 'create':
# Entity was created in draft state, now apply all fields and make visible
for item in items:
if item.change_type in ['add', 'modify']:
setattr(entity, item.field_name, item.new_value)
item.approve(reviewer)
entity.save()
elif submission.submission_type == 'update':
# Apply updates to existing entity
for item in items:
if item.change_type in ['add', 'modify']:
setattr(entity, item.field_name, item.new_value)
elif item.change_type == 'remove':
setattr(entity, item.field_name, None)
item.approve(reviewer)
entity.save()
elif submission.submission_type == 'delete':
# Check deletion type from metadata
deletion_type = submission.metadata.get('deletion_type', 'soft')
if deletion_type == 'soft':
# Soft delete: Apply status change to 'closed'
for item in items:
if item.field_name == 'status':
# Apply status change
setattr(entity, 'status', 'closed')
item.approve(reviewer)
entity.save()
logger.info(f"Entity soft-deleted (status=closed): {entity.id}")
else:
# Hard delete: Remove from database
for item in items:
item.approve(reviewer)
entity.delete()
logger.info(f"Entity hard-deleted from database: {entity.id}")
logger.info(f"Entity changes applied for submission {submission_id}")
else:
raise ValidationError(f"Unknown submission type: {submission.submission_type}")
# Approve submission (FSM transition)
submission.approve(reviewer)
submission.save()
# Release lock
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
# Send notification email asynchronously
try:
from apps.moderation.tasks import send_moderation_notification
send_moderation_notification.delay(str(submission.id), 'approved')
except Exception as e:
# Don't fail the approval if email fails to queue
logger.warning(f"Failed to queue approval notification: {str(e)}")
return submission
@staticmethod
@transaction.atomic
def approve_selective(submission_id, reviewer, item_ids):
"""
Approve only specific items in a submission (selective approval).
This allows moderators to approve some changes while rejecting others.
Uses atomic transactions for data integrity.
Args:
submission_id: UUID of submission
reviewer: User approving the items
item_ids: List of item UUIDs to approve
Returns:
dict with counts: {'approved': N, 'total': M}
Raises:
ValidationError: If submission cannot be reviewed
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission can be reviewed
if not submission.can_review(reviewer):
raise ValidationError("Submission cannot be reviewed at this time")
# Get entity
entity = submission.entity
if not entity:
raise ValidationError("Entity no longer exists")
# Get items to approve
items_to_approve = submission.items.filter(
id__in=item_ids,
status='pending'
)
approved_count = 0
for item in items_to_approve:
# Apply change to entity
if item.change_type in ['add', 'modify']:
setattr(entity, item.field_name, item.new_value)
elif item.change_type == 'remove':
setattr(entity, item.field_name, None)
# Mark item as approved
item.approve(reviewer)
approved_count += 1
# Save entity if any changes were made
if approved_count > 0:
entity.save()
# Check if all items are now reviewed
pending_count = submission.items.filter(status='pending').count()
if pending_count == 0:
# All items reviewed - mark submission as approved
submission.approve(reviewer)
submission.save()
# Release lock
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
return {
'approved': approved_count,
'total': submission.items.count(),
'pending': pending_count,
'submission_approved': pending_count == 0
}
@staticmethod
@transaction.atomic
def reject_submission(submission_id, reviewer, reason):
"""
Reject an entire submission.
Args:
submission_id: UUID of submission
reviewer: User rejecting the submission
reason: Reason for rejection
Returns:
ContentSubmission instance
Raises:
ValidationError: If submission cannot be rejected
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission can be reviewed
if not submission.can_review(reviewer):
raise ValidationError("Submission cannot be reviewed at this time")
# Reject all pending items
items = submission.items.filter(status='pending')
for item in items:
item.reject(reviewer, reason)
# Reject submission (FSM transition)
submission.reject(reviewer, reason)
submission.save()
# Release lock
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
# Send notification email asynchronously
try:
from apps.moderation.tasks import send_moderation_notification
send_moderation_notification.delay(str(submission.id), 'rejected')
except Exception as e:
# Don't fail the rejection if email fails to queue
logger.warning(f"Failed to queue rejection notification: {str(e)}")
return submission
@staticmethod
@transaction.atomic
def reject_selective(submission_id, reviewer, item_ids, reason=''):
"""
Reject specific items in a submission.
Args:
submission_id: UUID of submission
reviewer: User rejecting the items
item_ids: List of item UUIDs to reject
reason: Reason for rejection (optional)
Returns:
dict with counts: {'rejected': N, 'total': M}
Raises:
ValidationError: If submission cannot be reviewed
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission can be reviewed
if not submission.can_review(reviewer):
raise ValidationError("Submission cannot be reviewed at this time")
# Get items to reject
items_to_reject = submission.items.filter(
id__in=item_ids,
status='pending'
)
rejected_count = 0
for item in items_to_reject:
item.reject(reviewer, reason)
rejected_count += 1
# Check if all items are now reviewed
pending_count = submission.items.filter(status='pending').count()
if pending_count == 0:
# All items reviewed
approved_count = submission.items.filter(status='approved').count()
if approved_count > 0:
# Some items approved - mark submission as approved
submission.approve(reviewer)
submission.save()
else:
# All items rejected - mark submission as rejected
submission.reject(reviewer, "All items rejected")
submission.save()
# Release lock
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
return {
'rejected': rejected_count,
'total': submission.items.count(),
'pending': pending_count,
'submission_complete': pending_count == 0
}
@staticmethod
@transaction.atomic
def unlock_submission(submission_id):
"""
Manually unlock a submission.
Args:
submission_id: UUID of submission
Returns:
ContentSubmission instance
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
if submission.status == ContentSubmission.STATE_REVIEWING:
submission.unlock()
submission.save()
# Release lock record
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
return submission
@staticmethod
def cleanup_expired_locks():
"""
Cleanup expired locks and unlock submissions.
This should be called periodically (e.g., every 5 minutes via Celery).
Returns:
int: Number of locks cleaned up
"""
return ModerationLock.cleanup_expired()
@staticmethod
def get_queue(status=None, user=None, limit=50, offset=0):
"""
Get moderation queue with filters.
Args:
status: Filter by status (optional)
user: Filter by submitter (optional)
limit: Maximum results
offset: Pagination offset
Returns:
QuerySet of ContentSubmission objects
"""
queryset = ContentSubmission.objects.select_related(
'user',
'entity_type',
'locked_by',
'reviewed_by'
).prefetch_related('items')
if status:
queryset = queryset.filter(status=status)
if user:
queryset = queryset.filter(user=user)
return queryset[offset:offset + limit]
@staticmethod
def get_submission_details(submission_id):
"""
Get full submission details with all items.
Args:
submission_id: UUID of submission
Returns:
ContentSubmission instance with prefetched items
"""
return ContentSubmission.objects.select_related(
'user',
'entity_type',
'locked_by',
'reviewed_by'
).prefetch_related(
'items',
'items__reviewed_by'
).get(id=submission_id)
@staticmethod
def _can_moderate(user):
"""
Check if user has moderation permission.
Args:
user: User to check
Returns:
bool: True if user can moderate
"""
if not user or not user.is_authenticated:
return False
# Check if user is superuser
if user.is_superuser:
return True
# Check if user has moderator or admin role
try:
return user.role.is_moderator
except:
return False
@staticmethod
@transaction.atomic
def delete_submission(submission_id, user):
"""
Delete a submission (only if draft or by owner).
Args:
submission_id: UUID of submission
user: User attempting to delete
Returns:
bool: True if deleted
Raises:
PermissionDenied: If user cannot delete
ValidationError: If submission cannot be deleted
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
is_owner = submission.user == user
is_moderator = ModerationService._can_moderate(user)
if not (is_owner or is_moderator):
raise PermissionDenied("Only the owner or a moderator can delete this submission")
# Check state
if submission.status not in [ContentSubmission.STATE_DRAFT, ContentSubmission.STATE_PENDING]:
if not is_moderator:
raise ValidationError("Only moderators can delete submissions under review")
# Delete submission (cascades to items and lock)
submission.delete()
return True

View File

@@ -0,0 +1,304 @@
"""
Background tasks for moderation workflows and notifications.
"""
import logging
from celery import shared_task
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from django.utils import timezone
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_moderation_notification(self, submission_id, status):
"""
Send email notification when a submission is approved or rejected.
Args:
submission_id: UUID of the ContentSubmission
status: 'approved' or 'rejected'
Returns:
str: Notification result message
"""
from apps.moderation.models import ContentSubmission
try:
submission = ContentSubmission.objects.select_related(
'user', 'reviewed_by', 'entity_type'
).prefetch_related('items').get(id=submission_id)
# Get user's submission count
user_submission_count = ContentSubmission.objects.filter(
user=submission.user
).count()
# Prepare email context
context = {
'submission': submission,
'status': status,
'user': submission.user,
'user_submission_count': user_submission_count,
'submission_url': f"{settings.SITE_URL}/submissions/{submission.id}/",
'site_url': settings.SITE_URL,
}
# Choose template based on status
if status == 'approved':
template = 'emails/moderation_approved.html'
subject = f'✅ Submission Approved: {submission.title}'
else:
template = 'emails/moderation_rejected.html'
subject = f'⚠️ Submission Requires Changes: {submission.title}'
# Render HTML email
html_message = render_to_string(template, context)
# Send email
send_mail(
subject=subject,
message='', # Plain text version (optional)
html_message=html_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[submission.user.email],
fail_silently=False,
)
logger.info(
f"Moderation notification sent: {status} for submission {submission_id} "
f"to {submission.user.email}"
)
return f"Notification sent to {submission.user.email}"
except ContentSubmission.DoesNotExist:
logger.error(f"Submission {submission_id} not found")
raise
except Exception as exc:
logger.error(f"Error sending notification for submission {submission_id}: {str(exc)}")
# Retry with exponential backoff
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
@shared_task(bind=True, max_retries=2)
def cleanup_expired_locks(self):
"""
Clean up expired moderation locks.
This task runs periodically to unlock submissions that have
been locked for too long (default: 15 minutes).
Returns:
int: Number of locks cleaned up
"""
from apps.moderation.models import ModerationLock
try:
cleaned = ModerationLock.cleanup_expired()
logger.info(f"Cleaned up {cleaned} expired moderation locks")
return cleaned
except Exception as exc:
logger.error(f"Error cleaning up expired locks: {str(exc)}")
raise self.retry(exc=exc, countdown=300) # Retry after 5 minutes
@shared_task(bind=True, max_retries=3)
def send_batch_moderation_summary(self, moderator_id):
"""
Send a daily summary email to a moderator with their moderation stats.
Args:
moderator_id: ID of the moderator user
Returns:
str: Email send result
"""
from apps.users.models import User
from apps.moderation.models import ContentSubmission
from datetime import timedelta
try:
moderator = User.objects.get(id=moderator_id)
# Get stats for the past 24 hours
yesterday = timezone.now() - timedelta(days=1)
stats = {
'reviewed_today': ContentSubmission.objects.filter(
reviewed_by=moderator,
reviewed_at__gte=yesterday
).count(),
'approved_today': ContentSubmission.objects.filter(
reviewed_by=moderator,
reviewed_at__gte=yesterday,
status='approved'
).count(),
'rejected_today': ContentSubmission.objects.filter(
reviewed_by=moderator,
reviewed_at__gte=yesterday,
status='rejected'
).count(),
'pending_queue': ContentSubmission.objects.filter(
status='pending'
).count(),
}
context = {
'moderator': moderator,
'stats': stats,
'date': timezone.now(),
'site_url': settings.SITE_URL,
}
# For now, just log the stats (template not created yet)
logger.info(f"Moderation summary for {moderator.email}: {stats}")
# In production, you would send an actual email:
# html_message = render_to_string('emails/moderation_summary.html', context)
# send_mail(...)
return f"Summary sent to {moderator.email}"
except User.DoesNotExist:
logger.error(f"Moderator {moderator_id} not found")
raise
except Exception as exc:
logger.error(f"Error sending moderation summary: {str(exc)}")
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
@shared_task
def update_moderation_statistics():
"""
Update moderation-related statistics across the database.
Returns:
dict: Updated statistics
"""
from apps.moderation.models import ContentSubmission
from django.db.models import Count, Avg, F
from datetime import timedelta
try:
now = timezone.now()
week_ago = now - timedelta(days=7)
stats = {
'total_submissions': ContentSubmission.objects.count(),
'pending': ContentSubmission.objects.filter(status='pending').count(),
'reviewing': ContentSubmission.objects.filter(status='reviewing').count(),
'approved': ContentSubmission.objects.filter(status='approved').count(),
'rejected': ContentSubmission.objects.filter(status='rejected').count(),
'this_week': ContentSubmission.objects.filter(
created_at__gte=week_ago
).count(),
'by_type': dict(
ContentSubmission.objects.values('submission_type')
.annotate(count=Count('id'))
.values_list('submission_type', 'count')
),
}
logger.info(f"Moderation statistics updated: {stats}")
return stats
except Exception as e:
logger.error(f"Error updating moderation statistics: {str(e)}")
raise
@shared_task(bind=True, max_retries=2)
def auto_unlock_stale_reviews(self, hours=1):
"""
Automatically unlock submissions that have been in review for too long.
This helps prevent submissions from getting stuck if a moderator
starts a review but doesn't complete it.
Args:
hours: Number of hours before auto-unlocking (default: 1)
Returns:
int: Number of submissions unlocked
"""
from apps.moderation.models import ContentSubmission
from apps.moderation.services import ModerationService
from datetime import timedelta
try:
cutoff = timezone.now() - timedelta(hours=hours)
# Find submissions that have been reviewing too long
stale_reviews = ContentSubmission.objects.filter(
status='reviewing',
locked_at__lt=cutoff
)
count = 0
for submission in stale_reviews:
try:
ModerationService.unlock_submission(submission.id)
count += 1
except Exception as e:
logger.error(f"Failed to unlock submission {submission.id}: {str(e)}")
continue
logger.info(f"Auto-unlocked {count} stale reviews")
return count
except Exception as exc:
logger.error(f"Error auto-unlocking stale reviews: {str(exc)}")
raise self.retry(exc=exc, countdown=300)
@shared_task
def notify_moderators_of_queue_size():
"""
Notify moderators when the pending queue gets too large.
This helps ensure timely review of submissions.
Returns:
dict: Notification result
"""
from apps.moderation.models import ContentSubmission
from apps.users.models import User
try:
pending_count = ContentSubmission.objects.filter(status='pending').count()
# Threshold for notification (configurable)
threshold = getattr(settings, 'MODERATION_QUEUE_THRESHOLD', 50)
if pending_count >= threshold:
# Get all moderators
moderators = User.objects.filter(role__is_moderator=True)
logger.warning(
f"Moderation queue size ({pending_count}) exceeds threshold ({threshold}). "
f"Notifying {moderators.count()} moderators."
)
# In production, send emails to moderators
# For now, just log
return {
'queue_size': pending_count,
'threshold': threshold,
'notified': moderators.count(),
}
else:
logger.info(f"Moderation queue size ({pending_count}) is within threshold")
return {
'queue_size': pending_count,
'threshold': threshold,
'notified': 0,
}
except Exception as e:
logger.error(f"Error checking moderation queue: {str(e)}")
raise