Based on the git diff provided, here's a concise and descriptive commit message:

feat: add passkey authentication and enhance user preferences

- Add passkey login security event type with fingerprint icon
- Include request and site context in email confirmation for backend
- Add user_id exact match filter to prevent incorrect user lookups
- Enable PATCH method for updating user preferences via API
- Add moderation_preferences support to user settings
- Optimize ticket queries with select_related and prefetch_related

This commit introduces passkey authentication tracking, improves user
profile filtering accuracy, and extends the preferences API to support
updates. Query optimizations reduce database hits for ticket listings.
This commit is contained in:
pacnpal
2026-01-12 19:13:05 -05:00
parent 2b66814d82
commit d631f3183c
56 changed files with 5860 additions and 264 deletions

View File

@@ -124,6 +124,20 @@ SUBMISSION_TYPES = [
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="PHOTO",
label="Photo Submission",
description="Photo upload for existing content",
metadata={
"color": "purple",
"icon": "photograph",
"css_class": "bg-purple-100 text-purple-800 border-purple-200",
"sort_order": 3,
"requires_existing_object": True,
"complexity_level": "low",
},
category=ChoiceCategory.CLASSIFICATION,
),
]
# ============================================================================
@@ -934,6 +948,122 @@ BULK_OPERATION_TYPES = [
# PhotoSubmission uses the same STATUS_CHOICES as EditSubmission
PHOTO_SUBMISSION_STATUSES = EDIT_SUBMISSION_STATUSES
# ============================================================================
# ModerationAuditLog Action Choices
# ============================================================================
MODERATION_AUDIT_ACTIONS = [
RichChoice(
value="approved",
label="Approved",
description="Submission was approved by moderator",
metadata={
"color": "green",
"icon": "check-circle",
"css_class": "bg-green-100 text-green-800",
"sort_order": 1,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="rejected",
label="Rejected",
description="Submission was rejected by moderator",
metadata={
"color": "red",
"icon": "x-circle",
"css_class": "bg-red-100 text-red-800",
"sort_order": 2,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="claimed",
label="Claimed",
description="Submission was claimed by moderator",
metadata={
"color": "blue",
"icon": "user-check",
"css_class": "bg-blue-100 text-blue-800",
"sort_order": 3,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="unclaimed",
label="Unclaimed",
description="Submission was released by moderator",
metadata={
"color": "gray",
"icon": "user-minus",
"css_class": "bg-gray-100 text-gray-800",
"sort_order": 4,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="escalated",
label="Escalated",
description="Submission was escalated for higher-level review",
metadata={
"color": "purple",
"icon": "arrow-up",
"css_class": "bg-purple-100 text-purple-800",
"sort_order": 5,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="converted_to_edit",
label="Converted to Edit",
description="Photo submission was converted to an edit submission",
metadata={
"color": "indigo",
"icon": "refresh",
"css_class": "bg-indigo-100 text-indigo-800",
"sort_order": 6,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="status_changed",
label="Status Changed",
description="Submission status was changed",
metadata={
"color": "yellow",
"icon": "refresh-cw",
"css_class": "bg-yellow-100 text-yellow-800",
"sort_order": 7,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="notes_added",
label="Notes Added",
description="Moderator notes were added to submission",
metadata={
"color": "blue",
"icon": "edit",
"css_class": "bg-blue-100 text-blue-800",
"sort_order": 8,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="auto_approved",
label="Auto Approved",
description="Submission was auto-approved by the system",
metadata={
"color": "green",
"icon": "zap",
"css_class": "bg-green-100 text-green-800",
"sort_order": 9,
"is_system_action": True,
},
category=ChoiceCategory.CLASSIFICATION,
),
]
# ============================================================================
# Choice Registration
# ============================================================================
@@ -958,3 +1088,6 @@ register_choices("bulk_operation_types", BULK_OPERATION_TYPES, "moderation", "Bu
register_choices(
"photo_submission_statuses", PHOTO_SUBMISSION_STATUSES, "moderation", "Photo submission status options"
)
register_choices(
"moderation_audit_actions", MODERATION_AUDIT_ACTIONS, "moderation", "Moderation audit log action types"
)

View File

@@ -27,12 +27,10 @@ User = get_user_model()
class ModerationReportFilter(django_filters.FilterSet):
"""Filter for ModerationReport model."""
# Status filters
status = django_filters.ChoiceFilter(
choices=lambda: [
(choice.value, choice.label) for choice in get_choices("moderation_report_statuses", "moderation")
],
help_text="Filter by report status",
# Status filters - use method filter for case-insensitive matching
status = django_filters.CharFilter(
method="filter_status",
help_text="Filter by report status (case-insensitive)",
)
# Priority filters
@@ -144,6 +142,19 @@ class ModerationReportFilter(django_filters.FilterSet):
return queryset.exclude(resolution_action__isnull=True, resolution_action="")
return queryset.filter(Q(resolution_action__isnull=True) | Q(resolution_action=""))
def filter_status(self, queryset, name, value):
"""Filter by status with case-insensitive matching."""
if not value:
return queryset
# Normalize to uppercase for matching against RichChoice values
normalized_value = value.upper()
# Validate against registered choices
valid_values = {choice.value for choice in get_choices("moderation_report_statuses", "moderation")}
if normalized_value in valid_values:
return queryset.filter(status=normalized_value)
# If not valid, return empty queryset (invalid filter value)
return queryset.none()
class ModerationQueueFilter(django_filters.FilterSet):
"""Filter for ModerationQueue model."""

View File

@@ -0,0 +1,96 @@
# Generated by Django 5.2.10 on 2026-01-11 18:06
import apps.core.choices.fields
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0009_add_claim_fields"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ModerationAuditLog",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
(
"action",
apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="moderation_audit_actions",
choices=[
("approved", "Approved"),
("rejected", "Rejected"),
("claimed", "Claimed"),
("unclaimed", "Unclaimed"),
("escalated", "Escalated"),
("converted_to_edit", "Converted to Edit"),
("status_changed", "Status Changed"),
("notes_added", "Notes Added"),
("auto_approved", "Auto Approved"),
],
db_index=True,
domain="moderation",
help_text="The action that was performed",
max_length=50,
),
),
(
"previous_status",
models.CharField(blank=True, help_text="Status before the action", max_length=50, null=True),
),
(
"new_status",
models.CharField(blank=True, help_text="Status after the action", max_length=50, null=True),
),
("notes", models.TextField(blank=True, help_text="Notes or comments about the action", null=True)),
(
"is_system_action",
models.BooleanField(
db_index=True, default=False, help_text="Whether this was an automated system action"
),
),
("is_test_data", models.BooleanField(default=False, help_text="Whether this is test data")),
(
"created_at",
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When this action was performed"),
),
(
"moderator",
models.ForeignKey(
blank=True,
help_text="The moderator who performed the action (null for system actions)",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderation_audit_logs",
to=settings.AUTH_USER_MODEL,
),
),
(
"submission",
models.ForeignKey(
help_text="The submission this audit log entry is for",
on_delete=django.db.models.deletion.CASCADE,
related_name="audit_logs",
to="moderation.editsubmission",
),
),
],
options={
"verbose_name": "Moderation Audit Log",
"verbose_name_plural": "Moderation Audit Logs",
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["submission", "created_at"], name="moderation__submiss_2f5e56_idx"),
models.Index(fields=["moderator", "created_at"], name="moderation__moderat_591c14_idx"),
models.Index(fields=["action", "created_at"], name="moderation__action_a98c47_idx"),
],
},
),
]

View File

@@ -0,0 +1,99 @@
# Generated by Django 5.2.10 on 2026-01-12 23:00
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("django_cloudflareimages_toolkit", "0001_initial"),
("moderation", "0010_moderationauditlog"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="update_update",
),
migrations.AddField(
model_name="editsubmission",
name="caption",
field=models.CharField(blank=True, help_text="Photo caption", max_length=255),
),
migrations.AddField(
model_name="editsubmission",
name="date_taken",
field=models.DateField(blank=True, help_text="Date the photo was taken", null=True),
),
migrations.AddField(
model_name="editsubmission",
name="photo",
field=models.ForeignKey(
blank=True,
help_text="Photo for photo submissions",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AddField(
model_name="editsubmissionevent",
name="caption",
field=models.CharField(blank=True, help_text="Photo caption", max_length=255),
),
migrations.AddField(
model_name="editsubmissionevent",
name="date_taken",
field=models.DateField(blank=True, help_text="Date the photo was taken", null=True),
),
migrations.AddField(
model_name="editsubmissionevent",
name="photo",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Photo for photo submissions",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_editsubmissionevent" ("caption", "changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."caption", NEW."changes", 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."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="e9aed25fe6389b113919e729543a9abe20d9f30c",
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" ("caption", "changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."caption", NEW."changes", 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."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="070083ba4d2d459067d9c3a90356a759f6262a90",
operation="UPDATE",
pgid="pgtrigger_update_update_ab38f",
table="moderation_editsubmission",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,64 @@
"""
Data migration to copy PhotoSubmission data to EditSubmission.
This migration copies all PhotoSubmission rows to EditSubmission with submission_type="PHOTO".
After this migration, PhotoSubmission model can be safely removed.
"""
from django.db import migrations
def migrate_photo_submissions(apps, schema_editor):
"""Copy PhotoSubmission data to EditSubmission."""
PhotoSubmission = apps.get_model("moderation", "PhotoSubmission")
EditSubmission = apps.get_model("moderation", "EditSubmission")
ContentType = apps.get_model("contenttypes", "ContentType")
# Get EditSubmission content type for reference
edit_submission_ct = ContentType.objects.get_for_model(EditSubmission)
migrated = 0
for photo_sub in PhotoSubmission.objects.all():
# Create EditSubmission from PhotoSubmission
EditSubmission.objects.create(
user=photo_sub.user,
content_type=photo_sub.content_type,
object_id=photo_sub.object_id,
submission_type="PHOTO",
changes={}, # Photos don't have field changes
reason="Photo submission", # Default reason
status=photo_sub.status,
created_at=photo_sub.created_at,
handled_by=photo_sub.handled_by,
handled_at=photo_sub.handled_at,
notes=photo_sub.notes,
claimed_by=photo_sub.claimed_by,
claimed_at=photo_sub.claimed_at,
# Photo-specific fields
photo=photo_sub.photo,
caption=photo_sub.caption,
date_taken=photo_sub.date_taken,
)
migrated += 1
if migrated:
print(f"Migrated {migrated} PhotoSubmission(s) to EditSubmission")
def reverse_migration(apps, schema_editor):
"""Remove migrated EditSubmissions with type PHOTO."""
EditSubmission = apps.get_model("moderation", "EditSubmission")
deleted, _ = EditSubmission.objects.filter(submission_type="PHOTO").delete()
if deleted:
print(f"Deleted {deleted} PHOTO EditSubmission(s)")
class Migration(migrations.Migration):
dependencies = [
("moderation", "0011_add_photo_fields_to_editsubmission"),
]
operations = [
migrations.RunPython(migrate_photo_submissions, reverse_migration),
]

View File

@@ -18,6 +18,7 @@ are registered via the callback configuration defined in each model's Meta class
from datetime import timedelta
from typing import Any
import uuid
import pghistory
from django.conf import settings
@@ -114,6 +115,25 @@ class EditSubmission(StateMachineMixin, TrackedModel):
help_text="Moderator's edited version of the changes before approval",
)
# Photo submission fields (only used when submission_type="PHOTO")
photo = models.ForeignKey(
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Photo for photo submissions",
)
caption = models.CharField(
max_length=255,
blank=True,
help_text="Photo caption",
)
date_taken = models.DateField(
null=True,
blank=True,
help_text="Date the photo was taken",
)
# Metadata
reason = models.TextField(help_text="Why this edit/addition is needed")
source = models.TextField(blank=True, help_text="Source of information (if applicable)")
@@ -190,6 +210,122 @@ 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 _get_model_class_for_item_type(self, item_type: str):
"""
Map item_type string to the corresponding Django model class.
Args:
item_type: Type string from frontend (e.g., 'manufacturer', 'park', 'ride_model')
Returns:
Model class for the item type
"""
# Lazy imports to avoid circular dependencies
from apps.parks.models import Company, Park
from apps.rides.models import Ride, RideModel
type_map = {
# Company types (all map to Company model)
'manufacturer': Company,
'designer': Company,
'operator': Company,
'property_owner': Company,
'company': Company,
# Entity types
'park': Park,
'ride': Ride,
'ride_model': RideModel,
}
model_class = type_map.get(item_type.lower())
if not model_class:
raise ValueError(f"Unknown item_type: {item_type}")
return model_class
def _process_composite_items(self, composite_items: list[dict[str, Any]]) -> dict[int, Any]:
"""
Process composite submission items (dependencies) before the primary entity.
Args:
composite_items: List of dependency items from frontend's submissionItems array
Each item has: item_type, action_type, item_data, order_index, depends_on
Returns:
Dictionary mapping order_index -> created entity ID for resolving temp references
"""
from django.db import transaction
# Sort by order_index to ensure proper dependency order
sorted_items = sorted(composite_items, key=lambda x: x.get('order_index', 0))
# Map of order_index -> created entity ID
created_entities: dict[int, Any] = {}
with transaction.atomic():
for item in sorted_items:
item_type = item.get('item_type', '')
item_data = item.get('item_data', {})
order_index = item.get('order_index', 0)
if not item_type or not item_data:
continue
# Get the model class for this item type
model_class = self._get_model_class_for_item_type(item_type)
# Clean up internal fields not needed for model creation
clean_data = {}
for key, value in item_data.items():
# Skip internal/temp fields
if key.startswith('_temp_') or key == 'images' or key == '_composite_items':
continue
# Skip fields with None or 'temp-' values
if value is None or (isinstance(value, str) and value.startswith('temp-')):
continue
clean_data[key] = value
# Resolve _temp_*_ref fields to actual entity IDs from previously created entities
for key, value in item_data.items():
if key.startswith('_temp_') and key.endswith('_ref'):
# Extract the field name: _temp_manufacturer_ref -> manufacturer_id
field_name = key[6:-4] + '_id' # Remove '_temp_' prefix and '_ref' suffix
ref_order_index = value
if isinstance(ref_order_index, int) and ref_order_index in created_entities:
clean_data[field_name] = created_entities[ref_order_index]
# Resolve foreign keys to model instances
resolved_data = {}
for field_name, value in clean_data.items():
try:
field = model_class._meta.get_field(field_name)
if isinstance(field, models.ForeignKey) and value is not None:
try:
related_obj = field.related_model.objects.get(pk=value)
resolved_data[field_name] = related_obj
except ObjectDoesNotExist:
# Skip invalid FK references
continue
else:
resolved_data[field_name] = value
except:
# Field doesn't exist on model, still try to include it
resolved_data[field_name] = value
# Create the entity
try:
obj = model_class(**resolved_data)
obj.full_clean()
obj.save()
created_entities[order_index] = obj.pk
except Exception as e:
# Log but continue - don't fail the whole submission for one dependency
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to create composite item {item_type}: {e}")
continue
return created_entities
def claim(self, user: UserType) -> None:
"""
Claim this submission for review.
@@ -266,6 +402,28 @@ class EditSubmission(StateMachineMixin, TrackedModel):
raise ValueError("Could not resolve model class")
final_changes = self._get_final_changes()
# Process composite items (dependencies) first if present
created_entity_ids: dict[int, Any] = {}
if '_composite_items' in final_changes:
composite_items = final_changes.pop('_composite_items')
if composite_items and isinstance(composite_items, list):
created_entity_ids = self._process_composite_items(composite_items)
# Resolve _temp_*_ref fields in the primary entity using created dependency IDs
for key in list(final_changes.keys()):
if key.startswith('_temp_') and key.endswith('_ref'):
# Extract field name: _temp_manufacturer_ref -> manufacturer_id
field_name = key[6:-4] + '_id' # Remove '_temp_' and '_ref'
ref_order_index = final_changes.pop(key)
if isinstance(ref_order_index, int) and ref_order_index in created_entity_ids:
final_changes[field_name] = created_entity_ids[ref_order_index]
# Remove any remaining internal fields
keys_to_remove = [k for k in final_changes.keys() if k.startswith('_')]
for key in keys_to_remove:
final_changes.pop(key, None)
resolved_changes = self._resolve_foreign_keys(final_changes)
try:
@@ -295,6 +453,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
return obj
except Exception as e:
# On error, record the issue and attempt rejection transition
self.notes = f"Approval failed: {str(e)}"
@@ -900,3 +1059,82 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
self.handled_at = timezone.now()
self.notes = notes
self.save()
class ModerationAuditLog(models.Model):
"""
Audit log for moderation actions.
Records all moderation activities including approvals, rejections,
claims, escalations, and conversions for accountability and analytics.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
submission = models.ForeignKey(
EditSubmission,
on_delete=models.CASCADE,
related_name="audit_logs",
help_text="The submission this audit log entry is for",
)
moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="moderation_audit_logs",
help_text="The moderator who performed the action (null for system actions)",
)
action = RichChoiceField(
choice_group="moderation_audit_actions",
domain="moderation",
max_length=50,
db_index=True,
help_text="The action that was performed",
)
previous_status = models.CharField(
max_length=50,
blank=True,
null=True,
help_text="Status before the action",
)
new_status = models.CharField(
max_length=50,
blank=True,
null=True,
help_text="Status after the action",
)
notes = models.TextField(
blank=True,
null=True,
help_text="Notes or comments about the action",
)
is_system_action = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this was an automated system action",
)
is_test_data = models.BooleanField(
default=False,
help_text="Whether this is test data",
)
# Timestamps
created_at = models.DateTimeField(
auto_now_add=True,
db_index=True,
help_text="When this action was performed",
)
class Meta:
ordering = ["-created_at"]
verbose_name = "Moderation Audit Log"
verbose_name_plural = "Moderation Audit Logs"
indexes = [
models.Index(fields=["submission", "created_at"]),
models.Index(fields=["moderator", "created_at"]),
models.Index(fields=["action", "created_at"]),
]
def __str__(self) -> str:
actor = self.moderator.username if self.moderator else "System"
return f"{self.get_action_display()} by {actor} on {self.submission_id}"

View File

@@ -100,6 +100,10 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"claimed_at",
"created_at",
"time_since_created",
# Photo fields (used when submission_type="PHOTO")
"photo",
"caption",
"date_taken",
]
read_only_fields = [
"id",
@@ -1062,3 +1066,45 @@ class PhotoSubmissionSerializer(serializers.ModelSerializer):
else:
minutes = diff.seconds // 60
return f"{minutes} minutes ago"
# ============================================================================
# Moderation Audit Log Serializers
# ============================================================================
class ModerationAuditLogSerializer(serializers.ModelSerializer):
"""Serializer for moderation audit logs."""
moderator = UserBasicSerializer(read_only=True)
moderator_username = serializers.CharField(source="moderator.username", read_only=True, allow_null=True)
submission_content_type = serializers.CharField(source="submission.content_type.model", read_only=True)
action_display = serializers.CharField(source="get_action_display", read_only=True)
class Meta:
from .models import ModerationAuditLog
model = ModerationAuditLog
fields = [
"id",
"submission",
"submission_content_type",
"moderator",
"moderator_username",
"action",
"action_display",
"previous_status",
"new_status",
"notes",
"is_system_action",
"is_test_data",
"created_at",
]
read_only_fields = [
"id",
"created_at",
"moderator",
"moderator_username",
"submission_content_type",
"action_display",
]

View File

@@ -5,6 +5,7 @@ Following Django styleguide pattern for business logic encapsulation.
from typing import Any
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import QuerySet
from django.utils import timezone
@@ -340,9 +341,13 @@ class ModerationService:
Dictionary with submission info and queue status
"""
with transaction.atomic():
# Create the photo submission
submission = PhotoSubmission(
content_object=content_object,
# Create the photo submission using unified EditSubmission with PHOTO type
submission = EditSubmission(
content_type=ContentType.objects.get_for_model(content_object),
object_id=content_object.pk,
submission_type="PHOTO",
changes={}, # Photos don't have field changes
reason="Photo submission",
photo=photo,
caption=caption,
date_taken=date_taken,

View File

@@ -95,7 +95,7 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
source="task",
)
# Process PhotoSubmissions with stale claims
# Process PhotoSubmissions with stale claims (legacy model - until removed)
stale_photo_ids = list(
PhotoSubmission.objects.filter(
status="CLAIMED",
@@ -132,6 +132,43 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
source="task",
)
# Also process EditSubmission with PHOTO type (new unified model)
stale_photo_edit_ids = list(
EditSubmission.objects.filter(
submission_type="PHOTO",
status="CLAIMED",
claimed_at__lt=cutoff_time,
).values_list("id", flat=True)
)
for submission_id in stale_photo_edit_ids:
result["edit_submissions"]["processed"] += 1 # Count with edit submissions
try:
with transaction.atomic():
submission = EditSubmission.objects.select_for_update(skip_locked=True).filter(
id=submission_id,
status="CLAIMED",
).first()
if submission:
_release_claim(submission)
result["edit_submissions"]["released"] += 1
logger.info(
"Released stale claim on PHOTO EditSubmission %s (claimed by %s at %s)",
submission_id,
submission.claimed_by,
submission.claimed_at,
)
except Exception as e:
result["edit_submissions"]["failed"] += 1
error_msg = f"PHOTO EditSubmission {submission_id}: {str(e)}"
result["failures"].append(error_msg)
capture_and_log(
e,
f"Release stale claim on PHOTO EditSubmission {submission_id}",
source="task",
)
total_released = result["edit_submissions"]["released"] + result["photo_submissions"]["released"]
total_failed = result["edit_submissions"]["failed"] + result["photo_submissions"]["failed"]

View File

@@ -2131,12 +2131,14 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing photo submissions.
Now queries EditSubmission with submission_type="PHOTO" for unified model.
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
# Use EditSubmission filtered by PHOTO type instead of separate PhotoSubmission model
queryset = EditSubmission.objects.filter(submission_type="PHOTO")
serializer_class = EditSubmissionSerializer # Use unified serializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["caption", "notes"]
ordering_fields = ["created_at", "status"]
@@ -2144,10 +2146,10 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
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)
queryset = EditSubmission.objects.filter(submission_type="PHOTO")
status_param = self.request.query_params.get("status")
if status_param:
queryset = queryset.filter(status=status_param)
# User filter
user_id = self.request.query_params.get("user")
@@ -2168,8 +2170,9 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
with transaction.atomic():
try:
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except PhotoSubmission.DoesNotExist:
# Use EditSubmission filtered by PHOTO type
submission = EditSubmission.objects.filter(submission_type="PHOTO").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:
return Response(
@@ -2198,9 +2201,10 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
log_business_event(
logger,
event_type="submission_claimed",
message=f"PhotoSubmission {submission.id} claimed by {request.user.username}",
message=f"Photo EditSubmission {submission.id} claimed by {request.user.username}",
context={
"model": "PhotoSubmission",
"model": "EditSubmission",
"submission_type": "PHOTO",
"object_id": submission.id,
"claimed_by": request.user.username,
},
@@ -2767,3 +2771,55 @@ class ModerationStatsView(APIView):
}
return Response(stats_data)
# ============================================================================
# Moderation Audit Log ViewSet
# ============================================================================
class ModerationAuditLogViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for viewing moderation audit logs.
Provides read-only access to moderation action history for auditing
and accountability purposes.
"""
from .models import ModerationAuditLog
from .serializers import ModerationAuditLogSerializer
queryset = ModerationAuditLog.objects.select_related(
"submission", "submission__content_type", "moderator"
).all()
serializer_class = ModerationAuditLogSerializer
permission_classes = [IsAdminOrSuperuser]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["action", "is_system_action", "is_test_data"]
search_fields = ["notes"]
ordering_fields = ["created_at", "action"]
ordering = ["-created_at"]
def get_queryset(self):
queryset = super().get_queryset()
# Filter by submission ID
submission_id = self.request.query_params.get("submission_id")
if submission_id:
queryset = queryset.filter(submission_id=submission_id)
# Filter by moderator ID
moderator_id = self.request.query_params.get("moderator_id")
if moderator_id:
queryset = queryset.filter(moderator_id=moderator_id)
# Date range filtering
start_date = self.request.query_params.get("start_date")
end_date = self.request.query_params.get("end_date")
if start_date:
queryset = queryset.filter(created_at__gte=start_date)
if end_date:
queryset = queryset.filter(created_at__lte=end_date)
return queryset