mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 15:35:17 -05:00
473 lines
14 KiB
Markdown
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
|