mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 19:31:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
0
django-backend/apps/moderation/__init__.py
Normal file
0
django-backend/apps/moderation/__init__.py
Normal file
424
django-backend/apps/moderation/admin.py
Normal file
424
django-backend/apps/moderation/admin.py
Normal 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')
|
||||
11
django-backend/apps/moderation/apps.py
Normal file
11
django-backend/apps/moderation/apps.py
Normal 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'
|
||||
454
django-backend/apps/moderation/migrations/0001_initial.py
Normal file
454
django-backend/apps/moderation/migrations/0001_initial.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
478
django-backend/apps/moderation/models.py
Normal file
478
django-backend/apps/moderation/models.py
Normal 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
|
||||
638
django-backend/apps/moderation/services.py
Normal file
638
django-backend/apps/moderation/services.py
Normal 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
|
||||
304
django-backend/apps/moderation/tasks.py
Normal file
304
django-backend/apps/moderation/tasks.py
Normal 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
|
||||
Reference in New Issue
Block a user