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

473 lines
14 KiB
Markdown

---
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:
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```vue
<!-- 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
```vue
<!-- 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