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:
pacnpal
2025-08-23 18:40:07 -04:00
parent b0e0678590
commit d504d41de2
762 changed files with 142636 additions and 0 deletions

View File

View 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)

View 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"

View 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

View 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"))

View 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",
),
),
),
]

View 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

View 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()

View 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)}

View 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)

View 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), [])

View 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)

View 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",
),
]

View 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()