Files
thrillwiki_django_no_react/.agent/workflows/moderation.md
pacnpal 1adba1b804 lol
2026-01-02 07:58:58 -05:00

14 KiB

description
description
Add moderation support to a content type in ThrillWiki

Moderation Workflow

Add moderation (submission queue, version history, approval flow) to a content type.

Overview

ThrillWiki's moderation system ensures quality by:

  1. User submits new/edited content → Creates Submission record
  2. Content enters moderation queue with pending status
  3. Moderator reviews and approves/rejects
  4. On approval → Content is published, version record created

Implementation Steps

Step 1: Ensure Model Supports Versioning

The content model needs to track its current state and history:

# backend/apps/[app]/models.py

class Park(BaseModel):
    """Main park model - always shows current approved data"""
    name = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
    description = models.TextField(blank=True)
    # ... other fields
    
    # Track the current approved version
    current_version = models.ForeignKey(
        'ParkVersion',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='current_for'
    )


class ParkVersion(BaseModel):
    """Immutable snapshot of park data at a point in time"""
    park = models.ForeignKey(
        Park,
        on_delete=models.CASCADE,
        related_name='versions'
    )
    # Store complete snapshot of editable fields
    data = models.JSONField()
    
    # Metadata
    changed_by = models.ForeignKey(
        'users.User',
        on_delete=models.SET_NULL,
        null=True
    )
    change_summary = models.CharField(max_length=255, blank=True)
    submission = models.ForeignKey(
        'submissions.Submission',
        on_delete=models.SET_NULL,
        null=True,
        related_name='versions'
    )
    
    class Meta:
        ordering = ['-created_at']
    
    def apply_to_park(self):
        """Apply this version's data to the parent park"""
        for field, value in self.data.items():
            if hasattr(self.park, field):
                setattr(self.park, field, value)
        self.park.current_version = self
        self.park.save()

Step 2: Create Submission Serializers

# backend/apps/submissions/serializers.py

class ParkSubmissionSerializer(serializers.Serializer):
    """Serializer for park submission data"""
    name = serializers.CharField(max_length=255)
    description = serializers.CharField(required=False, allow_blank=True)
    city = serializers.CharField(max_length=100)
    country = serializers.CharField(max_length=100)
    status = serializers.ChoiceField(choices=Park.Status.choices)
    # ... other editable fields
    
    def validate_name(self, value):
        # Custom validation if needed
        return value


class SubmissionCreateSerializer(serializers.ModelSerializer):
    """Create a new submission"""
    data = serializers.JSONField()
    
    class Meta:
        model = Submission
        fields = ['content_type', 'object_id', 'data', 'change_summary']
    
    def validate(self, attrs):
        content_type = attrs['content_type']
        
        # Get the appropriate serializer for this content type
        serializer_map = {
            'park': ParkSubmissionSerializer,
            'ride': RideSubmissionSerializer,
            # ... other content types
        }
        
        serializer_class = serializer_map.get(content_type)
        if not serializer_class:
            raise serializers.ValidationError(
                {'content_type': 'Unsupported content type'}
            )
        
        # Validate the data field
        data_serializer = serializer_class(data=attrs['data'])
        data_serializer.is_valid(raise_exception=True)
        attrs['data'] = data_serializer.validated_data
        
        return attrs
    
    def create(self, validated_data):
        validated_data['submitted_by'] = self.context['request'].user
        validated_data['status'] = Submission.Status.PENDING
        return super().create(validated_data)

Step 3: Create Submission ViewSet

# backend/apps/submissions/views.py

class SubmissionViewSet(viewsets.ModelViewSet):
    """API for content submissions"""
    serializer_class = SubmissionSerializer
    permission_classes = [IsAuthenticated]
    
    def get_queryset(self):
        user = self.request.user
        
        # Users see their own submissions
        # Moderators see all pending submissions
        if user.is_moderator:
            return Submission.objects.all()
        return Submission.objects.filter(submitted_by=user)
    
    def get_serializer_class(self):
        if self.action == 'create':
            return SubmissionCreateSerializer
        return SubmissionSerializer
    
    @action(detail=True, methods=['get'])
    def diff(self, request, pk=None):
        """Get diff between submission and current version"""
        submission = self.get_object()
        
        if submission.object_id:
            # Edit submission - compare to current
            current = self.get_current_data(submission)
            return Response({
                'before': current,
                'after': submission.data,
                'changes': self.compute_diff(current, submission.data)
            })
        else:
            # New submission - no comparison
            return Response({
                'before': None,
                'after': submission.data,
                'changes': None
            })

Step 4: Create Moderation ViewSet

# backend/apps/moderation/views.py

class ModerationViewSet(viewsets.ViewSet):
    """Moderation queue and actions"""
    permission_classes = [IsModerator]
    
    def list(self, request):
        """Get moderation queue"""
        queryset = Submission.objects.filter(
            status=Submission.Status.PENDING
        ).select_related(
            'submitted_by'
        ).order_by('created_at')
        
        # Filter by content type
        content_type = request.query_params.get('type')
        if content_type:
            queryset = queryset.filter(content_type=content_type)
        
        serializer = SubmissionSerializer(queryset, many=True)
        return Response(serializer.data)
    
    @action(detail=True, methods=['post'])
    def approve(self, request, pk=None):
        """Approve a submission"""
        submission = get_object_or_404(Submission, pk=pk)
        
        if submission.status != Submission.Status.PENDING:
            return Response(
                {'error': 'Submission is not pending'},
                status=status.HTTP_400_BAD_REQUEST
            )
        
        # Apply the submission
        with transaction.atomic():
            if submission.object_id:
                # Edit existing content
                content = self.get_content_object(submission)
                version = self.create_version(content, submission)
                version.apply_to_park()
            else:
                # Create new content
                content = self.create_content(submission)
                version = self.create_version(content, submission)
                content.current_version = version
                content.save()
            
            submission.status = Submission.Status.APPROVED
            submission.reviewed_by = request.user
            submission.reviewed_at = timezone.now()
            submission.save()
        
        # Notify user
        notify_user(
            submission.submitted_by,
            'submission_approved',
            {'submission': submission}
        )
        
        return Response({'status': 'approved'})
    
    @action(detail=True, methods=['post'])
    def reject(self, request, pk=None):
        """Reject a submission"""
        submission = get_object_or_404(Submission, pk=pk)
        
        submission.status = Submission.Status.REJECTED
        submission.reviewed_by = request.user
        submission.reviewed_at = timezone.now()
        submission.review_notes = request.data.get('notes', '')
        submission.save()
        
        # Notify user
        notify_user(
            submission.submitted_by,
            'submission_rejected',
            {'submission': submission, 'reason': submission.review_notes}
        )
        
        return Response({'status': 'rejected'})
    
    @action(detail=True, methods=['post'])
    def request_changes(self, request, pk=None):
        """Request changes to a submission"""
        submission = get_object_or_404(Submission, pk=pk)
        
        submission.status = Submission.Status.CHANGES_REQUESTED
        submission.reviewed_by = request.user
        submission.review_notes = request.data.get('notes', '')
        submission.save()
        
        # Notify user
        notify_user(
            submission.submitted_by,
            'submission_changes_requested',
            {'submission': submission, 'notes': submission.review_notes}
        )
        
        return Response({'status': 'changes_requested'})

Step 5: Frontend - Submission Form

<!-- frontend/components/forms/ParkSubmitForm.vue -->
<script setup lang="ts">
import type { Park } from '~/types'

const props = defineProps<{
  park?: Park  // Existing park for edits, null for new
}>()

const emit = defineEmits<{
  (e: 'submitted'): void
}>()

const form = reactive({
  name: props.park?.name || '',
  description: props.park?.description || '',
  city: props.park?.city || '',
  country: props.park?.country || '',
  status: props.park?.status || 'operating',
  changeSummary: '',
})

const isSubmitting = ref(false)
const errors = ref<Record<string, string[]>>({})

async function handleSubmit() {
  isSubmitting.value = true
  errors.value = {}
  
  try {
    await $fetch('/api/v1/submissions/', {
      method: 'POST',
      body: {
        content_type: 'park',
        object_id: props.park?.id || null,
        data: {
          name: form.name,
          description: form.description,
          city: form.city,
          country: form.country,
          status: form.status,
        },
        change_summary: form.changeSummary,
      }
    })
    
    // Show success message
    useToast().success(
      props.park 
        ? 'Your edit has been submitted for review' 
        : 'Your submission has been received'
    )
    
    emit('submitted')
  } catch (e: any) {
    if (e.data?.error?.details) {
      errors.value = e.data.error.details
    } else {
      useToast().error('Failed to submit. Please try again.')
    }
  } finally {
    isSubmitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit" class="space-y-6">
    <FormField label="Park Name" :error="errors.name?.[0]" required>
      <Input v-model="form.name" />
    </FormField>
    
    <FormField label="Description" :error="errors.description?.[0]">
      <Textarea v-model="form.description" rows="4" />
    </FormField>
    
    <!-- More fields... -->
    
    <FormField 
      label="Summary of Changes" 
      :error="errors.changeSummary?.[0]"
      hint="Briefly describe what you're adding or changing"
    >
      <Input v-model="form.changeSummary" />
    </FormField>
    
    <Alert variant="info">
      Your submission will be reviewed by our moderators before being published.
    </Alert>
    
    <div class="flex justify-end gap-2">
      <Button variant="outline" @click="$emit('cancel')">Cancel</Button>
      <Button type="submit" :loading="isSubmitting">
        {{ park ? 'Submit Edit' : 'Submit for Review' }}
      </Button>
    </div>
  </form>
</template>

Step 6: Frontend - Moderation Queue Page

<!-- frontend/pages/moderation/index.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: ['auth', 'moderator']
})

useSeoMeta({
  title: 'Moderation Queue | ThrillWiki'
})

const filters = reactive({
  type: '',
  status: 'pending'
})

const { data, pending, refresh } = await useAsyncData(
  'moderation-queue',
  () => $fetch('/api/v1/moderation/', { params: filters }),
  { watch: [filters] }
)

async function handleApprove(id: string) {
  await $fetch(`/api/v1/moderation/${id}/approve/`, { method: 'POST' })
  useToast().success('Submission approved')
  refresh()
}

async function handleReject(id: string, notes: string) {
  await $fetch(`/api/v1/moderation/${id}/reject/`, { 
    method: 'POST',
    body: { notes }
  })
  useToast().success('Submission rejected')
  refresh()
}
</script>

<template>
  <PageContainer>
    <h1 class="text-3xl font-bold mb-8">Moderation Queue</h1>
    
    <!-- Filters -->
    <div class="flex gap-4 mb-6">
      <Select v-model="filters.type">
        <SelectOption value="">All Types</SelectOption>
        <SelectOption value="park">Parks</SelectOption>
        <SelectOption value="ride">Rides</SelectOption>
      </Select>
    </div>
    
    <!-- Queue -->
    <div class="space-y-4">
      <SubmissionCard
        v-for="submission in data"
        :key="submission.id"
        :submission="submission"
        @approve="handleApprove(submission.id)"
        @reject="notes => handleReject(submission.id, notes)"
      />
    </div>
    
    <EmptyState 
      v-if="!pending && !data?.length"
      icon="CheckCircle"
      title="Queue is empty"
      description="No pending submissions to review"
    />
  </PageContainer>
</template>

Checklist

  • Model supports versioning with JSONField snapshot
  • Submission model tracks all submission states
  • Validation serializers exist for each content type
  • Moderation endpoints have proper permissions
  • Approval creates version and applies changes atomically
  • Users are notified of submission status changes
  • Frontend shows submission status to users
  • Moderation queue is filterable and efficient
  • Diff view shows before/after comparison
  • Tests cover approval, rejection, and edge cases