mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-04-01 11:58:22 -04:00
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:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user