mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-27 06:47:05 -05:00
feat: Add blog, media, and support apps, implement ride credits and image API, and remove toplist feature.
This commit is contained in:
@@ -80,51 +80,51 @@ class ModerationConfig(AppConfig):
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# EditSubmission callbacks
|
||||
# EditSubmission callbacks (transitions from CLAIMED state)
|
||||
register_callback(
|
||||
EditSubmission, 'status', 'PENDING', 'APPROVED',
|
||||
EditSubmission, 'status', 'CLAIMED', 'APPROVED',
|
||||
SubmissionApprovedNotification()
|
||||
)
|
||||
register_callback(
|
||||
EditSubmission, 'status', 'PENDING', 'APPROVED',
|
||||
EditSubmission, 'status', 'CLAIMED', 'APPROVED',
|
||||
ModerationCacheInvalidation()
|
||||
)
|
||||
register_callback(
|
||||
EditSubmission, 'status', 'PENDING', 'REJECTED',
|
||||
EditSubmission, 'status', 'CLAIMED', 'REJECTED',
|
||||
SubmissionRejectedNotification()
|
||||
)
|
||||
register_callback(
|
||||
EditSubmission, 'status', 'PENDING', 'REJECTED',
|
||||
EditSubmission, 'status', 'CLAIMED', 'REJECTED',
|
||||
ModerationCacheInvalidation()
|
||||
)
|
||||
register_callback(
|
||||
EditSubmission, 'status', 'PENDING', 'ESCALATED',
|
||||
EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
|
||||
SubmissionEscalatedNotification()
|
||||
)
|
||||
register_callback(
|
||||
EditSubmission, 'status', 'PENDING', 'ESCALATED',
|
||||
EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
|
||||
ModerationCacheInvalidation()
|
||||
)
|
||||
|
||||
# PhotoSubmission callbacks
|
||||
# PhotoSubmission callbacks (transitions from CLAIMED state)
|
||||
register_callback(
|
||||
PhotoSubmission, 'status', 'PENDING', 'APPROVED',
|
||||
PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
|
||||
SubmissionApprovedNotification()
|
||||
)
|
||||
register_callback(
|
||||
PhotoSubmission, 'status', 'PENDING', 'APPROVED',
|
||||
PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
|
||||
ModerationCacheInvalidation()
|
||||
)
|
||||
register_callback(
|
||||
PhotoSubmission, 'status', 'PENDING', 'REJECTED',
|
||||
PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
|
||||
SubmissionRejectedNotification()
|
||||
)
|
||||
register_callback(
|
||||
PhotoSubmission, 'status', 'PENDING', 'REJECTED',
|
||||
PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
|
||||
ModerationCacheInvalidation()
|
||||
)
|
||||
register_callback(
|
||||
PhotoSubmission, 'status', 'PENDING', 'ESCALATED',
|
||||
PhotoSubmission, 'status', 'CLAIMED', 'ESCALATED',
|
||||
SubmissionEscalatedNotification()
|
||||
)
|
||||
|
||||
|
||||
@@ -22,12 +22,29 @@ EDIT_SUBMISSION_STATUSES = [
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
|
||||
'can_transition_to': ['CLAIMED'], # Must be claimed before any action
|
||||
'requires_moderator': True,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLAIMED",
|
||||
label="Claimed",
|
||||
description="Submission has been claimed by a moderator for review",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'user-check',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
# Note: PENDING not included to avoid cycle - unclaim uses direct status update
|
||||
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': True,
|
||||
'is_locked': True # Indicates this submission is locked for editing by others
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="APPROVED",
|
||||
label="Approved",
|
||||
@@ -36,7 +53,7 @@ EDIT_SUBMISSION_STATUSES = [
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 2,
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': False,
|
||||
@@ -52,7 +69,7 @@ EDIT_SUBMISSION_STATUSES = [
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 3,
|
||||
'sort_order': 4,
|
||||
'can_transition_to': [],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': False,
|
||||
@@ -68,7 +85,7 @@ EDIT_SUBMISSION_STATUSES = [
|
||||
'color': 'purple',
|
||||
'icon': 'arrow-up',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 4,
|
||||
'sort_order': 5,
|
||||
'can_transition_to': ['APPROVED', 'REJECTED'],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': True,
|
||||
|
||||
201
backend/apps/moderation/migrations/0009_add_claim_fields.py
Normal file
201
backend/apps/moderation/migrations/0009_add_claim_fields.py
Normal file
@@ -0,0 +1,201 @@
|
||||
# Generated by Django 5.1.6 on 2025-12-26 20:01
|
||||
|
||||
import apps.core.state_machine.fields
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0008_alter_bulkoperation_options_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="editsubmission",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="editsubmission",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="photosubmission",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="photosubmission",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmission",
|
||||
name="claimed_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmission",
|
||||
name="claimed_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Moderator who has claimed this submission for review",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="claimed_edit_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmissionevent",
|
||||
name="claimed_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmissionevent",
|
||||
name="claimed_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Moderator who has claimed this submission for review",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="photosubmission",
|
||||
name="claimed_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="photosubmission",
|
||||
name="claimed_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Moderator who has claimed this submission for review",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="claimed_photo_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="photosubmissionevent",
|
||||
name="claimed_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="photosubmissionevent",
|
||||
name="claimed_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Moderator who has claimed this submission for review",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmissionevent",
|
||||
name="status",
|
||||
field=apps.core.state_machine.fields.RichFSMField(
|
||||
allow_deprecated=False,
|
||||
choice_group="edit_submission_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("CLAIMED", "Claimed"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="photosubmissionevent",
|
||||
name="status",
|
||||
field=apps.core.state_machine.fields.RichFSMField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_submission_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("CLAIMED", "Claimed"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="editsubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="947e1d596310a6e4aad4f30724fbd2e2294d977b",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_2c796",
|
||||
table="moderation_editsubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="editsubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="568618c5161ed78a9c72d751f1c312c64dea3994",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_ab38f",
|
||||
table="moderation_editsubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="photosubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="483cca8949361fe83eb0a964f9f454c5d2c1ac22",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_62865",
|
||||
table="moderation_photosubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="photosubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="82c7edd7b108f50aed0b6b06e44786617792171c",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_9c311",
|
||||
table="moderation_photosubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -143,6 +143,19 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
blank=True, help_text="Notes from the moderator about this submission"
|
||||
)
|
||||
|
||||
# Claim tracking for concurrency control
|
||||
claimed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="claimed_edit_submissions",
|
||||
help_text="Moderator who has claimed this submission for review",
|
||||
)
|
||||
claimed_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When this submission was claimed"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Edit Submission"
|
||||
verbose_name_plural = "Edit Submissions"
|
||||
@@ -188,6 +201,54 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
|
||||
return self.moderator_changes or self.changes
|
||||
|
||||
def claim(self, user: UserType) -> None:
|
||||
"""
|
||||
Claim this submission for review.
|
||||
Transition: PENDING -> CLAIMED
|
||||
|
||||
Args:
|
||||
user: The moderator claiming this submission
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission is not in PENDING state
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != "PENDING":
|
||||
raise ValidationError(
|
||||
f"Cannot claim submission: current status is {self.status}, expected PENDING"
|
||||
)
|
||||
|
||||
self.transition_to_claimed(user=user)
|
||||
self.claimed_by = user
|
||||
self.claimed_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def unclaim(self, user: UserType = None) -> None:
|
||||
"""
|
||||
Release claim on this submission.
|
||||
Transition: CLAIMED -> PENDING
|
||||
|
||||
Args:
|
||||
user: The user initiating the unclaim (for audit)
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission is not in CLAIMED state
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
|
||||
)
|
||||
|
||||
# Set status directly (not via FSM transition to avoid cycle)
|
||||
# This is intentional - the unclaim action is a special "rollback" operation
|
||||
self.status = "PENDING"
|
||||
self.claimed_by = None
|
||||
self.claimed_at = None
|
||||
self.save()
|
||||
|
||||
def approve(self, moderator: UserType, user=None) -> Optional[models.Model]:
|
||||
"""
|
||||
Approve this submission and apply the changes.
|
||||
@@ -204,9 +265,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
ValueError: If submission cannot be approved
|
||||
ValidationError: If the data is invalid
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
approver = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before approval
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot approve submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
model_class = self.content_type.model_class()
|
||||
if not model_class:
|
||||
raise ValueError("Could not resolve model class")
|
||||
@@ -263,9 +332,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
reason: Reason for rejection
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
rejecter = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before rejection
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot reject submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_rejected(user=rejecter)
|
||||
self.handled_by = rejecter
|
||||
@@ -283,9 +360,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
reason: Reason for escalation
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
escalator = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before escalation
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_escalated(user=escalator)
|
||||
self.handled_by = escalator
|
||||
@@ -747,6 +832,19 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
)
|
||||
|
||||
# Claim tracking for concurrency control
|
||||
claimed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="claimed_photo_submissions",
|
||||
help_text="Moderator who has claimed this submission for review",
|
||||
)
|
||||
claimed_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When this submission was claimed"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Photo Submission"
|
||||
verbose_name_plural = "Photo Submissions"
|
||||
@@ -759,6 +857,54 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
|
||||
def __str__(self) -> str:
|
||||
return f"Photo submission by {self.user.username} for {self.content_object}"
|
||||
|
||||
def claim(self, user: UserType) -> None:
|
||||
"""
|
||||
Claim this photo submission for review.
|
||||
Transition: PENDING -> CLAIMED
|
||||
|
||||
Args:
|
||||
user: The moderator claiming this submission
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission is not in PENDING state
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != "PENDING":
|
||||
raise ValidationError(
|
||||
f"Cannot claim submission: current status is {self.status}, expected PENDING"
|
||||
)
|
||||
|
||||
self.transition_to_claimed(user=user)
|
||||
self.claimed_by = user
|
||||
self.claimed_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def unclaim(self, user: UserType = None) -> None:
|
||||
"""
|
||||
Release claim on this photo submission.
|
||||
Transition: CLAIMED -> PENDING
|
||||
|
||||
Args:
|
||||
user: The user initiating the unclaim (for audit)
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission is not in CLAIMED state
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
|
||||
)
|
||||
|
||||
# Set status directly (not via FSM transition to avoid cycle)
|
||||
# This is intentional - the unclaim action is a special "rollback" operation
|
||||
self.status = "PENDING"
|
||||
self.claimed_by = None
|
||||
self.claimed_at = None
|
||||
self.save()
|
||||
|
||||
def approve(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
||||
"""
|
||||
Approve the photo submission.
|
||||
@@ -771,10 +917,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
|
||||
"""
|
||||
from apps.parks.models.media import ParkPhoto
|
||||
from apps.rides.models.media import RidePhoto
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
approver = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before approval
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Determine the correct photo model based on the content type
|
||||
model_class = self.content_type.model_class()
|
||||
if model_class.__name__ == "Park":
|
||||
@@ -810,9 +963,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
|
||||
notes: Rejection reason
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
rejecter = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before rejection
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_rejected(user=rejecter)
|
||||
self.handled_by = rejecter # type: ignore
|
||||
@@ -839,9 +1000,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
|
||||
notes: Escalation reason
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
escalator = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before escalation
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_escalated(user=escalator)
|
||||
self.handled_by = escalator # type: ignore
|
||||
|
||||
@@ -22,6 +22,7 @@ from .models import (
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
EditSubmission,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -65,6 +66,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for EditSubmission with UI metadata for Nuxt frontend."""
|
||||
|
||||
submitted_by = UserBasicSerializer(source="user", read_only=True)
|
||||
claimed_by = UserBasicSerializer(read_only=True)
|
||||
content_type_name = serializers.CharField(
|
||||
source="content_type.model", read_only=True
|
||||
)
|
||||
@@ -91,6 +93,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"rejection_reason",
|
||||
"submitted_by",
|
||||
"reviewed_by",
|
||||
"claimed_by",
|
||||
"claimed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"time_since_created",
|
||||
@@ -100,6 +104,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"submitted_by",
|
||||
"claimed_by",
|
||||
"claimed_at",
|
||||
"status_color",
|
||||
"status_icon",
|
||||
"status_display",
|
||||
@@ -111,6 +117,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""Return hex color based on status for UI badges."""
|
||||
colors = {
|
||||
"PENDING": "#f59e0b", # Amber
|
||||
"CLAIMED": "#3b82f6", # Blue
|
||||
"APPROVED": "#10b981", # Emerald
|
||||
"REJECTED": "#ef4444", # Red
|
||||
"ESCALATED": "#8b5cf6", # Violet
|
||||
@@ -121,6 +128,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""Return Heroicons icon name based on status."""
|
||||
icons = {
|
||||
"PENDING": "heroicons:clock",
|
||||
"CLAIMED": "heroicons:user-circle",
|
||||
"APPROVED": "heroicons:check-circle",
|
||||
"REJECTED": "heroicons:x-circle",
|
||||
"ESCALATED": "heroicons:arrow-up-circle",
|
||||
@@ -148,6 +156,9 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
|
||||
submitted_by_username = serializers.CharField(
|
||||
source="user.username", read_only=True
|
||||
)
|
||||
claimed_by_username = serializers.CharField(
|
||||
source="claimed_by.username", read_only=True, allow_null=True
|
||||
)
|
||||
content_type_name = serializers.CharField(
|
||||
source="content_type.model", read_only=True
|
||||
)
|
||||
@@ -162,6 +173,8 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
|
||||
"content_type_name",
|
||||
"object_id",
|
||||
"submitted_by_username",
|
||||
"claimed_by_username",
|
||||
"claimed_at",
|
||||
"status_color",
|
||||
"status_icon",
|
||||
"created_at",
|
||||
@@ -171,6 +184,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
|
||||
def get_status_color(self, obj) -> str:
|
||||
colors = {
|
||||
"PENDING": "#f59e0b",
|
||||
"CLAIMED": "#3b82f6",
|
||||
"APPROVED": "#10b981",
|
||||
"REJECTED": "#ef4444",
|
||||
"ESCALATED": "#8b5cf6",
|
||||
@@ -180,6 +194,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
|
||||
def get_status_icon(self, obj) -> str:
|
||||
icons = {
|
||||
"PENDING": "heroicons:clock",
|
||||
"CLAIMED": "heroicons:user-circle",
|
||||
"APPROVED": "heroicons:check-circle",
|
||||
"REJECTED": "heroicons:x-circle",
|
||||
"ESCALATED": "heroicons:arrow-up-circle",
|
||||
@@ -911,3 +926,90 @@ class StateLogSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class PhotoSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for PhotoSubmission."""
|
||||
|
||||
submitted_by = UserBasicSerializer(source="user", read_only=True)
|
||||
content_type_name = serializers.CharField(
|
||||
source="content_type.model", read_only=True
|
||||
)
|
||||
photo_url = serializers.SerializerMethodField()
|
||||
|
||||
# UI Metadata
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
status_color = serializers.SerializerMethodField()
|
||||
status_icon = serializers.SerializerMethodField()
|
||||
time_since_created = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PhotoSubmission
|
||||
fields = [
|
||||
"id",
|
||||
"status",
|
||||
"status_display",
|
||||
"status_color",
|
||||
"status_icon",
|
||||
"content_type",
|
||||
"content_type_name",
|
||||
"object_id",
|
||||
"photo",
|
||||
"photo_url",
|
||||
"caption",
|
||||
"date_taken",
|
||||
"submitted_by",
|
||||
"handled_by",
|
||||
"handled_at",
|
||||
"notes",
|
||||
"created_at",
|
||||
"time_since_created",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"submitted_by",
|
||||
"handled_by",
|
||||
"handled_at",
|
||||
"status_display",
|
||||
"status_color",
|
||||
"status_icon",
|
||||
"content_type_name",
|
||||
"photo_url",
|
||||
"time_since_created",
|
||||
]
|
||||
|
||||
def get_photo_url(self, obj) -> str | None:
|
||||
if obj.photo:
|
||||
return obj.photo.image_url
|
||||
return None
|
||||
|
||||
def get_status_color(self, obj) -> str:
|
||||
colors = {
|
||||
"PENDING": "#f59e0b",
|
||||
"APPROVED": "#10b981",
|
||||
"REJECTED": "#ef4444",
|
||||
}
|
||||
return colors.get(obj.status, "#6b7280")
|
||||
|
||||
def get_status_icon(self, obj) -> str:
|
||||
icons = {
|
||||
"PENDING": "heroicons:clock",
|
||||
"APPROVED": "heroicons:check-circle",
|
||||
"REJECTED": "heroicons:x-circle",
|
||||
}
|
||||
return icons.get(obj.status, "heroicons:question-mark-circle")
|
||||
|
||||
def get_time_since_created(self, obj) -> str:
|
||||
"""Human-readable time since creation."""
|
||||
now = timezone.now()
|
||||
diff = now - obj.created_at
|
||||
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} days ago"
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hours ago"
|
||||
else:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes ago"
|
||||
|
||||
|
||||
@@ -4,12 +4,17 @@ Signal handlers for moderation-related FSM state transitions.
|
||||
This module provides signal handlers that execute when moderation
|
||||
models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
|
||||
undergo state transitions.
|
||||
|
||||
Includes:
|
||||
- Transition handlers for approval, rejection, escalation
|
||||
- Real-time broadcasting signal for dashboard updates
|
||||
- Claim/unclaim tracking for concurrency control
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from django.dispatch import receiver, Signal
|
||||
|
||||
from apps.core.state_machine.signals import (
|
||||
post_state_transition,
|
||||
@@ -20,6 +25,71 @@ from apps.core.state_machine.signals import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Custom Signals for Real-Time Broadcasting
|
||||
# ============================================================================
|
||||
|
||||
# Signal emitted when a submission status changes - for real-time UI updates
|
||||
# Arguments:
|
||||
# - sender: The model class (EditSubmission or PhotoSubmission)
|
||||
# - submission_id: The ID of the submission
|
||||
# - submission_type: "edit" or "photo"
|
||||
# - new_status: The new status value
|
||||
# - previous_status: The previous status value
|
||||
# - locked_by: Username of the moderator who claimed it (or None)
|
||||
# - payload: Full payload dictionary for broadcasting
|
||||
submission_status_changed = Signal()
|
||||
|
||||
|
||||
def handle_submission_claimed(instance, source, target, user, context=None, **kwargs):
|
||||
"""
|
||||
Handle submission claim transitions.
|
||||
|
||||
Called when an EditSubmission or PhotoSubmission is claimed by a moderator.
|
||||
Broadcasts the status change for real-time dashboard updates.
|
||||
|
||||
Args:
|
||||
instance: The submission instance.
|
||||
source: The source state.
|
||||
target: The target state.
|
||||
user: The user who claimed.
|
||||
context: Optional TransitionContext.
|
||||
"""
|
||||
if target != 'CLAIMED':
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Submission {instance.pk} claimed by {user.username if user else 'system'}"
|
||||
)
|
||||
|
||||
# Broadcast for real-time dashboard updates
|
||||
_broadcast_submission_status_change(instance, source, target, user)
|
||||
|
||||
|
||||
def handle_submission_unclaimed(instance, source, target, user, context=None, **kwargs):
|
||||
"""
|
||||
Handle submission unclaim transitions (CLAIMED -> PENDING).
|
||||
|
||||
Called when a moderator releases their claim on a submission.
|
||||
|
||||
Args:
|
||||
instance: The submission instance.
|
||||
source: The source state.
|
||||
target: The target state.
|
||||
user: The user who unclaimed.
|
||||
context: Optional TransitionContext.
|
||||
"""
|
||||
if source != 'CLAIMED' or target != 'PENDING':
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Submission {instance.pk} unclaimed by {user.username if user else 'system'}"
|
||||
)
|
||||
|
||||
# Broadcast for real-time dashboard updates
|
||||
_broadcast_submission_status_change(instance, source, target, user)
|
||||
|
||||
|
||||
def handle_submission_approved(instance, source, target, user, context=None, **kwargs):
|
||||
"""
|
||||
Handle submission approval transitions.
|
||||
@@ -255,6 +325,66 @@ def _finalize_bulk_operation(instance, success):
|
||||
logger.warning(f"Failed to finalize bulk operation: {e}")
|
||||
|
||||
|
||||
def _broadcast_submission_status_change(instance, source, target, user):
|
||||
"""
|
||||
Broadcast submission status change for real-time UI updates.
|
||||
|
||||
Emits the submission_status_changed signal with a structured payload
|
||||
that can be consumed by notification systems (Novu, SSE, WebSocket, etc.).
|
||||
|
||||
Payload format:
|
||||
{
|
||||
"submission_id": 123,
|
||||
"submission_type": "edit" | "photo",
|
||||
"new_status": "CLAIMED",
|
||||
"previous_status": "PENDING",
|
||||
"locked_by": "moderator_username" | None,
|
||||
"locked_at": "2024-01-01T12:00:00Z" | None,
|
||||
"changed_by": "username" | None,
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
|
||||
# Determine submission type
|
||||
submission_type = "edit" if isinstance(instance, EditSubmission) else "photo"
|
||||
|
||||
# Build the broadcast payload
|
||||
payload = {
|
||||
"submission_id": instance.pk,
|
||||
"submission_type": submission_type,
|
||||
"new_status": target,
|
||||
"previous_status": source,
|
||||
"locked_by": None,
|
||||
"locked_at": None,
|
||||
"changed_by": user.username if user else None,
|
||||
}
|
||||
|
||||
# Add claim information if available
|
||||
if hasattr(instance, 'claimed_by') and instance.claimed_by:
|
||||
payload["locked_by"] = instance.claimed_by.username
|
||||
if hasattr(instance, 'claimed_at') and instance.claimed_at:
|
||||
payload["locked_at"] = instance.claimed_at.isoformat()
|
||||
|
||||
# Emit the signal for downstream notification handlers
|
||||
submission_status_changed.send(
|
||||
sender=type(instance),
|
||||
submission_id=instance.pk,
|
||||
submission_type=submission_type,
|
||||
new_status=target,
|
||||
previous_status=source,
|
||||
locked_by=payload["locked_by"],
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Broadcast status change: {submission_type}#{instance.pk} "
|
||||
f"{source} -> {target}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to broadcast submission status change: {e}")
|
||||
|
||||
|
||||
# Signal handler registration
|
||||
|
||||
def register_moderation_signal_handlers():
|
||||
@@ -320,7 +450,41 @@ def register_moderation_signal_handlers():
|
||||
handle_bulk_operation_status, stage='post'
|
||||
)
|
||||
|
||||
# Claim/Unclaim handlers for EditSubmission
|
||||
register_transition_handler(
|
||||
EditSubmission, 'PENDING', 'CLAIMED',
|
||||
handle_submission_claimed, stage='post'
|
||||
)
|
||||
register_transition_handler(
|
||||
EditSubmission, 'CLAIMED', 'PENDING',
|
||||
handle_submission_unclaimed, stage='post'
|
||||
)
|
||||
|
||||
# Claim/Unclaim handlers for PhotoSubmission
|
||||
register_transition_handler(
|
||||
PhotoSubmission, 'PENDING', 'CLAIMED',
|
||||
handle_submission_claimed, stage='post'
|
||||
)
|
||||
register_transition_handler(
|
||||
PhotoSubmission, 'CLAIMED', 'PENDING',
|
||||
handle_submission_unclaimed, stage='post'
|
||||
)
|
||||
|
||||
logger.info("Registered moderation signal handlers")
|
||||
|
||||
except ImportError as e:
|
||||
logger.warning(f"Could not register moderation signal handlers: {e}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
'submission_status_changed',
|
||||
'register_moderation_signal_handlers',
|
||||
'handle_submission_approved',
|
||||
'handle_submission_rejected',
|
||||
'handle_submission_escalated',
|
||||
'handle_submission_claimed',
|
||||
'handle_submission_unclaimed',
|
||||
'handle_report_resolved',
|
||||
'handle_queue_completed',
|
||||
'handle_bulk_operation_status',
|
||||
]
|
||||
|
||||
185
backend/apps/moderation/sse.py
Normal file
185
backend/apps/moderation/sse.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Server-Sent Events (SSE) endpoint for real-time moderation dashboard updates.
|
||||
|
||||
This module provides a streaming HTTP response that broadcasts submission status
|
||||
changes to connected moderators in real-time.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
from django.http import StreamingHttpResponse, JsonResponse
|
||||
from django.views import View
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from apps.moderation.permissions import CanViewModerationData
|
||||
from apps.moderation.signals import submission_status_changed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Thread-safe queue for broadcasting events to all connected clients
|
||||
class SSEBroadcaster:
|
||||
"""
|
||||
Manages SSE connections and broadcasts events to all clients.
|
||||
|
||||
Uses a simple subscriber pattern where each connected client
|
||||
gets its own queue of events to consume.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: list[queue.Queue] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
"""Create a new subscriber queue and register it."""
|
||||
client_queue = queue.Queue()
|
||||
with self._lock:
|
||||
self._subscribers.append(client_queue)
|
||||
logger.debug(f"SSE client subscribed. Total clients: {len(self._subscribers)}")
|
||||
return client_queue
|
||||
|
||||
def unsubscribe(self, client_queue: queue.Queue):
|
||||
"""Remove a subscriber queue."""
|
||||
with self._lock:
|
||||
if client_queue in self._subscribers:
|
||||
self._subscribers.remove(client_queue)
|
||||
logger.debug(f"SSE client unsubscribed. Total clients: {len(self._subscribers)}")
|
||||
|
||||
def broadcast(self, event_data: dict):
|
||||
"""Send an event to all connected clients."""
|
||||
with self._lock:
|
||||
for client_queue in self._subscribers:
|
||||
try:
|
||||
client_queue.put_nowait(event_data)
|
||||
except queue.Full:
|
||||
logger.warning("SSE client queue full, dropping event")
|
||||
|
||||
|
||||
# Global broadcaster instance
|
||||
sse_broadcaster = SSEBroadcaster()
|
||||
|
||||
|
||||
def handle_submission_status_changed(sender, payload, **kwargs):
|
||||
"""
|
||||
Signal handler that broadcasts submission status changes to SSE clients.
|
||||
|
||||
Connected to the submission_status_changed signal from signals.py.
|
||||
"""
|
||||
sse_broadcaster.broadcast(payload)
|
||||
logger.debug(f"Broadcast SSE event: {payload.get('submission_type')}#{payload.get('submission_id')}")
|
||||
|
||||
|
||||
# Connect the signal handler
|
||||
submission_status_changed.connect(handle_submission_status_changed)
|
||||
|
||||
|
||||
class ModerationSSEView(APIView):
|
||||
"""
|
||||
Server-Sent Events endpoint for real-time moderation updates.
|
||||
|
||||
Provides a streaming response that sends submission status changes
|
||||
as they occur. Clients should connect to this endpoint and keep
|
||||
the connection open to receive real-time updates.
|
||||
|
||||
Response format (SSE):
|
||||
data: {"submission_id": 1, "new_status": "CLAIMED", ...}
|
||||
|
||||
Usage:
|
||||
const eventSource = new EventSource('/api/moderation/sse/')
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
// Handle update
|
||||
}
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, CanViewModerationData]
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
Establish SSE connection and stream events.
|
||||
|
||||
Sends a heartbeat every 30 seconds to keep the connection alive.
|
||||
"""
|
||||
def event_stream() -> Generator[str, None, None]:
|
||||
client_queue = sse_broadcaster.subscribe()
|
||||
|
||||
try:
|
||||
# Send initial connection event
|
||||
yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established'})}\n\n"
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Wait for event with timeout for heartbeat
|
||||
event = client_queue.get(timeout=30)
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
except queue.Empty:
|
||||
# Send heartbeat to keep connection alive
|
||||
yield f": heartbeat\n\n"
|
||||
except GeneratorExit:
|
||||
# Client disconnected
|
||||
sse_broadcaster.unsubscribe(client_queue)
|
||||
finally:
|
||||
sse_broadcaster.unsubscribe(client_queue)
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
event_stream(),
|
||||
content_type='text/event-stream'
|
||||
)
|
||||
response['Cache-Control'] = 'no-cache'
|
||||
response['X-Accel-Buffering'] = 'no' # Disable nginx buffering
|
||||
response['Connection'] = 'keep-alive'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ModerationSSETestView(APIView):
|
||||
"""
|
||||
Test endpoint to manually trigger an SSE event.
|
||||
|
||||
This is useful for testing the SSE connection without making
|
||||
actual state transitions.
|
||||
|
||||
POST /api/moderation/sse/test/
|
||||
{
|
||||
"submission_id": 1,
|
||||
"submission_type": "edit",
|
||||
"new_status": "CLAIMED",
|
||||
"previous_status": "PENDING"
|
||||
}
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, CanViewModerationData]
|
||||
|
||||
def post(self, request):
|
||||
"""Broadcast a test event."""
|
||||
test_payload = {
|
||||
"submission_id": request.data.get("submission_id", 999),
|
||||
"submission_type": request.data.get("submission_type", "edit"),
|
||||
"new_status": request.data.get("new_status", "CLAIMED"),
|
||||
"previous_status": request.data.get("previous_status", "PENDING"),
|
||||
"locked_by": request.user.username,
|
||||
"locked_at": None,
|
||||
"changed_by": request.user.username,
|
||||
"test": True,
|
||||
}
|
||||
|
||||
sse_broadcaster.broadcast(test_payload)
|
||||
|
||||
return JsonResponse({
|
||||
"status": "ok",
|
||||
"message": f"Test event broadcast to {len(sse_broadcaster._subscribers)} clients",
|
||||
"payload": test_payload,
|
||||
})
|
||||
|
||||
|
||||
__all__ = [
|
||||
'ModerationSSEView',
|
||||
'ModerationSSETestView',
|
||||
'sse_broadcaster',
|
||||
]
|
||||
@@ -16,7 +16,10 @@ from .views import (
|
||||
ModerationActionViewSet,
|
||||
BulkOperationViewSet,
|
||||
UserModerationViewSet,
|
||||
EditSubmissionViewSet,
|
||||
PhotoSubmissionViewSet,
|
||||
)
|
||||
from .sse import ModerationSSEView, ModerationSSETestView
|
||||
from apps.core.views.views import FSMTransitionView
|
||||
|
||||
|
||||
@@ -68,9 +71,16 @@ router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
|
||||
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
|
||||
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
|
||||
router.register(r"users", UserModerationViewSet, basename="user-moderation")
|
||||
# EditSubmission - register under both names for compatibility
|
||||
router.register(r"submissions", EditSubmissionViewSet, basename="submissions")
|
||||
router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-submissions")
|
||||
# PhotoSubmission - register under both names for compatibility
|
||||
router.register(r"photos", PhotoSubmissionViewSet, basename="photos")
|
||||
router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions")
|
||||
|
||||
app_name = "moderation"
|
||||
|
||||
|
||||
# FSM transition convenience URLs for moderation models
|
||||
fsm_transition_patterns = [
|
||||
# EditSubmission transitions
|
||||
@@ -161,9 +171,17 @@ html_patterns = [
|
||||
path("history/", HistoryPageView.as_view(), name="history"),
|
||||
]
|
||||
|
||||
# SSE endpoints for real-time updates
|
||||
sse_patterns = [
|
||||
path("sse/", ModerationSSEView.as_view(), name="moderation-sse"),
|
||||
path("sse/test/", ModerationSSETestView.as_view(), name="moderation-sse-test"),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
# HTML page views
|
||||
*html_patterns,
|
||||
# SSE endpoints
|
||||
*sse_patterns,
|
||||
# Include all router URLs (API endpoints)
|
||||
path("api/", include(router.urls)),
|
||||
# FSM transition convenience endpoints
|
||||
|
||||
@@ -34,6 +34,8 @@ from .models import (
|
||||
ModerationQueue,
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
EditSubmission,
|
||||
PhotoSubmission,
|
||||
)
|
||||
from .serializers import (
|
||||
ModerationReportSerializer,
|
||||
@@ -47,6 +49,9 @@ from .serializers import (
|
||||
BulkOperationSerializer,
|
||||
CreateBulkOperationSerializer,
|
||||
UserModerationProfileSerializer,
|
||||
EditSubmissionSerializer,
|
||||
EditSubmissionListSerializer,
|
||||
PhotoSubmissionSerializer,
|
||||
)
|
||||
from .filters import (
|
||||
ModerationReportFilter,
|
||||
@@ -1166,6 +1171,28 @@ class UserModerationViewSet(viewsets.ViewSet):
|
||||
# Default serializer for schema generation
|
||||
serializer_class = UserModerationProfileSerializer
|
||||
|
||||
def list(self, request):
|
||||
"""Search for users to moderate."""
|
||||
query = request.query_params.get("q", "")
|
||||
if not query:
|
||||
return Response([])
|
||||
|
||||
queryset = User.objects.filter(
|
||||
Q(username__icontains=query) | Q(email__icontains=query)
|
||||
)[:20]
|
||||
|
||||
users_data = [
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": getattr(user, "role", "USER"),
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
for user in queryset
|
||||
]
|
||||
return Response(users_data)
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
"""Get moderation profile for a specific user."""
|
||||
try:
|
||||
@@ -1367,3 +1394,345 @@ class UserModerationViewSet(viewsets.ViewSet):
|
||||
}
|
||||
|
||||
return Response(stats_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Submission ViewSets
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing edit submissions.
|
||||
|
||||
Includes claim/unclaim endpoints with concurrency protection using
|
||||
database row locking (select_for_update) to prevent race conditions.
|
||||
"""
|
||||
queryset = EditSubmission.objects.all()
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
search_fields = ["reason", "changes"]
|
||||
ordering_fields = ["created_at", "status"]
|
||||
ordering = ["-created_at"]
|
||||
permission_classes = [CanViewModerationData]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return EditSubmissionListSerializer
|
||||
return EditSubmissionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
status = self.request.query_params.get("status")
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
# User filter
|
||||
user_id = self.request.query_params.get("user")
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def claim(self, request, pk=None):
|
||||
"""
|
||||
Claim a submission for review with concurrency protection.
|
||||
|
||||
Uses select_for_update() to acquire a database row lock,
|
||||
preventing race conditions when multiple moderators try to
|
||||
claim the same submission simultaneously.
|
||||
|
||||
Returns:
|
||||
200: Submission successfully claimed
|
||||
404: Submission not found
|
||||
409: Submission already claimed or being claimed by another moderator
|
||||
400: Invalid state for claiming
|
||||
"""
|
||||
from django.db import transaction, DatabaseError
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
# Lock the row for update - other transactions will fail immediately
|
||||
submission = EditSubmission.objects.select_for_update(nowait=True).get(pk=pk)
|
||||
except EditSubmission.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Submission not found"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except DatabaseError:
|
||||
# Row is already locked by another transaction
|
||||
return Response(
|
||||
{"error": "Submission is being claimed by another moderator. Please try again."},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
# Check if already claimed
|
||||
if submission.status == "CLAIMED":
|
||||
return Response(
|
||||
{
|
||||
"error": "Submission already claimed",
|
||||
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
|
||||
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
# Check if in valid state for claiming
|
||||
if submission.status != "PENDING":
|
||||
return Response(
|
||||
{"error": f"Cannot claim submission in {submission.status} state"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
submission.claim(user=request.user)
|
||||
log_business_event(
|
||||
logger,
|
||||
event_type="submission_claimed",
|
||||
message=f"EditSubmission {submission.id} claimed by {request.user.username}",
|
||||
context={
|
||||
"model": "EditSubmission",
|
||||
"object_id": submission.id,
|
||||
"claimed_by": request.user.username,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def unclaim(self, request, pk=None):
|
||||
"""
|
||||
Release claim on a submission.
|
||||
|
||||
Only the claiming moderator or an admin can unclaim a submission.
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
submission = self.get_object()
|
||||
|
||||
# Only the claiming user or an admin can unclaim
|
||||
if submission.claimed_by != request.user and not request.user.is_staff:
|
||||
return Response(
|
||||
{"error": "Only the claiming moderator or an admin can unclaim"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
if submission.status != "CLAIMED":
|
||||
return Response(
|
||||
{"error": "Submission is not claimed"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
submission.unclaim(user=request.user)
|
||||
log_business_event(
|
||||
logger,
|
||||
event_type="submission_unclaimed",
|
||||
message=f"EditSubmission {submission.id} unclaimed by {request.user.username}",
|
||||
context={
|
||||
"model": "EditSubmission",
|
||||
"object_id": submission.id,
|
||||
"unclaimed_by": request.user.username,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def approve(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
user = request.user
|
||||
|
||||
try:
|
||||
submission.approve(moderator=user)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def reject(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
user = request.user
|
||||
reason = request.data.get("reason", "")
|
||||
|
||||
try:
|
||||
submission.reject(moderator=user, reason=reason)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def escalate(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
user = request.user
|
||||
reason = request.data.get("reason", "")
|
||||
|
||||
try:
|
||||
submission.escalate(moderator=user, reason=reason)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing photo submissions.
|
||||
|
||||
Includes claim/unclaim endpoints with concurrency protection using
|
||||
database row locking (select_for_update) to prevent race conditions.
|
||||
"""
|
||||
queryset = PhotoSubmission.objects.all()
|
||||
serializer_class = PhotoSubmissionSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
search_fields = ["caption", "notes"]
|
||||
ordering_fields = ["created_at", "status"]
|
||||
ordering = ["-created_at"]
|
||||
permission_classes = [CanViewModerationData]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
status = self.request.query_params.get("status")
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
# User filter
|
||||
user_id = self.request.query_params.get("user")
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def claim(self, request, pk=None):
|
||||
"""
|
||||
Claim a photo submission for review with concurrency protection.
|
||||
|
||||
Uses select_for_update() to acquire a database row lock.
|
||||
"""
|
||||
from django.db import transaction, DatabaseError
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
|
||||
except PhotoSubmission.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Submission not found"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except DatabaseError:
|
||||
return Response(
|
||||
{"error": "Submission is being claimed by another moderator. Please try again."},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
if submission.status == "CLAIMED":
|
||||
return Response(
|
||||
{
|
||||
"error": "Submission already claimed",
|
||||
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
|
||||
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
if submission.status != "PENDING":
|
||||
return Response(
|
||||
{"error": f"Cannot claim submission in {submission.status} state"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
submission.claim(user=request.user)
|
||||
log_business_event(
|
||||
logger,
|
||||
event_type="submission_claimed",
|
||||
message=f"PhotoSubmission {submission.id} claimed by {request.user.username}",
|
||||
context={
|
||||
"model": "PhotoSubmission",
|
||||
"object_id": submission.id,
|
||||
"claimed_by": request.user.username,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def unclaim(self, request, pk=None):
|
||||
"""Release claim on a photo submission."""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
submission = self.get_object()
|
||||
|
||||
if submission.claimed_by != request.user and not request.user.is_staff:
|
||||
return Response(
|
||||
{"error": "Only the claiming moderator or an admin can unclaim"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
if submission.status != "CLAIMED":
|
||||
return Response(
|
||||
{"error": "Submission is not claimed"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
submission.unclaim(user=request.user)
|
||||
log_business_event(
|
||||
logger,
|
||||
event_type="submission_unclaimed",
|
||||
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
|
||||
context={
|
||||
"model": "PhotoSubmission",
|
||||
"object_id": submission.id,
|
||||
"unclaimed_by": request.user.username,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def approve(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
user = request.user
|
||||
notes = request.data.get("notes", "")
|
||||
|
||||
try:
|
||||
submission.approve(moderator=user, notes=notes)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def reject(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
user = request.user
|
||||
notes = request.data.get("notes", "")
|
||||
|
||||
try:
|
||||
submission.reject(moderator=user, notes=notes)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def escalate(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
user = request.user
|
||||
notes = request.data.get("notes", "")
|
||||
|
||||
try:
|
||||
submission.escalate(moderator=user, notes=notes)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user