feat: Implement email change cancellation, location search, and admin anomaly detection endpoints.

This commit is contained in:
pacnpal
2026-01-05 14:31:04 -05:00
parent a801813dcf
commit 2b7bb4dfaa
13 changed files with 2074 additions and 22 deletions

View File

@@ -15,6 +15,7 @@ from apps.core.views.views import FSMTransitionView
from .sse import ModerationSSETestView, ModerationSSEView
from .views import (
BulkOperationViewSet,
ConvertSubmissionToEditView,
EditSubmissionViewSet,
ModerationActionViewSet,
ModerationQueueViewSet,
@@ -189,6 +190,8 @@ urlpatterns = [
*sse_patterns,
# Include all router URLs (API endpoints)
path("api/", include(router.urls)),
# Standalone convert-to-edit endpoint (frontend calls /moderation/api/edit-submissions/ POST)
path("api/edit-submissions/", ConvertSubmissionToEditView.as_view(), name="convert-to-edit"),
# FSM transition convenience endpoints
] + fsm_transition_patterns

View File

@@ -1516,6 +1516,116 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin], url_path="convert-to-edit")
def convert_to_edit(self, request, pk=None):
"""
Convert a pending entity submission to an edit suggestion.
This is used when a new entity submission should be merged with
an existing entity rather than creating a new one.
Request body:
target_entity_type: str - The type of entity to merge into (e.g., 'park', 'ride')
target_entity_id: int - The ID of the existing entity
merge_fields: list[str] - Optional list of fields to merge (defaults to all)
notes: str - Optional moderator notes
Returns:
200: Submission successfully converted
400: Invalid request or conversion not possible
404: Submission or target entity not found
"""
from django.contrib.contenttypes.models import ContentType
submission = self.get_object()
user = request.user
# Validate submission state
if submission.status not in ["PENDING", "CLAIMED"]:
return Response(
{"error": f"Cannot convert submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get request data
target_entity_type = request.data.get("target_entity_type")
target_entity_id = request.data.get("target_entity_id")
merge_fields = request.data.get("merge_fields", [])
notes = request.data.get("notes", "")
if not target_entity_type or not target_entity_id:
return Response(
{"error": "target_entity_type and target_entity_id are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Look up the target entity
try:
app_label = "parks" if target_entity_type in ["park"] else "rides"
if target_entity_type == "company":
app_label = "core"
content_type = ContentType.objects.get(app_label=app_label, model=target_entity_type)
model_class = content_type.model_class()
target_entity = model_class.objects.get(pk=target_entity_id)
except (ContentType.DoesNotExist, Exception) as e:
return Response(
{"error": f"Target entity not found: {target_entity_type}#{target_entity_id}"},
status=status.HTTP_404_NOT_FOUND,
)
# Store the conversion metadata
conversion_data = {
"converted_from": "new_entity_submission",
"target_entity_type": target_entity_type,
"target_entity_id": target_entity_id,
"target_entity_name": str(target_entity),
"merge_fields": merge_fields,
"converted_by": user.username,
"converted_at": timezone.now().isoformat(),
"notes": notes,
}
# Update the submission
if hasattr(submission, "changes") and isinstance(submission.changes, dict):
submission.changes["_conversion_metadata"] = conversion_data
else:
# Create changes dict if it doesn't exist
submission.changes = {"_conversion_metadata": conversion_data}
# Add moderator note
if hasattr(submission, "moderator_notes"):
existing_notes = submission.moderator_notes or ""
submission.moderator_notes = (
f"{existing_notes}\n\n[Converted to edit] {notes}".strip()
if notes
else f"{existing_notes}\n\n[Converted to edit for {target_entity_type} #{target_entity_id}]".strip()
)
submission.save()
# Log the conversion
log_business_event(
logger,
event_type="submission_converted_to_edit",
message=f"EditSubmission {submission.id} converted to edit for {target_entity_type}#{target_entity_id}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"target_entity_type": target_entity_type,
"target_entity_id": target_entity_id,
"converted_by": user.username,
},
request=request,
)
return Response({
"success": True,
"message": f"Submission converted to edit for {target_entity_type} #{target_entity_id}",
"submission": self.get_serializer(submission).data,
"conversion_metadata": conversion_data,
})
class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"""
@@ -1667,3 +1777,365 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
# ============================================================================
# Standalone Convert Submission to Edit View
# ============================================================================
from rest_framework.views import APIView
class ConvertSubmissionToEditView(APIView):
"""
POST /api/moderation/api/convert-to-edit/
Convert a CREATE submission to an EDIT by linking it to an existing entity.
Full parity with Supabase Edge Function: convert-submission-to-edit
This endpoint:
1. Validates the submission is locked by the requesting moderator
2. Validates the submission is in a valid state (PENDING or CLAIMED)
3. Validates the submission_type is 'CREATE' (only CREATE can be converted)
4. Looks up the existing entity
5. Updates the submission_type to 'EDIT' and links to existing entity
6. Logs to audit trail
BULLETPROOFED: Transaction safety, UUID validation, comprehensive error handling.
Request body:
{
"submissionId": "...", # The EditSubmission ID
"itemId": "...", # The submission item ID (optional, for Supabase compat)
"existingEntityId": "...", # The existing entity to link to
"conversionType": "..." # Optional: 'automatic' or 'manual'
}
Returns:
{
"success": true/false,
"itemId": "...",
"submissionId": "...",
"existingEntityId": "...",
"existingEntityName": "...",
"message": "..."
}
"""
permission_classes = [IsModeratorOrAdmin]
# Validation constants
MAX_NOTE_LENGTH = 5000
ALLOWED_CONVERSION_TYPES = {"automatic", "manual", "duplicate_detected"}
VALID_STATES = {"PENDING", "CLAIMED", "pending", "partially_approved", "claimed"}
def post(self, request):
from django.db import transaction
from django.contrib.contenttypes.models import ContentType
import uuid
try:
# ================================================================
# STEP 0: Validate user is authenticated
# ================================================================
user = request.user
if not user or not user.is_authenticated:
return Response(
{"success": False, "message": "Authentication required"},
status=status.HTTP_401_UNAUTHORIZED,
)
# ================================================================
# STEP 1: Extract and validate request parameters
# ================================================================
submission_id = request.data.get("submissionId")
item_id = request.data.get("itemId") # For Supabase compatibility
existing_entity_id = request.data.get("existingEntityId")
conversion_type = request.data.get("conversionType", "automatic")
# Validate required parameters
if not submission_id:
return Response(
{"success": False, "message": "submissionId is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not existing_entity_id:
return Response(
{"success": False, "message": "existingEntityId is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate UUID formats
try:
if isinstance(submission_id, str):
submission_uuid = uuid.UUID(submission_id)
else:
submission_uuid = submission_id
except (ValueError, AttributeError):
return Response(
{"success": False, "message": "Invalid submissionId format"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
if isinstance(existing_entity_id, str):
entity_uuid = uuid.UUID(existing_entity_id)
else:
entity_uuid = existing_entity_id
except (ValueError, AttributeError):
return Response(
{"success": False, "message": "Invalid existingEntityId format"},
status=status.HTTP_400_BAD_REQUEST,
)
# Sanitize conversion_type
if not isinstance(conversion_type, str):
conversion_type = "automatic"
conversion_type = conversion_type.strip().lower()[:50]
if conversion_type not in self.ALLOWED_CONVERSION_TYPES:
conversion_type = "automatic"
# ================================================================
# STEP 2: Get the submission with select_for_update
# ================================================================
try:
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(pk=submission_uuid)
except EditSubmission.DoesNotExist:
return Response(
{"success": False, "message": "Submission not found"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
logger.warning(f"Failed to fetch submission {submission_id}: {e}")
return Response(
{"success": False, "message": "Failed to fetch submission"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# ================================================================
# STEP 3: Verify submission is locked by requesting moderator
# ================================================================
claimed_by_id = getattr(submission, 'claimed_by_id', None)
user_id = getattr(user, 'id', None)
if claimed_by_id != user_id:
# Additional check: allow admins to override
if not getattr(user, 'is_staff', False) and not getattr(user, 'is_superuser', False):
return Response(
{"success": False, "message": "You must claim this submission before converting it"},
status=status.HTTP_400_BAD_REQUEST,
)
logger.info(
f"Admin override: {user.username} converting submission claimed by user {claimed_by_id}",
extra={"submission_id": str(submission_uuid), "admin_user": user.username}
)
# ================================================================
# STEP 4: Validate submission state
# ================================================================
current_status = getattr(submission, 'status', 'unknown')
if current_status not in self.VALID_STATES:
return Response(
{"success": False, "message": f"Submission must be pending or claimed to convert (current: {current_status})"},
status=status.HTTP_400_BAD_REQUEST,
)
# ================================================================
# STEP 5: Validate submission_type is CREATE
# ================================================================
current_type = getattr(submission, 'submission_type', '')
if current_type != "CREATE":
return Response(
{
"success": False,
"message": f"Item is already set to '{current_type}', cannot convert"
},
status=status.HTTP_400_BAD_REQUEST,
)
# ================================================================
# STEP 6: Determine entity type from submission's content_type
# ================================================================
target_entity_type = None
target_entity_name = None
target_entity_slug = None
target_entity = None
if submission.content_type:
target_entity_type = submission.content_type.model
# Also try to get from changes if available
if not target_entity_type and isinstance(submission.changes, dict):
target_entity_type = submission.changes.get("entity_type")
# ================================================================
# STEP 7: Look up the existing entity
# ================================================================
app_label_map = {
"park": "parks",
"ride": "rides",
"company": "core",
"ridemodel": "rides",
"manufacturer": "core",
"operator": "core",
}
if target_entity_type:
try:
app_label = app_label_map.get(target_entity_type.lower(), "core")
content_type = ContentType.objects.get(app_label=app_label, model=target_entity_type.lower())
model_class = content_type.model_class()
if model_class is None:
raise ValueError(f"No model class for {target_entity_type}")
target_entity = model_class.objects.filter(pk=entity_uuid).first()
if not target_entity:
return Response(
{"success": False, "message": f"Existing {target_entity_type} not found with ID {existing_entity_id}"},
status=status.HTTP_404_NOT_FOUND,
)
target_entity_name = str(getattr(target_entity, 'name', target_entity))[:200]
target_entity_slug = getattr(target_entity, 'slug', None)
except ContentType.DoesNotExist:
return Response(
{"success": False, "message": f"Unknown entity type: {target_entity_type}"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.warning(f"Failed to look up entity {target_entity_type}/{existing_entity_id}: {e}")
return Response(
{"success": False, "message": "Existing entity not found"},
status=status.HTTP_404_NOT_FOUND,
)
else:
# Try to find entity across common models
for model_name, app_label in [("park", "parks"), ("ride", "rides"), ("company", "core")]:
try:
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
model_class = content_type.model_class()
if model_class is None:
continue
target_entity = model_class.objects.filter(pk=entity_uuid).first()
if target_entity:
target_entity_type = model_name
target_entity_name = str(getattr(target_entity, 'name', target_entity))[:200]
target_entity_slug = getattr(target_entity, 'slug', None)
break
except Exception:
continue
if not target_entity:
return Response(
{"success": False, "message": "Existing entity not found in any known model"},
status=status.HTTP_404_NOT_FOUND,
)
# ================================================================
# STEP 8: Update submission with atomic transaction
# ================================================================
try:
with transaction.atomic():
# Re-fetch with lock to ensure no concurrent modifications
submission = EditSubmission.objects.select_for_update().get(pk=submission_uuid)
# Double-check state hasn't changed
if submission.submission_type != "CREATE":
return Response(
{"success": False, "message": "Submission was already converted"},
status=status.HTTP_409_CONFLICT,
)
# Update submission_type
submission.submission_type = "EDIT"
# Link to existing entity via object_id
submission.object_id = entity_uuid
# Store conversion metadata in changes
if not isinstance(submission.changes, dict):
submission.changes = {}
submission.changes["_conversion_metadata"] = {
"converted_from": "new_entity_submission",
"original_action_type": "create",
"target_entity_type": target_entity_type,
"target_entity_id": str(entity_uuid),
"target_entity_name": target_entity_name,
"target_entity_slug": target_entity_slug,
"conversion_type": conversion_type,
"converted_by": user.username,
"converted_by_id": str(getattr(user, 'user_id', user.id)),
"converted_at": timezone.now().isoformat(),
}
# Add moderator note (with length limit)
existing_notes = (submission.notes or "")[:self.MAX_NOTE_LENGTH]
conversion_note = f"[Converted CREATE to EDIT] for {target_entity_type}: {target_entity_name}"
if target_entity_slug:
conversion_note += f" ({target_entity_slug})"
conversion_note += f". Conversion type: {conversion_type}"
new_notes = f"{existing_notes}\n\n{conversion_note}".strip()
submission.notes = new_notes[:self.MAX_NOTE_LENGTH]
submission.save(update_fields=["submission_type", "object_id", "changes", "notes"])
except Exception as e:
logger.error(f"Failed to update submission {submission_uuid}: {e}")
return Response(
{"success": False, "message": "Failed to update submission"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# ================================================================
# STEP 9: Log to audit trail (outside transaction for reliability)
# ================================================================
try:
log_business_event(
logger,
event_type="submission_converted_to_edit",
message=f"EditSubmission {submission.id} converted from CREATE to EDIT for {target_entity_type}#{entity_uuid}",
context={
"model": "EditSubmission",
"object_id": str(submission.id),
"item_id": str(item_id) if item_id else None,
"target_entity_type": target_entity_type,
"target_entity_id": str(entity_uuid),
"target_entity_name": target_entity_name,
"converted_by": user.username,
"conversion_type": conversion_type,
},
request=request,
)
except Exception as log_error:
# Don't fail the request if logging fails
logger.warning(f"Failed to log conversion event: {log_error}")
# ================================================================
# STEP 10: Return success response matching original format
# ================================================================
return Response({
"success": True,
"itemId": str(item_id) if item_id else str(submission.id),
"submissionId": str(submission.id),
"existingEntityId": str(entity_uuid),
"existingEntityName": target_entity_name,
"message": f"Converted submission item to EDIT for existing {target_entity_type}: {target_entity_name}",
})
except Exception as e:
capture_and_log(e, "Convert submission to edit", source="moderation", request=request)
return Response(
{"success": False, "message": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)