mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:31:07 -05:00
good stuff
This commit is contained in:
0
moderation/__init__.py
Normal file
0
moderation/__init__.py
Normal file
78
moderation/admin.py
Normal file
78
moderation/admin.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
|
||||
class ModerationAdminSite(AdminSite):
|
||||
site_header = 'ThrillWiki Moderation'
|
||||
site_title = 'ThrillWiki Moderation'
|
||||
index_title = 'Moderation Dashboard'
|
||||
|
||||
def has_permission(self, request):
|
||||
"""Only allow moderators and above to access this admin site"""
|
||||
return request.user.is_authenticated and request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||
|
||||
moderation_site = ModerationAdminSite(name='moderation')
|
||||
|
||||
class EditSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'submitted_at', 'reviewed_by']
|
||||
list_filter = ['status', 'content_type', 'submitted_at']
|
||||
search_fields = ['user__username', 'reason', 'source', 'review_notes']
|
||||
readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'submitted_at']
|
||||
|
||||
def user_link(self, obj):
|
||||
url = reverse('admin:accounts_user_change', args=[obj.user.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||
user_link.short_description = 'User'
|
||||
|
||||
def content_link(self, obj):
|
||||
if hasattr(obj.content_object, 'get_absolute_url'):
|
||||
url = obj.content_object.get_absolute_url()
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||
return str(obj.content_object)
|
||||
content_link.short_description = 'Content'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if 'status' in form.changed_data:
|
||||
if obj.status == 'APPROVED':
|
||||
obj.approve(request.user, obj.review_notes)
|
||||
elif obj.status == 'REJECTED':
|
||||
obj.reject(request.user, obj.review_notes)
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
class PhotoSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'submitted_at', 'reviewed_by']
|
||||
list_filter = ['status', 'content_type', 'submitted_at']
|
||||
search_fields = ['user__username', 'caption', 'review_notes']
|
||||
readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'submitted_at']
|
||||
|
||||
def user_link(self, obj):
|
||||
url = reverse('admin:accounts_user_change', args=[obj.user.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||
user_link.short_description = 'User'
|
||||
|
||||
def content_link(self, obj):
|
||||
if hasattr(obj.content_object, 'get_absolute_url'):
|
||||
url = obj.content_object.get_absolute_url()
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||
return str(obj.content_object)
|
||||
content_link.short_description = 'Content'
|
||||
|
||||
def photo_preview(self, obj):
|
||||
if obj.photo:
|
||||
return format_html('<img src="{}" style="max-height: 100px; max-width: 200px;" />', obj.photo.url)
|
||||
return ''
|
||||
photo_preview.short_description = 'Photo Preview'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if 'status' in form.changed_data:
|
||||
if obj.status == 'APPROVED':
|
||||
obj.approve(request.user, obj.review_notes)
|
||||
elif obj.status == 'REJECTED':
|
||||
obj.reject(request.user, obj.review_notes)
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
# Register with moderation site only
|
||||
moderation_site.register(EditSubmission, EditSubmissionAdmin)
|
||||
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
|
||||
6
moderation/apps.py
Normal file
6
moderation/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class ModerationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'moderation'
|
||||
verbose_name = 'Content Moderation'
|
||||
16
moderation/context_processors.py
Normal file
16
moderation/context_processors.py
Normal file
@@ -0,0 +1,16 @@
|
||||
def moderation_access(request):
|
||||
"""Add moderation access check to template context"""
|
||||
context = {
|
||||
'has_moderation_access': False,
|
||||
'has_admin_access': False,
|
||||
'has_superuser_access': False,
|
||||
'user_role': None
|
||||
}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
context['user_role'] = request.user.role
|
||||
context['has_moderation_access'] = request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||
context['has_admin_access'] = request.user.role in ['ADMIN', 'SUPERUSER']
|
||||
context['has_superuser_access'] = request.user.role == 'SUPERUSER'
|
||||
|
||||
return context
|
||||
183
moderation/migrations/0001_initial.py
Normal file
183
moderation/migrations/0001_initial.py
Normal file
@@ -0,0 +1,183 @@
|
||||
# Generated by Django 5.1.2 on 2024-10-30 00:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EditSubmission",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
(
|
||||
"changes",
|
||||
models.JSONField(
|
||||
help_text="JSON representation of the changes made"
|
||||
),
|
||||
),
|
||||
("reason", models.TextField(help_text="Why this edit is needed")),
|
||||
(
|
||||
"source",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Source of information for this edit (if applicable)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("AUTO_APPROVED", "Auto Approved"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("submitted_at", models.DateTimeField(auto_now_add=True)),
|
||||
("reviewed_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"review_notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from the moderator about this submission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"reviewed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reviewed_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="edit_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-submitted_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="moderation__content_922d2b_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["status"], name="moderation__status_e4eb2b_idx"
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PhotoSubmission",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("photo", models.ImageField(upload_to="submissions/photos/")),
|
||||
("caption", models.CharField(blank=True, max_length=255)),
|
||||
("date_taken", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("AUTO_APPROVED", "Auto Approved"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("submitted_at", models.DateTimeField(auto_now_add=True)),
|
||||
("reviewed_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"review_notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"reviewed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reviewed_photos",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="photo_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-submitted_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="moderation__content_7a7bc1_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["status"], name="moderation__status_7a1914_idx"
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.2 on 2024-10-30 01:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="editsubmission",
|
||||
name="submission_type",
|
||||
field=models.CharField(
|
||||
choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")],
|
||||
default="EDIT",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmission",
|
||||
name="changes",
|
||||
field=models.JSONField(
|
||||
help_text="JSON representation of the changes or new object data"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmission",
|
||||
name="object_id",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmission",
|
||||
name="reason",
|
||||
field=models.TextField(help_text="Why this edit/addition is needed"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmission",
|
||||
name="source",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="Source of information (if applicable)"
|
||||
),
|
||||
),
|
||||
]
|
||||
0
moderation/migrations/__init__.py
Normal file
0
moderation/migrations/__init__.py
Normal file
205
moderation/mixins.py
Normal file
205
moderation/mixins.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import JsonResponse, HttpResponseForbidden
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils import timezone
|
||||
import json
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
|
||||
class EditSubmissionMixin(LoginRequiredMixin):
|
||||
"""
|
||||
Mixin for handling edit submissions with proper moderation.
|
||||
"""
|
||||
def handle_edit_submission(self, request, changes, reason='', source='', submission_type='EDIT'):
|
||||
"""
|
||||
Handle an edit submission based on user's role.
|
||||
|
||||
Args:
|
||||
request: The HTTP request
|
||||
changes: Dict of field changes {field_name: new_value}
|
||||
reason: Why this edit is needed
|
||||
source: Source of information (optional)
|
||||
submission_type: 'EDIT' or 'CREATE'
|
||||
|
||||
Returns:
|
||||
JsonResponse with status and message
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'You must be logged in to make edits.'
|
||||
}, status=403)
|
||||
|
||||
content_type = ContentType.objects.get_for_model(self.model)
|
||||
|
||||
# Create the submission
|
||||
submission = EditSubmission(
|
||||
user=request.user,
|
||||
content_type=content_type,
|
||||
submission_type=submission_type,
|
||||
changes=changes,
|
||||
reason=reason,
|
||||
source=source
|
||||
)
|
||||
|
||||
# For edits, set the object_id
|
||||
if submission_type == 'EDIT':
|
||||
obj = self.get_object()
|
||||
submission.object_id = obj.id
|
||||
|
||||
# Auto-approve for moderators and above
|
||||
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
||||
obj = submission.auto_approve()
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'message': 'Changes saved successfully.',
|
||||
'auto_approved': True,
|
||||
'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None
|
||||
})
|
||||
|
||||
# Submit for approval for regular users
|
||||
submission.save()
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'message': 'Your changes have been submitted for approval.',
|
||||
'auto_approved': False
|
||||
})
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Handle POST requests for editing"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
changes = data.get('changes', {})
|
||||
reason = data.get('reason', '')
|
||||
source = data.get('source', '')
|
||||
submission_type = data.get('submission_type', 'EDIT')
|
||||
|
||||
if not changes:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'No changes provided.'
|
||||
}, status=400)
|
||||
|
||||
if not reason and request.user.role == 'USER':
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Please provide a reason for your changes.'
|
||||
}, status=400)
|
||||
|
||||
return self.handle_edit_submission(
|
||||
request, changes, reason, source, submission_type
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON data.'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}, status=500)
|
||||
|
||||
class PhotoSubmissionMixin(LoginRequiredMixin):
|
||||
"""
|
||||
Mixin for handling photo submissions with proper moderation.
|
||||
"""
|
||||
def handle_photo_submission(self, request):
|
||||
"""Handle a photo submission based on user's role"""
|
||||
if not request.FILES.get('photo'):
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'No photo provided.'
|
||||
}, status=400)
|
||||
|
||||
obj = self.get_object()
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
submission = PhotoSubmission(
|
||||
user=request.user,
|
||||
content_type=content_type,
|
||||
object_id=obj.id,
|
||||
photo=request.FILES['photo'],
|
||||
caption=request.POST.get('caption', ''),
|
||||
date_taken=request.POST.get('date_taken')
|
||||
)
|
||||
|
||||
# Auto-approve for moderators and above
|
||||
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
||||
submission.auto_approve()
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'message': 'Photo uploaded successfully.',
|
||||
'auto_approved': True
|
||||
})
|
||||
|
||||
# Submit for approval for regular users
|
||||
submission.save()
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'message': 'Your photo has been submitted for approval.',
|
||||
'auto_approved': False
|
||||
})
|
||||
|
||||
class ModeratorRequiredMixin(UserPassesTestMixin):
|
||||
"""Require moderator or higher role for access"""
|
||||
def test_func(self):
|
||||
return (
|
||||
self.request.user.is_authenticated and
|
||||
self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||
)
|
||||
|
||||
def handle_no_permission(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return super().handle_no_permission()
|
||||
return HttpResponseForbidden("You must be a moderator to access this page.")
|
||||
|
||||
class AdminRequiredMixin(UserPassesTestMixin):
|
||||
"""Require admin or superuser role for access"""
|
||||
def test_func(self):
|
||||
return (
|
||||
self.request.user.is_authenticated and
|
||||
self.request.user.role in ['ADMIN', 'SUPERUSER']
|
||||
)
|
||||
|
||||
def handle_no_permission(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return super().handle_no_permission()
|
||||
return HttpResponseForbidden("You must be an admin to access this page.")
|
||||
|
||||
class InlineEditMixin:
|
||||
"""Add inline editing context to views"""
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.request.user.is_authenticated:
|
||||
context['can_edit'] = True
|
||||
context['can_auto_approve'] = self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||
if hasattr(self, 'get_object'):
|
||||
obj = self.get_object()
|
||||
context['pending_edits'] = EditSubmission.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(obj),
|
||||
object_id=obj.id,
|
||||
status='PENDING'
|
||||
).select_related('user').order_by('-submitted_at')
|
||||
return context
|
||||
|
||||
class HistoryMixin:
|
||||
"""Add edit history context to views"""
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
obj = self.get_object()
|
||||
|
||||
# Get historical records
|
||||
context['history'] = obj.history.all().select_related('history_user')
|
||||
|
||||
# Get related edit submissions
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
context['edit_submissions'] = EditSubmission.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=obj.id
|
||||
).exclude(
|
||||
status='PENDING'
|
||||
).select_related('user', 'reviewed_by').order_by('-submitted_at')
|
||||
|
||||
return context
|
||||
262
moderation/models.py
Normal file
262
moderation/models.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.apps import apps
|
||||
|
||||
class EditSubmission(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('APPROVED', 'Approved'),
|
||||
('REJECTED', 'Rejected'),
|
||||
('AUTO_APPROVED', 'Auto Approved'),
|
||||
]
|
||||
|
||||
SUBMISSION_TYPE_CHOICES = [
|
||||
('EDIT', 'Edit Existing'),
|
||||
('CREATE', 'Create New'),
|
||||
]
|
||||
|
||||
# Who submitted the edit
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='edit_submissions'
|
||||
)
|
||||
|
||||
# What is being edited (Park or Ride)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True) # Null for new objects
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
# Type of submission
|
||||
submission_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=SUBMISSION_TYPE_CHOICES,
|
||||
default='EDIT'
|
||||
)
|
||||
|
||||
# The actual changes/data
|
||||
changes = models.JSONField(
|
||||
help_text='JSON representation of the changes or new object data'
|
||||
)
|
||||
|
||||
# Metadata
|
||||
reason = models.TextField(
|
||||
help_text='Why this edit/addition is needed'
|
||||
)
|
||||
source = models.TextField(
|
||||
blank=True,
|
||||
help_text='Source of information (if applicable)'
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='PENDING'
|
||||
)
|
||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='reviewed_submissions'
|
||||
)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
review_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text='Notes from the moderator about this submission'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-submitted_at']
|
||||
indexes = [
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
models.Index(fields=['status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
action = "creation" if self.submission_type == 'CREATE' else "edit"
|
||||
target = self.content_object or self.content_type.model_class().__name__
|
||||
return f"{action} by {self.user.username} on {target}"
|
||||
|
||||
def _resolve_foreign_keys(self, data):
|
||||
"""Convert foreign key IDs to model instances"""
|
||||
model_class = self.content_type.model_class()
|
||||
resolved_data = data.copy()
|
||||
|
||||
for field_name, value in data.items():
|
||||
field = model_class._meta.get_field(field_name)
|
||||
if isinstance(field, models.ForeignKey) and value is not None:
|
||||
related_model = field.related_model
|
||||
resolved_data[field_name] = related_model.objects.get(id=value)
|
||||
|
||||
return resolved_data
|
||||
|
||||
def approve(self, moderator, notes=''):
|
||||
"""Approve the submission and apply the changes"""
|
||||
self.status = 'APPROVED'
|
||||
self.reviewed_by = moderator
|
||||
self.reviewed_at = timezone.now()
|
||||
self.review_notes = notes
|
||||
|
||||
model_class = self.content_type.model_class()
|
||||
resolved_data = self._resolve_foreign_keys(self.changes)
|
||||
|
||||
if self.submission_type == 'CREATE':
|
||||
# Create new object
|
||||
obj = model_class(**resolved_data)
|
||||
obj.save()
|
||||
# Update object_id after creation
|
||||
self.object_id = obj.id
|
||||
else:
|
||||
# Apply changes to existing object
|
||||
obj = self.content_object
|
||||
for field, value in resolved_data.items():
|
||||
setattr(obj, field, value)
|
||||
obj.save()
|
||||
|
||||
self.save()
|
||||
return obj
|
||||
|
||||
def reject(self, moderator, notes):
|
||||
"""Reject the submission"""
|
||||
self.status = 'REJECTED'
|
||||
self.reviewed_by = moderator
|
||||
self.reviewed_at = timezone.now()
|
||||
self.review_notes = notes
|
||||
self.save()
|
||||
|
||||
def auto_approve(self):
|
||||
"""Auto-approve the submission (for moderators/admins)"""
|
||||
self.status = 'AUTO_APPROVED'
|
||||
self.reviewed_by = self.user
|
||||
self.reviewed_at = timezone.now()
|
||||
|
||||
model_class = self.content_type.model_class()
|
||||
resolved_data = self._resolve_foreign_keys(self.changes)
|
||||
|
||||
if self.submission_type == 'CREATE':
|
||||
# Create new object
|
||||
obj = model_class(**resolved_data)
|
||||
obj.save()
|
||||
# Update object_id after creation
|
||||
self.object_id = obj.id
|
||||
else:
|
||||
# Apply changes to existing object
|
||||
obj = self.content_object
|
||||
for field, value in resolved_data.items():
|
||||
setattr(obj, field, value)
|
||||
obj.save()
|
||||
|
||||
self.save()
|
||||
return obj
|
||||
|
||||
class PhotoSubmission(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('APPROVED', 'Approved'),
|
||||
('REJECTED', 'Rejected'),
|
||||
('AUTO_APPROVED', 'Auto Approved'),
|
||||
]
|
||||
|
||||
# Who submitted the photo
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='photo_submissions'
|
||||
)
|
||||
|
||||
# What the photo is for (Park or Ride)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
# The photo itself
|
||||
photo = models.ImageField(upload_to='submissions/photos/')
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
date_taken = models.DateField(null=True, blank=True)
|
||||
|
||||
# Metadata
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='PENDING'
|
||||
)
|
||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='reviewed_photos'
|
||||
)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
review_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text='Notes from the moderator about this photo submission'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-submitted_at']
|
||||
indexes = [
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
models.Index(fields=['status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Photo submission by {self.user.username} for {self.content_object}"
|
||||
|
||||
def approve(self, moderator, notes=''):
|
||||
"""Approve the photo submission"""
|
||||
from media.models import Photo
|
||||
|
||||
self.status = 'APPROVED'
|
||||
self.reviewed_by = moderator
|
||||
self.reviewed_at = timezone.now()
|
||||
self.review_notes = notes
|
||||
|
||||
# Create the approved photo
|
||||
Photo.objects.create(
|
||||
user=self.user,
|
||||
content_type=self.content_type,
|
||||
object_id=self.object_id,
|
||||
image=self.photo,
|
||||
caption=self.caption,
|
||||
date_taken=self.date_taken
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def reject(self, moderator, notes):
|
||||
"""Reject the photo submission"""
|
||||
self.status = 'REJECTED'
|
||||
self.reviewed_by = moderator
|
||||
self.reviewed_at = timezone.now()
|
||||
self.review_notes = notes
|
||||
self.save()
|
||||
|
||||
def auto_approve(self):
|
||||
"""Auto-approve the photo submission (for moderators/admins)"""
|
||||
from media.models import Photo
|
||||
|
||||
self.status = 'AUTO_APPROVED'
|
||||
self.reviewed_by = self.user
|
||||
self.reviewed_at = timezone.now()
|
||||
|
||||
# Create the approved photo
|
||||
Photo.objects.create(
|
||||
user=self.user,
|
||||
content_type=self.content_type,
|
||||
object_id=self.object_id,
|
||||
image=self.photo,
|
||||
caption=self.caption,
|
||||
date_taken=self.date_taken
|
||||
)
|
||||
|
||||
self.save()
|
||||
3
moderation/tests.py
Normal file
3
moderation/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
moderation/urls.py
Normal file
16
moderation/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.urls import path, include
|
||||
from .admin import moderation_site
|
||||
from .views import EditSubmissionListView, PhotoSubmissionListView
|
||||
|
||||
app_name = 'moderation'
|
||||
|
||||
urlpatterns = [
|
||||
# Custom moderation views
|
||||
path('submissions/', include([
|
||||
path('edits/', EditSubmissionListView.as_view(), name='edit_submissions'),
|
||||
path('photos/', PhotoSubmissionListView.as_view(), name='photo_submissions'),
|
||||
])),
|
||||
|
||||
# Admin site URLs
|
||||
path('admin/', moderation_site.urls),
|
||||
]
|
||||
100
moderation/views.py
Normal file
100
moderation/views.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from django.views.generic import ListView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
from .mixins import ModeratorRequiredMixin
|
||||
|
||||
class EditSubmissionListView(ModeratorRequiredMixin, ListView):
|
||||
model = EditSubmission
|
||||
template_name = 'moderation/admin/edit_submission_list.html'
|
||||
context_object_name = 'submissions'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().select_related(
|
||||
'user', 'reviewed_by', 'content_type'
|
||||
).order_by('-submitted_at')
|
||||
|
||||
# Filter by status
|
||||
status = self.request.GET.get('status')
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
# Filter by submission type
|
||||
submission_type = self.request.GET.get('type')
|
||||
if submission_type:
|
||||
queryset = queryset.filter(submission_type=submission_type)
|
||||
|
||||
return queryset
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
submission_id = request.POST.get('submission_id')
|
||||
action = request.POST.get('action')
|
||||
review_notes = request.POST.get('review_notes', '')
|
||||
|
||||
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||
|
||||
if action == 'approve':
|
||||
obj = submission.approve(request.user, review_notes)
|
||||
message = 'New addition approved successfully.' if submission.submission_type == 'CREATE' else 'Changes approved successfully.'
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'message': message,
|
||||
'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None
|
||||
})
|
||||
elif action == 'reject':
|
||||
submission.reject(request.user, review_notes)
|
||||
message = 'New addition rejected.' if submission.submission_type == 'CREATE' else 'Changes rejected.'
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'message': message
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Invalid action.'
|
||||
}, status=400)
|
||||
|
||||
class PhotoSubmissionListView(ModeratorRequiredMixin, ListView):
|
||||
model = PhotoSubmission
|
||||
template_name = 'moderation/admin/photo_submission_list.html'
|
||||
context_object_name = 'submissions'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().select_related(
|
||||
'user', 'reviewed_by', 'content_type'
|
||||
).order_by('-submitted_at')
|
||||
|
||||
status = self.request.GET.get('status')
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
submission_id = request.POST.get('submission_id')
|
||||
action = request.POST.get('action')
|
||||
review_notes = request.POST.get('review_notes', '')
|
||||
|
||||
submission = get_object_or_404(PhotoSubmission, id=submission_id)
|
||||
|
||||
if action == 'approve':
|
||||
submission.approve(request.user, review_notes)
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'message': 'Photo approved successfully.'
|
||||
})
|
||||
elif action == 'reject':
|
||||
submission.reject(request.user, review_notes)
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'message': 'Photo rejected successfully.'
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Invalid action.'
|
||||
}, status=400)
|
||||
Reference in New Issue
Block a user