mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 05:45:17 -05:00
feat: Implement email change cancellation, location search, and admin anomaly detection endpoints.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user