mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:11:08 -05:00
feat: complete monorepo structure with frontend and shared resources
- Add complete backend/ directory with full Django application - Add frontend/ directory with Vite + TypeScript setup ready for Next.js - Add comprehensive shared/ directory with: - Complete documentation and memory-bank archives - Media files and avatars (letters, park/ride images) - Deployment scripts and automation tools - Shared types and utilities - Add architecture/ directory with migration guides - Configure pnpm workspace for monorepo development - Update .gitignore to exclude .django_tailwind_cli/ build artifacts - Preserve all historical documentation in shared/docs/memory-bank/ - Set up proper structure for full-stack development with shared resources
This commit is contained in:
0
backend/apps/moderation/__init__.py
Normal file
0
backend/apps/moderation/__init__.py
Normal file
171
backend/apps/moderation/admin.py
Normal file
171
backend/apps/moderation/admin.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
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",
|
||||
"created_at",
|
||||
"handled_by",
|
||||
]
|
||||
list_filter = ["status", "content_type", "created_at"]
|
||||
search_fields = ["user__username", "reason", "source", "notes"]
|
||||
readonly_fields = [
|
||||
"user",
|
||||
"content_type",
|
||||
"object_id",
|
||||
"changes",
|
||||
"created_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)
|
||||
elif obj.status == "REJECTED":
|
||||
obj.reject(request.user)
|
||||
elif obj.status == "ESCALATED":
|
||||
obj.escalate(request.user)
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class PhotoSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"user_link",
|
||||
"content_type",
|
||||
"content_link",
|
||||
"photo_preview",
|
||||
"status",
|
||||
"created_at",
|
||||
"handled_by",
|
||||
]
|
||||
list_filter = ["status", "content_type", "created_at"]
|
||||
search_fields = ["user__username", "caption", "notes"]
|
||||
readonly_fields = [
|
||||
"user",
|
||||
"content_type",
|
||||
"object_id",
|
||||
"photo_preview",
|
||||
"created_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.notes)
|
||||
elif obj.status == "REJECTED":
|
||||
obj.reject(request.user, obj.notes)
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class HistoryEventAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for viewing model history events"""
|
||||
|
||||
list_display = [
|
||||
"pgh_label",
|
||||
"pgh_created_at",
|
||||
"get_object_link",
|
||||
"get_context",
|
||||
]
|
||||
list_filter = ["pgh_label", "pgh_created_at"]
|
||||
readonly_fields = [
|
||||
"pgh_label",
|
||||
"pgh_obj_id",
|
||||
"pgh_data",
|
||||
"pgh_context",
|
||||
"pgh_created_at",
|
||||
]
|
||||
date_hierarchy = "pgh_created_at"
|
||||
|
||||
def get_object_link(self, obj):
|
||||
"""Display a link to the related object if possible"""
|
||||
if obj.pgh_obj and hasattr(obj.pgh_obj, "get_absolute_url"):
|
||||
url = obj.pgh_obj.get_absolute_url()
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.pgh_obj))
|
||||
return str(obj.pgh_obj or "")
|
||||
|
||||
get_object_link.short_description = "Object"
|
||||
|
||||
def get_context(self, obj):
|
||||
"""Format the context data nicely"""
|
||||
if not obj.pgh_context:
|
||||
return "-"
|
||||
html = ["<table>"]
|
||||
for key, value in obj.pgh_context.items():
|
||||
html.append(f"<tr><th>{key}</th><td>{value}</td></tr>")
|
||||
html.append("</table>")
|
||||
return mark_safe("".join(html))
|
||||
|
||||
get_context.short_description = "Context"
|
||||
|
||||
|
||||
# Register with moderation site only
|
||||
moderation_site.register(EditSubmission, EditSubmissionAdmin)
|
||||
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
|
||||
|
||||
# We will register concrete event models as they are created during migrations
|
||||
# Example: moderation_site.register(DesignerEvent, HistoryEventAdmin)
|
||||
7
backend/apps/moderation/apps.py
Normal file
7
backend/apps/moderation/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModerationConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.moderation"
|
||||
verbose_name = "Content Moderation"
|
||||
24
backend/apps/moderation/context_processors.py
Normal file
24
backend/apps/moderation/context_processors.py
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
# Check both role-based and Django's built-in superuser status
|
||||
context["has_moderation_access"] = (
|
||||
request.user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
or request.user.is_superuser
|
||||
)
|
||||
context["has_admin_access"] = (
|
||||
request.user.role in ["ADMIN", "SUPERUSER"] or request.user.is_superuser
|
||||
)
|
||||
context["has_superuser_access"] = (
|
||||
request.user.role == "SUPERUSER" or request.user.is_superuser
|
||||
)
|
||||
|
||||
return context
|
||||
0
backend/apps/moderation/management/__init__.py
Normal file
0
backend/apps/moderation/management/__init__.py
Normal file
261
backend/apps/moderation/management/commands/seed_submissions.py
Normal file
261
backend/apps/moderation/management/commands/seed_submissions.py
Normal file
@@ -0,0 +1,261 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from datetime import date
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seeds test submissions for moderation dashboard"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Ensure we have a test user
|
||||
user, created = User.objects.get_or_create(
|
||||
username="test_user", email="test@example.com"
|
||||
)
|
||||
if created:
|
||||
user.set_password("testpass123")
|
||||
user.save()
|
||||
self.stdout.write(self.style.SUCCESS("Created test user"))
|
||||
|
||||
# Get content types
|
||||
park_ct = ContentType.objects.get_for_model(Park)
|
||||
ride_ct = ContentType.objects.get_for_model(Ride)
|
||||
|
||||
# Create test park for edit submissions
|
||||
test_park, created = Park.objects.get_or_create(
|
||||
name="Test Park",
|
||||
defaults={
|
||||
"description": "A test theme park located in Orlando, Florida",
|
||||
"status": "OPERATING",
|
||||
"operating_season": "Year-round",
|
||||
"size_acres": 100.50,
|
||||
"website": "https://testpark.example.com",
|
||||
},
|
||||
)
|
||||
|
||||
# Create test ride for edit submissions
|
||||
test_ride, created = Ride.objects.get_or_create(
|
||||
name="Test Coaster",
|
||||
park=test_park,
|
||||
defaults={
|
||||
"description": "A thrilling steel roller coaster with multiple inversions",
|
||||
"status": "OPERATING",
|
||||
"category": "RC",
|
||||
"capacity_per_hour": 1200,
|
||||
"ride_duration_seconds": 180,
|
||||
"min_height_in": 48,
|
||||
"opening_date": date(2020, 6, 15),
|
||||
},
|
||||
)
|
||||
|
||||
# Create EditSubmissions
|
||||
|
||||
# New park creation with detailed information
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
submission_type="CREATE",
|
||||
changes={
|
||||
"name": "Adventure World Orlando",
|
||||
"description": (
|
||||
"A brand new theme park coming to Orlando featuring five uniquely themed lands: "
|
||||
"Future Frontier, Ancient Mysteries, Ocean Depths, Sky Kingdom, and Fantasy Forest. "
|
||||
"The park will feature state-of-the-art attractions including 3 roller coasters, "
|
||||
"4 dark rides, and multiple family attractions in each themed area."
|
||||
),
|
||||
"status": "UNDER_CONSTRUCTION",
|
||||
"opening_date": "2024-06-01",
|
||||
"operating_season": "Year-round with extended hours during summer and holidays",
|
||||
"size_acres": 250.75,
|
||||
"website": "https://adventureworld.example.com",
|
||||
"location": {
|
||||
"street_address": "1234 Theme Park Way",
|
||||
"city": "Orlando",
|
||||
"state": "Florida",
|
||||
"country": "United States",
|
||||
"postal_code": "32819",
|
||||
"latitude": "28.538336",
|
||||
"longitude": "-81.379234",
|
||||
},
|
||||
},
|
||||
reason=(
|
||||
"Submitting new theme park details based on official press release and construction permits. "
|
||||
"The park has begun vertical construction and has announced its opening date."
|
||||
),
|
||||
source=(
|
||||
"Official press release: https://adventureworld.example.com/press/announcement\n"
|
||||
"Construction permits: Orange County Building Department #2023-12345"
|
||||
),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Existing park edit with comprehensive updates
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
object_id=test_park.id,
|
||||
submission_type="EDIT",
|
||||
changes={
|
||||
"description": (
|
||||
"A world-class theme park featuring 12 uniquely themed areas and over 50 attractions. "
|
||||
'Recent expansion added the new "Cosmic Adventures" area with 2 roller coasters and '
|
||||
"3 family attractions. The park now offers enhanced dining options and night-time "
|
||||
'spectacular "Starlight Dreams".'
|
||||
),
|
||||
"status": "OPERATING",
|
||||
"website": "https://testpark.example.com",
|
||||
"size_acres": 120.25,
|
||||
"operating_season": (
|
||||
"Year-round with extended hours (9AM-11PM) during summer. "
|
||||
"Special events during Halloween and Christmas seasons."
|
||||
),
|
||||
"location": {
|
||||
"street_address": "5678 Park Boulevard",
|
||||
"city": "Orlando",
|
||||
"state": "Florida",
|
||||
"country": "United States",
|
||||
"postal_code": "32830",
|
||||
"latitude": "28.538336",
|
||||
"longitude": "-81.379234",
|
||||
},
|
||||
},
|
||||
reason=(
|
||||
"Updating park information to reflect recent expansion and operational changes. "
|
||||
"The new Cosmic Adventures area opened last month and operating hours have been extended."
|
||||
),
|
||||
source=(
|
||||
"Park press release: https://testpark.example.com/news/expansion\n"
|
||||
"Official park map: https://testpark.example.com/map\n"
|
||||
"Personal visit and photos from opening day of new area"
|
||||
),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# New ride creation with detailed specifications
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
submission_type="CREATE",
|
||||
changes={
|
||||
"name": "Thunderbolt: The Ultimate Launch Coaster",
|
||||
"park": test_park.id,
|
||||
"description": (
|
||||
"A cutting-edge steel launch coaster featuring the world's tallest inversion (160 ft) "
|
||||
"and fastest launch acceleration (0-80 mph in 2 seconds). The ride features a unique "
|
||||
"triple launch system, 5 inversions including a zero-g roll and cobra roll, and a "
|
||||
"first-of-its-kind vertical helix element. Total track length is 4,500 feet with a "
|
||||
"maximum height of 375 feet."
|
||||
),
|
||||
"status": "UNDER_CONSTRUCTION",
|
||||
"category": "RC",
|
||||
"opening_date": "2024-07-01",
|
||||
"capacity_per_hour": 1400,
|
||||
"ride_duration_seconds": 210,
|
||||
"min_height_in": 52,
|
||||
"manufacturer": 1, # Assuming manufacturer ID
|
||||
"park_area": 1, # Assuming park area ID
|
||||
"stats": {
|
||||
"height_ft": 375,
|
||||
"length_ft": 4500,
|
||||
"speed_mph": 80,
|
||||
"inversions": 5,
|
||||
"launch_type": "LSM",
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"trains_count": 3,
|
||||
"cars_per_train": 6,
|
||||
"seats_per_car": 4,
|
||||
},
|
||||
},
|
||||
reason=(
|
||||
"Submitting details for the new flagship roller coaster announced by the park. "
|
||||
"Construction has begun and track pieces are arriving on site."
|
||||
),
|
||||
source=(
|
||||
"Official announcement: https://testpark.example.com/thunderbolt\n"
|
||||
"Construction photos: https://coasterfan.com/thunderbolt-construction\n"
|
||||
"Manufacturer specifications sheet"
|
||||
),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Existing ride edit with technical updates
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
object_id=test_ride.id,
|
||||
submission_type="EDIT",
|
||||
changes={
|
||||
"description": (
|
||||
"A high-speed steel roller coaster featuring 4 inversions and a unique "
|
||||
"dual-loading station system. Recent upgrades include new magnetic braking "
|
||||
"system and enhanced on-board audio experience."
|
||||
),
|
||||
"status": "OPERATING",
|
||||
"capacity_per_hour": 1500, # Increased after station upgrades
|
||||
"ride_duration_seconds": 185,
|
||||
"min_height_in": 48,
|
||||
"max_height_in": 80,
|
||||
"stats": {
|
||||
"trains_count": 3,
|
||||
"cars_per_train": 8,
|
||||
"seats_per_car": 4,
|
||||
},
|
||||
},
|
||||
reason=(
|
||||
"Updating ride information to reflect recent upgrades including new braking system, "
|
||||
"audio system, and increased capacity due to improved loading efficiency."
|
||||
),
|
||||
source=(
|
||||
"Park operations manual\n"
|
||||
"Maintenance records\n"
|
||||
"Personal observation and timing of new ride cycle"
|
||||
),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Create PhotoSubmissions with detailed captions
|
||||
|
||||
# Park photo submission
|
||||
image_data = b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
|
||||
dummy_image = SimpleUploadedFile(
|
||||
"park_entrance.gif", image_data, content_type="image/gif"
|
||||
)
|
||||
|
||||
PhotoSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
object_id=test_park.id,
|
||||
photo=dummy_image,
|
||||
caption=(
|
||||
"Main entrance plaza of Test Park showing the newly installed digital display board "
|
||||
"and renovated ticketing area. Photo taken during morning park opening."
|
||||
),
|
||||
date_taken=date(2024, 1, 15),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Ride photo submission
|
||||
dummy_image2 = SimpleUploadedFile(
|
||||
"coaster_track.gif", image_data, content_type="image/gif"
|
||||
)
|
||||
PhotoSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
object_id=test_ride.id,
|
||||
photo=dummy_image2,
|
||||
caption=(
|
||||
"Test Coaster's first drop and loop element showing the new paint scheme. "
|
||||
"Photo taken from the guest pathway near Station Alpha."
|
||||
),
|
||||
date_taken=date(2024, 1, 20),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully seeded test submissions"))
|
||||
497
backend/apps/moderation/migrations/0001_initial.py
Normal file
497
backend/apps/moderation/migrations/0001_initial.py
Normal file
@@ -0,0 +1,497 @@
|
||||
# Generated by Django 5.1.4 on 2025-08-13 21:35
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
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",
|
||||
),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"object_id",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"submission_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("EDIT", "Edit Existing"),
|
||||
("CREATE", "Create New"),
|
||||
],
|
||||
default="EDIT",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"changes",
|
||||
models.JSONField(
|
||||
help_text="JSON representation of the changes or new object data"
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderator_changes",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="Moderator's edited version of the changes before approval",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"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(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("handled_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"handled_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="handled_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": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="EditSubmissionEvent",
|
||||
fields=[
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"object_id",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"submission_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("EDIT", "Edit Existing"),
|
||||
("CREATE", "Create New"),
|
||||
],
|
||||
default="EDIT",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"changes",
|
||||
models.JSONField(
|
||||
help_text="JSON representation of the changes or new object data"
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderator_changes",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="Moderator's edited version of the changes before approval",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"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(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("handled_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from the moderator about this submission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"handled_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="moderation.editsubmission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PhotoSubmission",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("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"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("handled_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"handled_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="handled_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": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PhotoSubmissionEvent",
|
||||
fields=[
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("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"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("handled_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"handled_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="moderation.photosubmission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="editsubmission",
|
||||
index=models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="moderation__content_922d2b_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="editsubmission",
|
||||
index=models.Index(fields=["status"], name="moderation__status_e4eb2b_idx"),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="editsubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_2c796",
|
||||
table="moderation_editsubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="editsubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_ab38f",
|
||||
table="moderation_editsubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="photosubmission",
|
||||
index=models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="moderation__content_7a7bc1_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="photosubmission",
|
||||
index=models.Index(fields=["status"], name="moderation__status_7a1914_idx"),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="photosubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_62865",
|
||||
table="moderation_photosubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="photosubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_9c311",
|
||||
table="moderation_photosubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
0
backend/apps/moderation/migrations/__init__.py
Normal file
0
backend/apps/moderation/migrations/__init__.py
Normal file
314
backend/apps/moderation/mixins.py
Normal file
314
backend/apps/moderation/mixins.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from typing import Any, Dict, Optional, Type, cast
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import (
|
||||
JsonResponse,
|
||||
HttpResponseForbidden,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
)
|
||||
from django.views.generic import DetailView
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
import json
|
||||
from .models import EditSubmission, PhotoSubmission, UserType
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class EditSubmissionMixin(DetailView):
|
||||
"""
|
||||
Mixin for handling edit submissions with proper moderation.
|
||||
"""
|
||||
|
||||
model: Optional[Type[models.Model]] = None
|
||||
|
||||
def handle_edit_submission(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
changes: Dict[str, Any],
|
||||
reason: str = "",
|
||||
source: str = "",
|
||||
submission_type: str = "EDIT",
|
||||
) -> JsonResponse:
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
if not self.model:
|
||||
raise ValueError("model attribute must be set")
|
||||
|
||||
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 = getattr(obj, "id", None)
|
||||
|
||||
# Auto-approve for moderators and above
|
||||
user_role = getattr(request.user, "role", None)
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
obj = submission.approve(cast(UserType, request.user))
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Changes saved successfully.",
|
||||
"auto_approved": True,
|
||||
"redirect_url": getattr(obj, "get_absolute_url", lambda: 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: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse:
|
||||
"""Handle POST requests for editing"""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "You must be logged in to make edits.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
user_role = getattr(request.user, "role", None)
|
||||
if not reason and 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(DetailView):
|
||||
"""
|
||||
Mixin for handling photo submissions with proper moderation.
|
||||
"""
|
||||
|
||||
model: Optional[Type[models.Model]] = None
|
||||
|
||||
def handle_photo_submission(self, request: HttpRequest) -> JsonResponse:
|
||||
"""Handle a photo submission based on user's role"""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "You must be logged in to upload photos.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
if not self.model:
|
||||
raise ValueError("model attribute must be set")
|
||||
|
||||
try:
|
||||
obj = self.get_object()
|
||||
except (AttributeError, self.model.DoesNotExist):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid object."}, status=400
|
||||
)
|
||||
|
||||
if not request.FILES.get("photo"):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "No photo provided."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
submission = PhotoSubmission(
|
||||
user=request.user,
|
||||
content_type=content_type,
|
||||
object_id=getattr(obj, "id", None),
|
||||
photo=request.FILES["photo"],
|
||||
caption=request.POST.get("caption", ""),
|
||||
date_taken=request.POST.get("date_taken"),
|
||||
)
|
||||
|
||||
# Auto-approve for moderators and above
|
||||
user_role = getattr(request.user, "role", None)
|
||||
if 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"""
|
||||
|
||||
request: Optional[HttpRequest] = None
|
||||
|
||||
def test_func(self) -> bool:
|
||||
if not self.request:
|
||||
return False
|
||||
user_role = getattr(self.request.user, "role", None)
|
||||
return self.request.user.is_authenticated and user_role in [
|
||||
"MODERATOR",
|
||||
"ADMIN",
|
||||
"SUPERUSER",
|
||||
]
|
||||
|
||||
def handle_no_permission(self) -> HttpResponse:
|
||||
if not self.request or 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"""
|
||||
|
||||
request: Optional[HttpRequest] = None
|
||||
|
||||
def test_func(self) -> bool:
|
||||
if not self.request:
|
||||
return False
|
||||
user_role = getattr(self.request.user, "role", None)
|
||||
return self.request.user.is_authenticated and user_role in [
|
||||
"ADMIN",
|
||||
"SUPERUSER",
|
||||
]
|
||||
|
||||
def handle_no_permission(self) -> HttpResponse:
|
||||
if not self.request or 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"""
|
||||
|
||||
request: Optional[HttpRequest] = None
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs) # type: ignore
|
||||
if self.request and self.request.user.is_authenticated:
|
||||
context["can_edit"] = True
|
||||
user_role = getattr(self.request.user, "role", None)
|
||||
context["can_auto_approve"] = user_role in [
|
||||
"MODERATOR",
|
||||
"ADMIN",
|
||||
"SUPERUSER",
|
||||
]
|
||||
|
||||
if isinstance(self, DetailView):
|
||||
obj = self.get_object() # type: ignore
|
||||
context["pending_edits"] = (
|
||||
EditSubmission.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(obj.__class__),
|
||||
object_id=getattr(obj, "id", None),
|
||||
status="NEW",
|
||||
)
|
||||
.select_related("user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class HistoryMixin:
|
||||
"""Add edit history context to views"""
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs) # type: ignore
|
||||
|
||||
# Only add history context for DetailViews
|
||||
if isinstance(self, DetailView):
|
||||
obj = self.get_object() # type: ignore
|
||||
|
||||
# Get historical records ordered by date if available
|
||||
try:
|
||||
# Use pghistory's get_history method
|
||||
context["history"] = obj.get_history()
|
||||
except (AttributeError, TypeError):
|
||||
context["history"] = []
|
||||
|
||||
# Get related edit submissions
|
||||
content_type = ContentType.objects.get_for_model(obj.__class__)
|
||||
context["edit_submissions"] = (
|
||||
EditSubmission.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=getattr(obj, "id", None),
|
||||
)
|
||||
.exclude(status="NEW")
|
||||
.select_related("user", "handled_by")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
return context
|
||||
328
backend/apps/moderation/models.py
Normal file
328
backend/apps/moderation/models.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from typing import Any, Dict, Optional, Type, Union
|
||||
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.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
import pghistory
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
UserType = Union[AbstractBaseUser, AnonymousUser]
|
||||
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class EditSubmission(TrackedModel):
|
||||
STATUS_CHOICES = [
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
]
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
# Moderator's edited version of changes before approval
|
||||
moderator_changes = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Moderator's edited version of the changes before approval",
|
||||
)
|
||||
|
||||
# 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")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
handled_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="handled_submissions",
|
||||
)
|
||||
handled_at = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(
|
||||
blank=True, help_text="Notes from the moderator about this submission"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["status"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
action = "creation" if self.submission_type == "CREATE" else "edit"
|
||||
if model_class := self.content_type.model_class():
|
||||
target = self.content_object or model_class.__name__
|
||||
else:
|
||||
target = "Unknown"
|
||||
return f"{action} by {self.user.username} on {target}"
|
||||
|
||||
def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert foreign key IDs to model instances"""
|
||||
if not (model_class := self.content_type.model_class()):
|
||||
raise ValueError("Could not resolve model class")
|
||||
|
||||
resolved_data = data.copy()
|
||||
|
||||
for field_name, value in data.items():
|
||||
try:
|
||||
if (
|
||||
(field := model_class._meta.get_field(field_name))
|
||||
and isinstance(field, models.ForeignKey)
|
||||
and value is not None
|
||||
):
|
||||
if related_model := field.related_model:
|
||||
resolved_data[field_name] = related_model.objects.get(id=value)
|
||||
except (FieldDoesNotExist, ObjectDoesNotExist):
|
||||
continue
|
||||
|
||||
return resolved_data
|
||||
|
||||
def _prepare_model_data(
|
||||
self, data: Dict[str, Any], model_class: Type[models.Model]
|
||||
) -> Dict[str, Any]:
|
||||
"""Prepare data for model creation/update by filtering out auto-generated fields"""
|
||||
prepared_data = data.copy()
|
||||
|
||||
# Remove fields that are auto-generated or handled by the model's save
|
||||
# method
|
||||
auto_fields = {"created_at", "updated_at", "slug"}
|
||||
for field in auto_fields:
|
||||
prepared_data.pop(field, None)
|
||||
|
||||
# Set default values for required fields if not provided
|
||||
for field in model_class._meta.fields:
|
||||
if not field.auto_created and not field.blank and not field.null:
|
||||
if field.name not in prepared_data and field.has_default():
|
||||
prepared_data[field.name] = field.get_default()
|
||||
|
||||
return prepared_data
|
||||
|
||||
def _check_duplicate_name(
|
||||
self, model_class: Type[models.Model], name: str
|
||||
) -> Optional[models.Model]:
|
||||
"""Check if an object with the same name already exists"""
|
||||
try:
|
||||
return model_class.objects.filter(name=name).first()
|
||||
except BaseException:
|
||||
return None
|
||||
|
||||
def approve(self, user: UserType) -> Optional[models.Model]:
|
||||
"""Approve the submission and apply the changes"""
|
||||
if not (model_class := self.content_type.model_class()):
|
||||
raise ValueError("Could not resolve model class")
|
||||
|
||||
try:
|
||||
# Use moderator_changes if available, otherwise use original
|
||||
# changes
|
||||
changes_to_apply = (
|
||||
self.moderator_changes
|
||||
if self.moderator_changes is not None
|
||||
else self.changes
|
||||
)
|
||||
|
||||
resolved_data = self._resolve_foreign_keys(changes_to_apply)
|
||||
prepared_data = self._prepare_model_data(resolved_data, model_class)
|
||||
|
||||
# For CREATE submissions, check for duplicates by name
|
||||
if self.submission_type == "CREATE" and "name" in prepared_data:
|
||||
if existing_obj := self._check_duplicate_name(
|
||||
model_class, prepared_data["name"]
|
||||
):
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"A {
|
||||
model_class.__name__} with the name '{
|
||||
prepared_data['name']}' already exists (ID: {
|
||||
existing_obj.id})"
|
||||
self.save()
|
||||
raise ValueError(self.notes)
|
||||
|
||||
self.status = "APPROVED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
|
||||
if self.submission_type == "CREATE":
|
||||
# Create new object
|
||||
obj = model_class(**prepared_data)
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
# Update object_id after creation
|
||||
self.object_id = getattr(obj, "id", None)
|
||||
else:
|
||||
# Apply changes to existing object
|
||||
if not (obj := self.content_object):
|
||||
raise ValueError("Content object not found")
|
||||
for field, value in prepared_data.items():
|
||||
setattr(obj, field, value)
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
self.full_clean()
|
||||
self.save()
|
||||
return obj
|
||||
except Exception as e:
|
||||
if (
|
||||
self.status != "REJECTED"
|
||||
): # Don't override if already rejected due to duplicate
|
||||
self.status = "PENDING" # Reset status if approval failed
|
||||
self.save()
|
||||
raise ValueError(f"Error approving submission: {str(e)}") from e
|
||||
|
||||
def reject(self, user: UserType) -> None:
|
||||
"""Reject the submission"""
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def escalate(self, user: UserType) -> None:
|
||||
"""Escalate the submission to admin"""
|
||||
self.status = "ESCALATED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class PhotoSubmission(TrackedModel):
|
||||
STATUS_CHOICES = [
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
]
|
||||
|
||||
# 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")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
handled_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="handled_photos",
|
||||
)
|
||||
handled_at = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["status"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Photo submission by {
|
||||
self.user.username} for {
|
||||
self.content_object}"
|
||||
|
||||
def approve(self, moderator: UserType, notes: str = "") -> None:
|
||||
"""Approve the photo submission"""
|
||||
from apps.media.models import Photo
|
||||
|
||||
self.status = "APPROVED"
|
||||
self.handled_by = moderator # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
|
||||
# Create the approved photo
|
||||
Photo.objects.create(
|
||||
uploaded_by=self.user,
|
||||
content_type=self.content_type,
|
||||
object_id=self.object_id,
|
||||
image=self.photo,
|
||||
caption=self.caption,
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def reject(self, moderator: UserType, notes: str) -> None:
|
||||
"""Reject the photo submission"""
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = moderator # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
|
||||
def auto_approve(self) -> None:
|
||||
"""Auto-approve submissions from moderators"""
|
||||
# Get user role safely
|
||||
user_role = getattr(self.user, "role", None)
|
||||
|
||||
# If user is moderator or above, auto-approve
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
self.approve(self.user)
|
||||
|
||||
def escalate(self, moderator: UserType, notes: str = "") -> None:
|
||||
"""Escalate the photo submission to admin"""
|
||||
self.status = "ESCALATED"
|
||||
self.handled_by = moderator # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
278
backend/apps/moderation/selectors.py
Normal file
278
backend/apps/moderation/selectors.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Selectors for moderation-related data retrieval.
|
||||
Following Django styleguide pattern for separating data access from business logic.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from django.db.models import QuerySet, Count
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from .models import EditSubmission
|
||||
|
||||
|
||||
def pending_submissions_for_review(
|
||||
*, content_type: Optional[str] = None, limit: int = 50
|
||||
) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get pending submissions that need moderation review.
|
||||
|
||||
Args:
|
||||
content_type: Optional filter by content type name
|
||||
limit: Maximum number of submissions to return
|
||||
|
||||
Returns:
|
||||
QuerySet of pending submissions ordered by submission date
|
||||
"""
|
||||
queryset = (
|
||||
EditSubmission.objects.filter(status="PENDING")
|
||||
.select_related("submitted_by", "content_type")
|
||||
.prefetch_related("content_object")
|
||||
)
|
||||
|
||||
if content_type:
|
||||
queryset = queryset.filter(content_type__model=content_type.lower())
|
||||
|
||||
return queryset.order_by("submitted_at")[:limit]
|
||||
|
||||
|
||||
def submissions_by_user(
|
||||
*, user_id: int, status: Optional[str] = None
|
||||
) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get submissions created by a specific user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user who submitted
|
||||
status: Optional filter by submission status
|
||||
|
||||
Returns:
|
||||
QuerySet of user's submissions
|
||||
"""
|
||||
queryset = EditSubmission.objects.filter(submitted_by_id=user_id).select_related(
|
||||
"content_type", "handled_by"
|
||||
)
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset.order_by("-submitted_at")
|
||||
|
||||
|
||||
def submissions_handled_by_moderator(
|
||||
*, moderator_id: int, days: int = 30
|
||||
) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get submissions handled by a specific moderator in the last N days.
|
||||
|
||||
Args:
|
||||
moderator_id: ID of the moderator
|
||||
days: Number of days to look back
|
||||
|
||||
Returns:
|
||||
QuerySet of submissions handled by the moderator
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
EditSubmission.objects.filter(
|
||||
handled_by_id=moderator_id, handled_at__gte=cutoff_date
|
||||
)
|
||||
.select_related("submitted_by", "content_type")
|
||||
.order_by("-handled_at")
|
||||
)
|
||||
|
||||
|
||||
def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get recent submissions from the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to look back
|
||||
|
||||
Returns:
|
||||
QuerySet of recent submissions
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
EditSubmission.objects.filter(submitted_at__gte=cutoff_date)
|
||||
.select_related("submitted_by", "content_type", "handled_by")
|
||||
.order_by("-submitted_at")
|
||||
)
|
||||
|
||||
|
||||
def submissions_by_content_type(
|
||||
*, content_type: str, status: Optional[str] = None
|
||||
) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get submissions for a specific content type.
|
||||
|
||||
Args:
|
||||
content_type: Name of the content type (e.g., 'park', 'ride')
|
||||
status: Optional filter by submission status
|
||||
|
||||
Returns:
|
||||
QuerySet of submissions for the content type
|
||||
"""
|
||||
queryset = EditSubmission.objects.filter(
|
||||
content_type__model=content_type.lower()
|
||||
).select_related("submitted_by", "handled_by")
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset.order_by("-submitted_at")
|
||||
|
||||
|
||||
def moderation_queue_summary() -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary statistics for the moderation queue.
|
||||
|
||||
Returns:
|
||||
Dictionary containing queue statistics
|
||||
"""
|
||||
pending_count = EditSubmission.objects.filter(status="PENDING").count()
|
||||
approved_today = EditSubmission.objects.filter(
|
||||
status="APPROVED", handled_at__date=timezone.now().date()
|
||||
).count()
|
||||
rejected_today = EditSubmission.objects.filter(
|
||||
status="REJECTED", handled_at__date=timezone.now().date()
|
||||
).count()
|
||||
|
||||
# Submissions by content type
|
||||
submissions_by_type = (
|
||||
EditSubmission.objects.filter(status="PENDING")
|
||||
.values("content_type__model")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
return {
|
||||
"pending_count": pending_count,
|
||||
"approved_today": approved_today,
|
||||
"rejected_today": rejected_today,
|
||||
"submissions_by_type": list(submissions_by_type),
|
||||
}
|
||||
|
||||
|
||||
def moderation_statistics_summary(
|
||||
*, days: int = 30, moderator: Optional[User] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive moderation statistics for a time period.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
moderator: Optional filter by specific moderator
|
||||
|
||||
Returns:
|
||||
Dictionary containing detailed moderation statistics
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
base_queryset = EditSubmission.objects.filter(submitted_at__gte=cutoff_date)
|
||||
|
||||
if moderator:
|
||||
handled_queryset = base_queryset.filter(handled_by=moderator)
|
||||
else:
|
||||
handled_queryset = base_queryset
|
||||
|
||||
total_submissions = base_queryset.count()
|
||||
pending_submissions = base_queryset.filter(status="PENDING").count()
|
||||
approved_submissions = handled_queryset.filter(status="APPROVED").count()
|
||||
rejected_submissions = handled_queryset.filter(status="REJECTED").count()
|
||||
|
||||
# Response time analysis (only for handled submissions)
|
||||
handled_with_times = (
|
||||
handled_queryset.exclude(handled_at__isnull=True)
|
||||
.extra(
|
||||
select={
|
||||
"response_hours": "EXTRACT(EPOCH FROM (handled_at - submitted_at)) / 3600"
|
||||
}
|
||||
)
|
||||
.values_list("response_hours", flat=True)
|
||||
)
|
||||
|
||||
avg_response_time = None
|
||||
if handled_with_times:
|
||||
avg_response_time = sum(handled_with_times) / len(handled_with_times)
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"total_submissions": total_submissions,
|
||||
"pending_submissions": pending_submissions,
|
||||
"approved_submissions": approved_submissions,
|
||||
"rejected_submissions": rejected_submissions,
|
||||
"approval_rate": (
|
||||
(approved_submissions / (approved_submissions + rejected_submissions) * 100)
|
||||
if (approved_submissions + rejected_submissions) > 0
|
||||
else 0
|
||||
),
|
||||
"average_response_time_hours": avg_response_time,
|
||||
"moderator": moderator.username if moderator else None,
|
||||
}
|
||||
|
||||
|
||||
def submissions_needing_attention(*, hours: int = 24) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get pending submissions that have been waiting for more than N hours.
|
||||
|
||||
Args:
|
||||
hours: Number of hours threshold for attention
|
||||
|
||||
Returns:
|
||||
QuerySet of submissions needing attention
|
||||
"""
|
||||
cutoff_time = timezone.now() - timedelta(hours=hours)
|
||||
|
||||
return (
|
||||
EditSubmission.objects.filter(status="PENDING", submitted_at__lte=cutoff_time)
|
||||
.select_related("submitted_by", "content_type")
|
||||
.order_by("submitted_at")
|
||||
)
|
||||
|
||||
|
||||
def top_contributors(*, days: int = 30, limit: int = 10) -> QuerySet[User]:
|
||||
"""
|
||||
Get users who have submitted the most content in the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
limit: Maximum number of users to return
|
||||
|
||||
Returns:
|
||||
QuerySet of top contributing users
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
User.objects.filter(edit_submissions__submitted_at__gte=cutoff_date)
|
||||
.annotate(submission_count=Count("edit_submissions"))
|
||||
.filter(submission_count__gt=0)
|
||||
.order_by("-submission_count")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def moderator_workload_summary(*, days: int = 30) -> Dict[str, Any]:
|
||||
"""
|
||||
Get workload distribution among moderators.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary containing moderator workload statistics
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
moderator_stats = (
|
||||
User.objects.filter(handled_submissions__handled_at__gte=cutoff_date)
|
||||
.annotate(handled_count=Count("handled_submissions"))
|
||||
.filter(handled_count__gt=0)
|
||||
.order_by("-handled_count")
|
||||
.values("username", "handled_count")
|
||||
)
|
||||
|
||||
return {"period_days": days, "moderator_stats": list(moderator_stats)}
|
||||
230
backend/apps/moderation/services.py
Normal file
230
backend/apps/moderation/services.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Services for moderation functionality.
|
||||
Following Django styleguide pattern for business logic encapsulation.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from .models import EditSubmission
|
||||
|
||||
|
||||
class ModerationService:
|
||||
"""Service for handling content moderation workflows."""
|
||||
|
||||
@staticmethod
|
||||
def approve_submission(
|
||||
*, submission_id: int, moderator: User, notes: Optional[str] = None
|
||||
) -> Union[object, None]:
|
||||
"""
|
||||
Approve a content submission and apply changes.
|
||||
|
||||
Args:
|
||||
submission_id: ID of the submission to approve
|
||||
moderator: User performing the approval
|
||||
notes: Optional notes about the approval
|
||||
|
||||
Returns:
|
||||
The created/updated object or None if approval failed
|
||||
|
||||
Raises:
|
||||
EditSubmission.DoesNotExist: If submission doesn't exist
|
||||
ValidationError: If submission data is invalid
|
||||
ValueError: If submission cannot be processed
|
||||
"""
|
||||
with transaction.atomic():
|
||||
submission = EditSubmission.objects.select_for_update().get(
|
||||
id=submission_id
|
||||
)
|
||||
|
||||
if submission.status != "PENDING":
|
||||
raise ValueError(f"Submission {submission_id} is not pending approval")
|
||||
|
||||
try:
|
||||
# Call the model's approve method which handles the business
|
||||
# logic
|
||||
obj = submission.approve(moderator)
|
||||
|
||||
# Add moderator notes if provided
|
||||
if notes:
|
||||
if submission.notes:
|
||||
submission.notes += f"\n[Moderator]: {notes}"
|
||||
else:
|
||||
submission.notes = f"[Moderator]: {notes}"
|
||||
submission.save()
|
||||
|
||||
return obj
|
||||
|
||||
except Exception as e:
|
||||
# Mark as rejected on any error
|
||||
submission.status = "REJECTED"
|
||||
submission.handled_by = moderator
|
||||
submission.handled_at = timezone.now()
|
||||
submission.notes = f"Approval failed: {str(e)}"
|
||||
submission.save()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def reject_submission(
|
||||
*, submission_id: int, moderator: User, reason: str
|
||||
) -> EditSubmission:
|
||||
"""
|
||||
Reject a content submission.
|
||||
|
||||
Args:
|
||||
submission_id: ID of the submission to reject
|
||||
moderator: User performing the rejection
|
||||
reason: Reason for rejection
|
||||
|
||||
Returns:
|
||||
Updated submission object
|
||||
|
||||
Raises:
|
||||
EditSubmission.DoesNotExist: If submission doesn't exist
|
||||
ValueError: If submission cannot be rejected
|
||||
"""
|
||||
with transaction.atomic():
|
||||
submission = EditSubmission.objects.select_for_update().get(
|
||||
id=submission_id
|
||||
)
|
||||
|
||||
if submission.status != "PENDING":
|
||||
raise ValueError(f"Submission {submission_id} is not pending review")
|
||||
|
||||
submission.status = "REJECTED"
|
||||
submission.handled_by = moderator
|
||||
submission.handled_at = timezone.now()
|
||||
submission.notes = f"Rejected: {reason}"
|
||||
|
||||
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
return submission
|
||||
|
||||
@staticmethod
|
||||
def create_edit_submission(
|
||||
*,
|
||||
content_object: object,
|
||||
changes: Dict[str, Any],
|
||||
submitter: User,
|
||||
submission_type: str = "UPDATE",
|
||||
notes: Optional[str] = None,
|
||||
) -> EditSubmission:
|
||||
"""
|
||||
Create a new edit submission for moderation.
|
||||
|
||||
Args:
|
||||
content_object: The object being edited
|
||||
changes: Dictionary of field changes
|
||||
submitter: User submitting the changes
|
||||
submission_type: Type of submission ("CREATE" or "UPDATE")
|
||||
notes: Optional notes about the submission
|
||||
|
||||
Returns:
|
||||
Created EditSubmission object
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission data is invalid
|
||||
"""
|
||||
submission = EditSubmission(
|
||||
content_object=content_object,
|
||||
changes=changes,
|
||||
submitted_by=submitter,
|
||||
submission_type=submission_type,
|
||||
notes=notes or "",
|
||||
)
|
||||
|
||||
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
return submission
|
||||
|
||||
@staticmethod
|
||||
def update_submission_changes(
|
||||
*,
|
||||
submission_id: int,
|
||||
moderator_changes: Dict[str, Any],
|
||||
moderator: User,
|
||||
) -> EditSubmission:
|
||||
"""
|
||||
Update submission with moderator changes before approval.
|
||||
|
||||
Args:
|
||||
submission_id: ID of the submission to update
|
||||
moderator_changes: Dictionary of moderator modifications
|
||||
moderator: User making the changes
|
||||
|
||||
Returns:
|
||||
Updated submission object
|
||||
|
||||
Raises:
|
||||
EditSubmission.DoesNotExist: If submission doesn't exist
|
||||
ValueError: If submission cannot be modified
|
||||
"""
|
||||
with transaction.atomic():
|
||||
submission = EditSubmission.objects.select_for_update().get(
|
||||
id=submission_id
|
||||
)
|
||||
|
||||
if submission.status != "PENDING":
|
||||
raise ValueError(f"Submission {submission_id} is not pending review")
|
||||
|
||||
submission.moderator_changes = moderator_changes
|
||||
|
||||
# Add note about moderator changes
|
||||
note = f"[Moderator changes by {moderator.username}]"
|
||||
if submission.notes:
|
||||
submission.notes += f"\n{note}"
|
||||
else:
|
||||
submission.notes = note
|
||||
|
||||
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
return submission
|
||||
|
||||
@staticmethod
|
||||
def get_pending_submissions_for_moderator(
|
||||
*,
|
||||
moderator: User,
|
||||
content_type: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> QuerySet:
|
||||
"""
|
||||
Get pending submissions for a moderator to review.
|
||||
|
||||
Args:
|
||||
moderator: The moderator user
|
||||
content_type: Optional filter by content type
|
||||
limit: Maximum number of submissions to return
|
||||
|
||||
Returns:
|
||||
QuerySet of pending submissions
|
||||
"""
|
||||
from .selectors import pending_submissions_for_review
|
||||
|
||||
return pending_submissions_for_review(content_type=content_type, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def get_submission_statistics(
|
||||
*, days: int = 30, moderator: Optional[User] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get moderation statistics for a time period.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
moderator: Optional filter by specific moderator
|
||||
|
||||
Returns:
|
||||
Dictionary containing moderation statistics
|
||||
"""
|
||||
from .selectors import moderation_statistics_summary
|
||||
|
||||
return moderation_statistics_summary(days=days, moderator=moderator)
|
||||
69
backend/apps/moderation/templatetags/moderation_tags.py
Normal file
69
backend/apps/moderation/templatetags/moderation_tags.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from django import template
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_object_name(value: Optional[int], model_path: str) -> Optional[str]:
|
||||
"""Get object name from ID and model path."""
|
||||
if not value or not model_path or "." not in model_path:
|
||||
return None
|
||||
|
||||
app_label, model = model_path.split(".")
|
||||
try:
|
||||
content_type = ContentType.objects.get(
|
||||
app_label=app_label.lower(), model=model.lower()
|
||||
)
|
||||
model_class = content_type.model_class()
|
||||
if not model_class:
|
||||
return None
|
||||
|
||||
obj = model_class.objects.filter(id=value).first()
|
||||
return str(obj) if obj else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_category_display(value: Optional[str]) -> Optional[str]:
|
||||
"""Get display value for ride category."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
categories = {
|
||||
"RC": "Roller Coaster",
|
||||
"DR": "Dark Ride",
|
||||
"FR": "Flat Ride",
|
||||
"WR": "Water Ride",
|
||||
"TR": "Transport",
|
||||
"OT": "Other",
|
||||
}
|
||||
return categories.get(value)
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_park_area_name(value: Optional[int], park_id: Optional[int]) -> Optional[str]:
|
||||
"""Get park area name from ID and park ID."""
|
||||
if not value or not park_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
from apps.parks.models import ParkArea
|
||||
|
||||
area = ParkArea.objects.filter(id=value, park_id=park_id).first()
|
||||
return str(area) if area else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(
|
||||
dictionary: Optional[Dict[str, Any]], key: Optional[Union[str, int]]
|
||||
) -> List[Any]:
|
||||
"""Get item from dictionary by key."""
|
||||
if not dictionary or not isinstance(dictionary, dict) or not key:
|
||||
return []
|
||||
|
||||
return dictionary.get(str(key), [])
|
||||
349
backend/apps/moderation/tests.py
Normal file
349
backend/apps/moderation/tests.py
Normal file
@@ -0,0 +1,349 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.http import JsonResponse, HttpRequest
|
||||
from .models import EditSubmission
|
||||
from .mixins import (
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
ModeratorRequiredMixin,
|
||||
AdminRequiredMixin,
|
||||
InlineEditMixin,
|
||||
HistoryMixin,
|
||||
)
|
||||
from apps.parks.models import Company as Operator
|
||||
from django.views.generic import DetailView
|
||||
from django.test import RequestFactory
|
||||
import json
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestView(
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
InlineEditMixin,
|
||||
HistoryMixin,
|
||||
DetailView,
|
||||
):
|
||||
model = Operator
|
||||
template_name = "test.html"
|
||||
pk_url_kwarg = "pk"
|
||||
slug_url_kwarg = "slug"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
if not hasattr(self, "object"):
|
||||
self.object = self.get_object()
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def setup(self, request: HttpRequest, *args, **kwargs):
|
||||
super().setup(request, *args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
|
||||
class ModerationMixinsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
# Create users with different roles
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
self.moderator = User.objects.create_user(
|
||||
username="moderator",
|
||||
email="moderator@example.com",
|
||||
password="modpass123",
|
||||
role="MODERATOR",
|
||||
)
|
||||
self.admin = User.objects.create_user(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="adminpass123",
|
||||
role="ADMIN",
|
||||
)
|
||||
|
||||
# Create test company
|
||||
self.operator = Operator.objects.create(
|
||||
name="Test Operator",
|
||||
website="http://example.com",
|
||||
description="Test Description",
|
||||
)
|
||||
|
||||
def test_edit_submission_mixin_unauthenticated(self):
|
||||
"""Test edit submission when not logged in"""
|
||||
view = TestView()
|
||||
request = self.factory.post(f"/test/{self.operator.pk}/")
|
||||
request.user = AnonymousUser()
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
response = view.handle_edit_submission(request, {})
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_edit_submission_mixin_no_changes(self):
|
||||
"""Test edit submission with no changes"""
|
||||
view = TestView()
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/",
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
)
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
response = view.post(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_edit_submission_mixin_invalid_json(self):
|
||||
"""Test edit submission with invalid JSON"""
|
||||
view = TestView()
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/",
|
||||
data="invalid json",
|
||||
content_type="application/json",
|
||||
)
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
response = view.post(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_edit_submission_mixin_regular_user(self):
|
||||
"""Test edit submission as regular user"""
|
||||
view = TestView()
|
||||
request = self.factory.post(f"/test/{self.operator.pk}/")
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
changes = {"name": "New Name"}
|
||||
response = view.handle_edit_submission(
|
||||
request, changes, "Test reason", "Test source"
|
||||
)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content.decode())
|
||||
self.assertFalse(data["auto_approved"])
|
||||
|
||||
def test_edit_submission_mixin_moderator(self):
|
||||
"""Test edit submission as moderator"""
|
||||
view = TestView()
|
||||
request = self.factory.post(f"/test/{self.operator.pk}/")
|
||||
request.user = self.moderator
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
changes = {"name": "New Name"}
|
||||
response = view.handle_edit_submission(
|
||||
request, changes, "Test reason", "Test source"
|
||||
)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content.decode())
|
||||
self.assertTrue(data["auto_approved"])
|
||||
|
||||
def test_photo_submission_mixin_unauthenticated(self):
|
||||
"""Test photo submission when not logged in"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/", data={}, format="multipart"
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
response = view.handle_photo_submission(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_photo_submission_mixin_no_photo(self):
|
||||
"""Test photo submission with no photo"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/", data={}, format="multipart"
|
||||
)
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
response = view.handle_photo_submission(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_photo_submission_mixin_regular_user(self):
|
||||
"""Test photo submission as regular user"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
# Create a test photo file
|
||||
photo = SimpleUploadedFile(
|
||||
"test.gif",
|
||||
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;",
|
||||
content_type="image/gif",
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/",
|
||||
data={
|
||||
"photo": photo,
|
||||
"caption": "Test Photo",
|
||||
"date_taken": "2024-01-01",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
|
||||
response = view.handle_photo_submission(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content.decode())
|
||||
self.assertFalse(data["auto_approved"])
|
||||
|
||||
def test_photo_submission_mixin_moderator(self):
|
||||
"""Test photo submission as moderator"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
# Create a test photo file
|
||||
photo = SimpleUploadedFile(
|
||||
"test.gif",
|
||||
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;",
|
||||
content_type="image/gif",
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/",
|
||||
data={
|
||||
"photo": photo,
|
||||
"caption": "Test Photo",
|
||||
"date_taken": "2024-01-01",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
request.user = self.moderator
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
|
||||
response = view.handle_photo_submission(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content.decode())
|
||||
self.assertTrue(data["auto_approved"])
|
||||
|
||||
def test_moderator_required_mixin(self):
|
||||
"""Test moderator required mixin"""
|
||||
|
||||
class TestModeratorView(ModeratorRequiredMixin):
|
||||
pass
|
||||
|
||||
view = TestModeratorView()
|
||||
|
||||
# Test unauthenticated user
|
||||
request = self.factory.get("/test/")
|
||||
request.user = AnonymousUser()
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test regular user
|
||||
request.user = self.user
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test moderator
|
||||
request.user = self.moderator
|
||||
view.request = request
|
||||
self.assertTrue(view.test_func())
|
||||
|
||||
# Test admin
|
||||
request.user = self.admin
|
||||
view.request = request
|
||||
self.assertTrue(view.test_func())
|
||||
|
||||
def test_admin_required_mixin(self):
|
||||
"""Test admin required mixin"""
|
||||
|
||||
class TestAdminView(AdminRequiredMixin):
|
||||
pass
|
||||
|
||||
view = TestAdminView()
|
||||
|
||||
# Test unauthenticated user
|
||||
request = self.factory.get("/test/")
|
||||
request.user = AnonymousUser()
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test regular user
|
||||
request.user = self.user
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test moderator
|
||||
request.user = self.moderator
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test admin
|
||||
request.user = self.admin
|
||||
view.request = request
|
||||
self.assertTrue(view.test_func())
|
||||
|
||||
def test_inline_edit_mixin(self):
|
||||
"""Test inline edit mixin"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
# Test unauthenticated user
|
||||
request = self.factory.get(f"/test/{self.operator.pk}/")
|
||||
request.user = AnonymousUser()
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
context = view.get_context_data()
|
||||
self.assertNotIn("can_edit", context)
|
||||
|
||||
# Test regular user
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
context = view.get_context_data()
|
||||
self.assertTrue(context["can_edit"])
|
||||
self.assertFalse(context["can_auto_approve"])
|
||||
|
||||
# Test moderator
|
||||
request.user = self.moderator
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
context = view.get_context_data()
|
||||
self.assertTrue(context["can_edit"])
|
||||
self.assertTrue(context["can_auto_approve"])
|
||||
|
||||
def test_history_mixin(self):
|
||||
"""Test history mixin"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
request = self.factory.get(f"/test/{self.operator.pk}/")
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
|
||||
# Create some edit submissions
|
||||
EditSubmission.objects.create(
|
||||
user=self.user,
|
||||
content_type=ContentType.objects.get_for_model(Operator),
|
||||
object_id=getattr(self.operator, "id", None),
|
||||
submission_type="EDIT",
|
||||
changes={"name": "New Name"},
|
||||
status="APPROVED",
|
||||
)
|
||||
|
||||
context = view.get_context_data()
|
||||
self.assertIn("history", context)
|
||||
self.assertIn("edit_submissions", context)
|
||||
self.assertEqual(len(context["edit_submissions"]), 1)
|
||||
58
backend/apps/moderation/urls.py
Normal file
58
backend/apps/moderation/urls.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from django.urls import path
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse_lazy
|
||||
from . import views
|
||||
|
||||
app_name = "moderation"
|
||||
|
||||
|
||||
def redirect_to_dashboard(request):
|
||||
return redirect(reverse_lazy("moderation:dashboard"))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# Root URL redirects to dashboard
|
||||
path("", redirect_to_dashboard),
|
||||
# Dashboard and Submissions
|
||||
path("dashboard/", views.DashboardView.as_view(), name="dashboard"),
|
||||
path("submissions/", views.submission_list, name="submission_list"),
|
||||
# Search endpoints
|
||||
path("search/parks/", views.search_parks, name="search_parks"),
|
||||
path(
|
||||
"search/ride-models/",
|
||||
views.search_ride_models,
|
||||
name="search_ride_models",
|
||||
),
|
||||
# Submission Actions
|
||||
path(
|
||||
"submissions/<int:submission_id>/edit/",
|
||||
views.edit_submission,
|
||||
name="edit_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/approve/",
|
||||
views.approve_submission,
|
||||
name="approve_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/reject/",
|
||||
views.reject_submission,
|
||||
name="reject_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/escalate/",
|
||||
views.escalate_submission,
|
||||
name="escalate_submission",
|
||||
),
|
||||
# Photo Submissions
|
||||
path(
|
||||
"photos/<int:submission_id>/approve/",
|
||||
views.approve_photo,
|
||||
name="approve_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:submission_id>/reject/",
|
||||
views.reject_photo,
|
||||
name="reject_photo",
|
||||
),
|
||||
]
|
||||
429
backend/apps/moderation/views.py
Normal file
429
backend/apps/moderation/views.py
Normal file
@@ -0,0 +1,429 @@
|
||||
from django.views.generic import ListView
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.http import HttpResponse, HttpRequest
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import QuerySet
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from typing import Optional, Any, Dict, List, Tuple, cast
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
import json
|
||||
from apps.accounts.models import User
|
||||
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
from apps.parks.models import Park, ParkArea
|
||||
from apps.rides.models import RideModel
|
||||
|
||||
MODERATOR_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
|
||||
class ModeratorRequiredMixin(UserPassesTestMixin):
|
||||
request: HttpRequest
|
||||
|
||||
def test_func(self) -> bool:
|
||||
"""Check if user has moderator permissions."""
|
||||
user = cast(User, self.request.user)
|
||||
return user.is_authenticated and (
|
||||
user.role in MODERATOR_ROLES or user.is_superuser
|
||||
)
|
||||
|
||||
def handle_no_permission(self) -> HttpResponse:
|
||||
if not self.request.user.is_authenticated:
|
||||
return super().handle_no_permission()
|
||||
raise PermissionDenied("You do not have moderator permissions.")
|
||||
|
||||
|
||||
def get_filtered_queryset(
|
||||
request: HttpRequest, status: str, submission_type: str
|
||||
) -> QuerySet:
|
||||
"""Get filtered queryset based on request parameters."""
|
||||
if submission_type == "photo":
|
||||
return PhotoSubmission.objects.filter(status=status).order_by("-created_at")
|
||||
|
||||
queryset = EditSubmission.objects.filter(status=status).order_by("-created_at")
|
||||
|
||||
if type_filter := request.GET.get("type"):
|
||||
queryset = queryset.filter(submission_type=type_filter)
|
||||
|
||||
if content_type := request.GET.get("content_type"):
|
||||
queryset = queryset.filter(content_type__model=content_type)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
def get_context_data(request: HttpRequest, queryset: QuerySet) -> Dict[str, Any]:
|
||||
"""Get common context data for views."""
|
||||
park_areas_by_park: Dict[int, List[Tuple[int, str]]] = {}
|
||||
|
||||
if isinstance(queryset.first(), EditSubmission):
|
||||
for submission in queryset:
|
||||
if (
|
||||
submission.content_type.model == "park"
|
||||
and isinstance(submission.changes, dict)
|
||||
and "park" in submission.changes
|
||||
):
|
||||
park_id = submission.changes["park"]
|
||||
if park_id not in park_areas_by_park:
|
||||
areas = ParkArea.objects.filter(park_id=park_id)
|
||||
park_areas_by_park[park_id] = [
|
||||
(area.pk, str(area)) for area in areas
|
||||
]
|
||||
|
||||
return {
|
||||
"submissions": queryset,
|
||||
"user": request.user,
|
||||
"parks": [(park.pk, str(park)) for park in Park.objects.all()],
|
||||
"ride_models": [(model.pk, str(model)) for model in RideModel.objects.all()],
|
||||
"owners": [
|
||||
(user.pk, str(user))
|
||||
for user in User.objects.filter(role__in=["OWNER", "ADMIN", "SUPERUSER"])
|
||||
],
|
||||
"park_areas_by_park": park_areas_by_park,
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""HTMX endpoint for searching parks in moderation dashboard"""
|
||||
user = cast(User, request.user)
|
||||
if not (user.role in MODERATOR_ROLES or user.is_superuser):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
query = request.GET.get("q", "").strip()
|
||||
submission_id = request.GET.get("submission_id")
|
||||
|
||||
parks = Park.objects.all().order_by("name")
|
||||
if query:
|
||||
parks = parks.filter(name__icontains=query)
|
||||
parks = parks[:10]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"moderation/partials/park_search_results.html",
|
||||
{"parks": parks, "search_term": query, "submission_id": submission_id},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def search_ride_models(request: HttpRequest) -> HttpResponse:
|
||||
"""HTMX endpoint for searching ride models in moderation dashboard"""
|
||||
user = cast(User, request.user)
|
||||
if not (user.role in MODERATOR_ROLES or user.is_superuser):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
query = request.GET.get("q", "").strip()
|
||||
submission_id = request.GET.get("submission_id")
|
||||
manufacturer_id = request.GET.get("manufacturer")
|
||||
|
||||
queryset = RideModel.objects.all()
|
||||
if manufacturer_id:
|
||||
queryset = queryset.filter(manufacturer_id=manufacturer_id)
|
||||
if query:
|
||||
queryset = queryset.filter(name__icontains=query)
|
||||
queryset = queryset.order_by("name")[:10]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"moderation/partials/ride_model_search_results.html",
|
||||
{
|
||||
"ride_models": queryset,
|
||||
"search_term": query,
|
||||
"submission_id": submission_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class DashboardView(LoginRequiredMixin, ModeratorRequiredMixin, ListView):
|
||||
template_name = "moderation/dashboard.html"
|
||||
context_object_name = "submissions"
|
||||
paginate_by = 10
|
||||
|
||||
def get_template_names(self) -> List[str]:
|
||||
if self.request.headers.get("HX-Request"):
|
||||
return ["moderation/partials/dashboard_content.html"]
|
||||
return [self.template_name]
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
status = self.request.GET.get("status", "PENDING")
|
||||
submission_type = self.request.GET.get("submission_type", "")
|
||||
return get_filtered_queryset(self.request, status, submission_type)
|
||||
|
||||
|
||||
@login_required
|
||||
def submission_list(request: HttpRequest) -> HttpResponse:
|
||||
"""View for submission list with filters"""
|
||||
user = cast(User, request.user)
|
||||
if not (user.role in MODERATOR_ROLES or user.is_superuser):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
status = request.GET.get("status", "PENDING")
|
||||
submission_type = request.GET.get("submission_type", "")
|
||||
|
||||
queryset = get_filtered_queryset(request, status, submission_type)
|
||||
|
||||
# Process location data for park submissions
|
||||
for submission in queryset:
|
||||
if submission.content_type.model == "park" and isinstance(
|
||||
submission.changes, dict
|
||||
):
|
||||
# Extract location fields into a location object
|
||||
location_fields = [
|
||||
"latitude",
|
||||
"longitude",
|
||||
"street_address",
|
||||
"city",
|
||||
"state",
|
||||
"postal_code",
|
||||
"country",
|
||||
]
|
||||
location_data = {
|
||||
field: submission.changes.get(field) for field in location_fields
|
||||
}
|
||||
# Add location data back as a single object
|
||||
submission.changes["location"] = location_data
|
||||
|
||||
context = get_context_data(request, queryset)
|
||||
|
||||
template_name = (
|
||||
"moderation/partials/dashboard_content.html"
|
||||
if request.headers.get("HX-Request")
|
||||
else "moderation/dashboard.html"
|
||||
)
|
||||
|
||||
return render(request, template_name, context)
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
|
||||
"""HTMX endpoint for editing a submission"""
|
||||
user = cast(User, request.user)
|
||||
if not (user.role in MODERATOR_ROLES or user.is_superuser):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||
|
||||
if request.method != "POST":
|
||||
return HttpResponse("Invalid request method", status=405)
|
||||
|
||||
notes = request.POST.get("notes")
|
||||
if not notes:
|
||||
return HttpResponse("Notes are required when editing a submission", status=400)
|
||||
|
||||
try:
|
||||
edited_changes = dict(submission.changes) if submission.changes else {}
|
||||
|
||||
# Update stats if present
|
||||
if "stats" in edited_changes:
|
||||
edited_stats = {}
|
||||
for key in edited_changes["stats"]:
|
||||
if new_value := request.POST.get(f"stats.{key}"):
|
||||
edited_stats[key] = new_value
|
||||
edited_changes["stats"] = edited_stats
|
||||
|
||||
# Update location fields if present
|
||||
if submission.content_type.model == "park":
|
||||
location_fields = [
|
||||
"latitude",
|
||||
"longitude",
|
||||
"street_address",
|
||||
"city",
|
||||
"state",
|
||||
"postal_code",
|
||||
"country",
|
||||
]
|
||||
location_data = {}
|
||||
for field in location_fields:
|
||||
if new_value := request.POST.get(field):
|
||||
if field in ["latitude", "longitude"]:
|
||||
try:
|
||||
location_data[field] = float(new_value)
|
||||
except ValueError:
|
||||
return HttpResponse(
|
||||
f"Invalid value for {field}", status=400
|
||||
)
|
||||
else:
|
||||
location_data[field] = new_value
|
||||
if location_data:
|
||||
edited_changes.update(location_data)
|
||||
|
||||
# Update other fields
|
||||
for field in edited_changes:
|
||||
if field == "stats" or field in [
|
||||
"latitude",
|
||||
"longitude",
|
||||
"street_address",
|
||||
"city",
|
||||
"state",
|
||||
"postal_code",
|
||||
"country",
|
||||
]:
|
||||
continue
|
||||
|
||||
if new_value := request.POST.get(field):
|
||||
if field in ["size_acres"]:
|
||||
try:
|
||||
edited_changes[field] = float(new_value)
|
||||
except ValueError:
|
||||
return HttpResponse(f"Invalid value for {field}", status=400)
|
||||
else:
|
||||
edited_changes[field] = new_value
|
||||
|
||||
# Convert to JSON-serializable format
|
||||
json_changes = json.loads(json.dumps(edited_changes, cls=DjangoJSONEncoder))
|
||||
submission.moderator_changes = json_changes
|
||||
submission.notes = notes
|
||||
submission.save()
|
||||
|
||||
# Process location data for display
|
||||
if submission.content_type.model == "park":
|
||||
location_fields = [
|
||||
"latitude",
|
||||
"longitude",
|
||||
"street_address",
|
||||
"city",
|
||||
"state",
|
||||
"postal_code",
|
||||
"country",
|
||||
]
|
||||
location_data = {
|
||||
field: json_changes.get(field) for field in location_fields
|
||||
}
|
||||
# Add location data back as a single object
|
||||
json_changes["location"] = location_data
|
||||
submission.changes = json_changes
|
||||
|
||||
context = get_context_data(
|
||||
request, EditSubmission.objects.filter(id=submission_id)
|
||||
)
|
||||
return render(request, "moderation/partials/submission_list.html", context)
|
||||
|
||||
except Exception as e:
|
||||
return HttpResponse(str(e), status=400)
|
||||
|
||||
|
||||
@login_required
|
||||
def approve_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
|
||||
"""HTMX endpoint for approving a submission"""
|
||||
user = cast(User, request.user)
|
||||
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||
|
||||
if not (
|
||||
(submission.status != "ESCALATED" and user.role in MODERATOR_ROLES)
|
||||
or user.role in ["ADMIN", "SUPERUSER"]
|
||||
or user.is_superuser
|
||||
):
|
||||
return HttpResponse("Insufficient permissions", status=403)
|
||||
|
||||
try:
|
||||
submission.approve(user)
|
||||
_update_submission_notes(submission, request.POST.get("notes"))
|
||||
|
||||
status = request.GET.get("status", "PENDING")
|
||||
submission_type = request.GET.get("submission_type", "")
|
||||
queryset = get_filtered_queryset(request, status, submission_type)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"moderation/partials/dashboard_content.html",
|
||||
{
|
||||
"submissions": queryset,
|
||||
"user": request.user,
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
return HttpResponse(str(e), status=400)
|
||||
|
||||
|
||||
@login_required
|
||||
def reject_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
|
||||
"""HTMX endpoint for rejecting a submission"""
|
||||
user = cast(User, request.user)
|
||||
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||
|
||||
if not (
|
||||
(submission.status != "ESCALATED" and user.role in MODERATOR_ROLES)
|
||||
or user.role in ["ADMIN", "SUPERUSER"]
|
||||
or user.is_superuser
|
||||
):
|
||||
return HttpResponse("Insufficient permissions", status=403)
|
||||
|
||||
submission.reject(user)
|
||||
_update_submission_notes(submission, request.POST.get("notes"))
|
||||
|
||||
status = request.GET.get("status", "PENDING")
|
||||
submission_type = request.GET.get("submission_type", "")
|
||||
queryset = get_filtered_queryset(request, status, submission_type)
|
||||
context = get_context_data(request, queryset)
|
||||
|
||||
return render(request, "moderation/partials/submission_list.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def escalate_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
|
||||
"""HTMX endpoint for escalating a submission"""
|
||||
user = cast(User, request.user)
|
||||
if not (user.role in MODERATOR_ROLES or user.is_superuser):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||
if submission.status == "ESCALATED":
|
||||
return HttpResponse("Submission is already escalated", status=400)
|
||||
|
||||
submission.escalate(user)
|
||||
_update_submission_notes(submission, request.POST.get("notes"))
|
||||
|
||||
status = request.GET.get("status", "PENDING")
|
||||
submission_type = request.GET.get("submission_type", "")
|
||||
queryset = get_filtered_queryset(request, status, submission_type)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"moderation/partials/dashboard_content.html",
|
||||
{
|
||||
"submissions": queryset,
|
||||
"user": request.user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def approve_photo(request: HttpRequest, submission_id: int) -> HttpResponse:
|
||||
"""HTMX endpoint for approving a photo submission"""
|
||||
user = cast(User, request.user)
|
||||
if not (user.role in MODERATOR_ROLES or user.is_superuser):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
submission = get_object_or_404(PhotoSubmission, id=submission_id)
|
||||
try:
|
||||
submission.approve(user, request.POST.get("notes", ""))
|
||||
return render(
|
||||
request,
|
||||
"moderation/partials/photo_submission.html",
|
||||
{"submission": submission},
|
||||
)
|
||||
except Exception as e:
|
||||
return HttpResponse(str(e), status=400)
|
||||
|
||||
|
||||
@login_required
|
||||
def reject_photo(request: HttpRequest, submission_id: int) -> HttpResponse:
|
||||
"""HTMX endpoint for rejecting a photo submission"""
|
||||
user = cast(User, request.user)
|
||||
if not (user.role in MODERATOR_ROLES or user.is_superuser):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
submission = get_object_or_404(PhotoSubmission, id=submission_id)
|
||||
submission.reject(user, request.POST.get("notes", ""))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"moderation/partials/photo_submission.html",
|
||||
{"submission": submission},
|
||||
)
|
||||
|
||||
|
||||
def _update_submission_notes(submission: EditSubmission, notes: Optional[str]) -> None:
|
||||
"""Update submission notes if provided."""
|
||||
if notes:
|
||||
submission.notes = notes
|
||||
submission.save()
|
||||
Reference in New Issue
Block a user