Refactor test utilities and enhance ASGI settings

- Cleaned up and standardized assertions in ApiTestMixin for API response validation.
- Updated ASGI settings to use os.environ for setting the DJANGO_SETTINGS_MODULE.
- Removed unused imports and improved formatting in settings.py.
- Refactored URL patterns in urls.py for better readability and organization.
- Enhanced view functions in views.py for consistency and clarity.
- Added .flake8 configuration for linting and style enforcement.
- Introduced type stubs for django-environ to improve type checking with Pylance.
This commit is contained in:
pacnpal
2025-08-20 19:51:59 -04:00
parent 69c07d1381
commit 66ed4347a9
230 changed files with 15094 additions and 11578 deletions

View File

@@ -5,102 +5,163 @@ 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'
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']
return request.user.is_authenticated and request.user.role in [
"MODERATOR",
"ADMIN",
"SUPERUSER",
]
moderation_site = ModerationAdminSite(name="moderation")
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']
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])
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'
user_link.short_description = "User"
def content_link(self, obj):
if hasattr(obj.content_object, 'get_absolute_url'):
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'
content_link.short_description = "Content"
def save_model(self, request, obj, form, change):
if 'status' in form.changed_data:
if obj.status == 'APPROVED':
if "status" in form.changed_data:
if obj.status == "APPROVED":
obj.approve(request.user)
elif obj.status == 'REJECTED':
elif obj.status == "REJECTED":
obj.reject(request.user)
elif obj.status == 'ESCALATED':
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']
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])
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'
user_link.short_description = "User"
def content_link(self, obj):
if hasattr(obj.content_object, 'get_absolute_url'):
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'
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'
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':
if "status" in form.changed_data:
if obj.status == "APPROVED":
obj.approve(request.user, obj.notes)
elif obj.status == 'REJECTED':
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'
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'):
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'
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>']
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'
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)

View File

@@ -1,6 +1,7 @@
from django.apps import AppConfig
class ModerationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'moderation'
verbose_name = 'Content Moderation'
default_auto_field = "django.db.models.BigAutoField"
name = "moderation"
verbose_name = "Content Moderation"

View File

@@ -1,26 +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
"has_moderation_access": False,
"has_admin_access": False,
"has_superuser_access": False,
"user_role": None,
}
if request.user.is_authenticated:
context['user_role'] = request.user.role
context["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_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_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
context["has_superuser_access"] = (
request.user.role == "SUPERUSER" or request.user.is_superuser
)
return context

View File

@@ -2,27 +2,26 @@ 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 django.utils import timezone
from moderation.models import EditSubmission, PhotoSubmission
from parks.models import Park
from rides.models import Ride
from datetime import date, timedelta
from datetime import date
User = get_user_model()
class Command(BaseCommand):
help = 'Seeds test submissions for moderation dashboard'
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'
username="test_user", email="test@example.com"
)
if created:
user.set_password('testpass123')
user.set_password("testpass123")
user.save()
self.stdout.write(self.style.SUCCESS('Created test user'))
self.stdout.write(self.style.SUCCESS("Created test user"))
# Get content types
park_ct = ContentType.objects.get_for_model(Park)
@@ -30,29 +29,29 @@ class Command(BaseCommand):
# Create test park for edit submissions
test_park, created = Park.objects.get_or_create(
name='Test Park',
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'
}
"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',
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)
}
"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
@@ -61,33 +60,39 @@ class Command(BaseCommand):
EditSubmission.objects.create(
user=user,
content_type=park_ct,
submission_type='CREATE',
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'
}
"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'
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
@@ -95,75 +100,89 @@ class Command(BaseCommand):
user=user,
content_type=park_ct,
object_id=test_park.id,
submission_type='EDIT',
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'
}
"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'
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',
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
}
"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'
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
@@ -171,58 +190,72 @@ class Command(BaseCommand):
user=user,
content_type=ride_ct,
object_id=test_ride.id,
submission_type='EDIT',
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
}
"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'
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')
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.'),
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'
status="PENDING",
)
# Ride photo submission
dummy_image2 = SimpleUploadedFile('coaster_track.gif', image_data, content_type='image/gif')
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.'),
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'
status="PENDING",
)
self.stdout.write(self.style.SUCCESS('Successfully seeded test submissions'))
self.stdout.write(self.style.SUCCESS("Successfully seeded test submissions"))

View File

@@ -31,11 +31,17 @@ class Migration(migrations.Migration):
),
),
("updated_at", models.DateTimeField(auto_now=True)),
("object_id", models.PositiveIntegerField(blank=True, null=True)),
(
"object_id",
models.PositiveIntegerField(blank=True, null=True),
),
(
"submission_type",
models.CharField(
choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")],
choices=[
("EDIT", "Edit Existing"),
("CREATE", "Create New"),
],
default="EDIT",
max_length=10,
),
@@ -61,7 +67,8 @@ class Migration(migrations.Migration):
(
"source",
models.TextField(
blank=True, help_text="Source of information (if applicable)"
blank=True,
help_text="Source of information (if applicable)",
),
),
(
@@ -119,16 +126,25 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="EditSubmissionEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"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)),
(
"object_id",
models.PositiveIntegerField(blank=True, null=True),
),
(
"submission_type",
models.CharField(
choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")],
choices=[
("EDIT", "Edit Existing"),
("CREATE", "Create New"),
],
default="EDIT",
max_length=10,
),
@@ -154,7 +170,8 @@ class Migration(migrations.Migration):
(
"source",
models.TextField(
blank=True, help_text="Source of information (if applicable)"
blank=True,
help_text="Source of information (if applicable)",
),
),
(
@@ -307,7 +324,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="PhotoSubmissionEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"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()),

View File

@@ -1,45 +1,57 @@
from typing import Any, Dict, Optional, Type, Union, cast
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
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.core.exceptions import PermissionDenied
from django.views.generic import DetailView, View
from django.utils import timezone
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
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
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:
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)
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")
@@ -53,89 +65,101 @@ class EditSubmissionMixin(DetailView):
submission_type=submission_type,
changes=changes,
reason=reason,
source=source
source=source,
)
# For edits, set the object_id
if submission_type == 'EDIT':
if submission_type == "EDIT":
obj = self.get_object()
submission.object_id = getattr(obj, 'id', None)
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']:
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)()
})
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
})
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)
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')
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)
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)
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)
return JsonResponse(
{"status": "error", "message": "Invalid JSON data."},
status=400,
)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=500)
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)
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")
@@ -143,125 +167,148 @@ class PhotoSubmissionMixin(DetailView):
try:
obj = self.get_object()
except (AttributeError, self.model.DoesNotExist):
return JsonResponse({
'status': 'error',
'message': 'Invalid object.'
}, status=400)
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)
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')
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']:
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
})
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
})
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']
)
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']
)
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']
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')
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()
context["history"] = obj.get_history()
except (AttributeError, TypeError):
context['history'] = []
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')
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

@@ -1,19 +1,18 @@
from typing import Any, Dict, Optional, Type, Union, cast
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.apps import apps
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from django.utils.text import slugify
import pghistory
from core.history import TrackedModel
UserType = Union[AbstractBaseUser, AnonymousUser]
@pghistory.track() # Track all changes by default
class EditSubmission(TrackedModel):
STATUS_CHOICES = [
@@ -51,12 +50,12 @@ class EditSubmission(TrackedModel):
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"
help_text="Moderator's edited version of the changes before approval",
)
# Metadata
@@ -104,7 +103,11 @@ class EditSubmission(TrackedModel):
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 (
(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):
@@ -112,28 +115,33 @@ class EditSubmission(TrackedModel):
return resolved_data
def _prepare_model_data(self, data: Dict[str, Any], model_class: Type[models.Model]) -> Dict[str, Any]:
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'}
# 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]:
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:
except BaseException:
return None
def approve(self, user: UserType) -> Optional[models.Model]:
@@ -142,19 +150,29 @@ class EditSubmission(TrackedModel):
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
# 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"]):
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.notes = f"A {
model_class.__name__} with the name '{
prepared_data['name']}' already exists (ID: {
existing_obj.id})"
self.save()
raise ValueError(self.notes)
@@ -185,7 +203,9 @@ class EditSubmission(TrackedModel):
self.save()
return obj
except Exception as e:
if self.status != "REJECTED": # Don't override if already rejected due to duplicate
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
@@ -204,6 +224,7 @@ class EditSubmission(TrackedModel):
self.handled_at = timezone.now()
self.save()
@pghistory.track() # Track all changes by default
class PhotoSubmission(TrackedModel):
STATUS_CHOICES = [
@@ -244,7 +265,8 @@ class PhotoSubmission(TrackedModel):
)
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True, help_text="Notes from the moderator about this photo submission"
blank=True,
help_text="Notes from the moderator about this photo submission",
)
class Meta:
@@ -255,7 +277,9 @@ class PhotoSubmission(TrackedModel):
]
def __str__(self) -> str:
return f"Photo submission by {self.user.username} for {self.content_object}"
return f"Photo submission by {
self.user.username} for {
self.content_object}"
def approve(self, moderator: UserType, notes: str = "") -> None:
"""Approve the photo submission"""
@@ -285,12 +309,12 @@ class PhotoSubmission(TrackedModel):
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)

View File

@@ -4,302 +4,275 @@ Following Django styleguide pattern for separating data access from business log
"""
from typing import Optional, Dict, Any
from django.db.models import QuerySet, Q, Count
from django.db.models import QuerySet, Count
from django.utils import timezone
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from .models import EditSubmission
def pending_submissions_for_review(
*,
content_type: Optional[str] = None,
limit: int = 50
*, 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'
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]
return queryset.order_by("submitted_at")[:limit]
def submissions_by_user(
*,
user_id: int,
status: Optional[str] = None
*, 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'
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')
return queryset.order_by("-submitted_at")
def submissions_handled_by_moderator(
*,
moderator_id: int,
days: int = 30
*, 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')
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')
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
*, 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'
)
).select_related("submitted_by", "handled_by")
if status:
queryset = queryset.filter(status=status)
return queryset.order_by('-submitted_at')
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()
pending_count = EditSubmission.objects.filter(status="PENDING").count()
approved_today = EditSubmission.objects.filter(
status='APPROVED',
handled_at__date=timezone.now().date()
status="APPROVED", handled_at__date=timezone.now().date()
).count()
rejected_today = EditSubmission.objects.filter(
status='REJECTED',
handled_at__date=timezone.now().date()
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')
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)
"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
*, 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
)
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()
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)
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
"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')
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]
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'
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)
}
return {"period_days": days, "moderator_stats": list(moderator_stats)}

View File

@@ -6,7 +6,6 @@ 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.core.exceptions import ValidationError
from django.contrib.auth.models import User
from django.db.models import QuerySet
@@ -15,25 +14,22 @@ 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
*, 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
@@ -43,14 +39,15 @@ class ModerationService:
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != 'PENDING':
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
# 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:
@@ -58,36 +55,33 @@ class ModerationService:
else:
submission.notes = f"[Moderator]: {notes}"
submission.save()
return obj
except Exception as e:
# Mark as rejected on any error
submission.status = 'REJECTED'
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
*, 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
@@ -96,21 +90,21 @@ class ModerationService:
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != 'PENDING':
if submission.status != "PENDING":
raise ValueError(f"Submission {submission_id} is not pending review")
submission.status = 'REJECTED'
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(
*,
@@ -118,21 +112,21 @@ class ModerationService:
changes: Dict[str, Any],
submitter: User,
submission_type: str = "UPDATE",
notes: Optional[str] = None
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
"""
@@ -141,33 +135,33 @@ class ModerationService:
changes=changes,
submitted_by=submitter,
submission_type=submission_type,
notes=notes or ""
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
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
@@ -176,25 +170,25 @@ class ModerationService:
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != 'PENDING':
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(
*,
@@ -204,41 +198,33 @@ class ModerationService:
) -> 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
)
return pending_submissions_for_review(content_type=content_type, limit=limit)
@staticmethod
def get_submission_statistics(
*,
days: int = 30,
moderator: Optional[User] = None
*, 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
)
return moderation_statistics_summary(days=days, moderator=moderator)

View File

@@ -1,62 +1,69 @@
from django import template
from django.utils.safestring import mark_safe
from django.contrib.contenttypes.models import ContentType
from django.db.models import Model
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:
if not value or not model_path or "." not in model_path:
return None
app_label, model = model_path.split('.')
app_label, model = model_path.split(".")
try:
content_type = ContentType.objects.get(app_label=app_label.lower(), model=model.lower())
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'
"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 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]:
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

@@ -1,31 +1,40 @@
from django.test import TestCase, Client
from django.urls import reverse
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 django.utils import timezone
from django.utils.datastructures import MultiValueDict
from django.http import QueryDict
from .models import EditSubmission, PhotoSubmission
from .mixins import EditSubmissionMixin, PhotoSubmissionMixin, ModeratorRequiredMixin, AdminRequiredMixin, InlineEditMixin, HistoryMixin
from .models import EditSubmission
from .mixins import (
EditSubmissionMixin,
PhotoSubmissionMixin,
ModeratorRequiredMixin,
AdminRequiredMixin,
InlineEditMixin,
HistoryMixin,
)
from parks.models import Company as Operator
from django.views.generic import DetailView
from django.test import RequestFactory
import json
from typing import Optional
User = get_user_model()
class TestView(EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView):
class TestView(
EditSubmissionMixin,
PhotoSubmissionMixin,
InlineEditMixin,
HistoryMixin,
DetailView,
):
model = Operator
template_name = 'test.html'
pk_url_kwarg = 'pk'
slug_url_kwarg = 'slug'
template_name = "test.html"
pk_url_kwarg = "pk"
slug_url_kwarg = "slug"
def get_context_data(self, **kwargs):
if not hasattr(self, 'object'):
if not hasattr(self, "object"):
self.object = self.get_object()
return super().get_context_data(**kwargs)
@@ -33,44 +42,45 @@ class TestView(EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, Histo
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'
username="testuser",
email="test@example.com",
password="testpass123",
)
self.moderator = User.objects.create_user(
username='moderator',
email='moderator@example.com',
password='modpass123',
role='MODERATOR'
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'
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'
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 = self.factory.post(f"/test/{self.operator.pk}/")
request.user = AnonymousUser()
view.setup(request, pk=self.operator.pk)
view.kwargs = {'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)
@@ -79,13 +89,13 @@ class ModerationMixinsTests(TestCase):
"""Test edit submission with no changes"""
view = TestView()
request = self.factory.post(
f'/test/{self.operator.pk}/',
f"/test/{self.operator.pk}/",
data=json.dumps({}),
content_type='application/json'
content_type="application/json",
)
request.user = self.user
view.setup(request, pk=self.operator.pk)
view.kwargs = {'pk': self.operator.pk}
view.kwargs = {"pk": self.operator.pk}
response = view.post(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 400)
@@ -94,13 +104,13 @@ class ModerationMixinsTests(TestCase):
"""Test edit submission with invalid JSON"""
view = TestView()
request = self.factory.post(
f'/test/{self.operator.pk}/',
data='invalid json',
content_type='application/json'
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}
view.kwargs = {"pk": self.operator.pk}
response = view.post(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 400)
@@ -108,41 +118,43 @@ class ModerationMixinsTests(TestCase):
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 = 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')
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'])
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 = 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')
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'])
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.kwargs = {"pk": self.operator.pk}
view.object = self.operator
request = self.factory.post(
f'/test/{self.operator.pk}/',
data={},
format='multipart'
f"/test/{self.operator.pk}/", data={}, format="multipart"
)
request.user = AnonymousUser()
view.setup(request, pk=self.operator.pk)
@@ -153,13 +165,11 @@ class ModerationMixinsTests(TestCase):
def test_photo_submission_mixin_no_photo(self):
"""Test photo submission with no photo"""
view = TestView()
view.kwargs = {'pk': self.operator.pk}
view.kwargs = {"pk": self.operator.pk}
view.object = self.operator
request = self.factory.post(
f'/test/{self.operator.pk}/',
data={},
format='multipart'
f"/test/{self.operator.pk}/", data={}, format="multipart"
)
request.user = self.user
view.setup(request, pk=self.operator.pk)
@@ -170,80 +180,89 @@ class ModerationMixinsTests(TestCase):
def test_photo_submission_mixin_regular_user(self):
"""Test photo submission as regular user"""
view = TestView()
view.kwargs = {'pk': self.operator.pk}
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'
"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'
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'])
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.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'
"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'
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'])
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 = 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
@@ -251,27 +270,28 @@ class ModerationMixinsTests(TestCase):
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 = 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
@@ -280,50 +300,50 @@ class ModerationMixinsTests(TestCase):
def test_inline_edit_mixin(self):
"""Test inline edit mixin"""
view = TestView()
view.kwargs = {'pk': self.operator.pk}
view.kwargs = {"pk": self.operator.pk}
view.object = self.operator
# Test unauthenticated user
request = self.factory.get(f'/test/{self.operator.pk}/')
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)
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'])
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'])
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.kwargs = {"pk": self.operator.pk}
view.object = self.operator
request = self.factory.get(f'/test/{self.operator.pk}/')
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'
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)
self.assertIn("history", context)
self.assertIn("edit_submissions", context)
self.assertEqual(len(context["edit_submissions"]), 1)

View File

@@ -3,30 +3,56 @@ from django.shortcuts import redirect
from django.urls import reverse_lazy
from . import views
app_name = 'moderation'
app_name = "moderation"
def redirect_to_dashboard(request):
return redirect(reverse_lazy('moderation:dashboard'))
return redirect(reverse_lazy("moderation:dashboard"))
urlpatterns = [
# Root URL redirects to dashboard
path('', redirect_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'),
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'),
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'),
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'),
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

@@ -1,22 +1,21 @@
from django.views.generic import ListView, TemplateView
from django.views.generic import ListView
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponse, JsonResponse, HttpRequest
from django.http import HttpResponse, HttpRequest
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.decorators import login_required
from django.template.loader import render_to_string
from django.db.models import Q, QuerySet
from django.db.models import QuerySet
from django.core.exceptions import PermissionDenied
from typing import Optional, Any, Dict, List, Tuple, Union, cast
from django.db import models
from typing import Optional, Any, Dict, List, Tuple, cast
from django.core.serializers.json import DjangoJSONEncoder
import json
from accounts.models import User
from .models import EditSubmission, PhotoSubmission
from parks.models import Park, ParkArea
from rides.models import RideModel, Company
from rides.models import RideModel
MODERATOR_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
MODERATOR_ROLES = ['MODERATOR', 'ADMIN', 'SUPERUSER']
class ModeratorRequiredMixin(UserPassesTestMixin):
request: HttpRequest
@@ -24,71 +23,85 @@ class ModeratorRequiredMixin(UserPassesTestMixin):
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)
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:
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'):
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'):
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 (
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]
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
"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')
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
})
return render(
request,
"moderation/partials/park_search_results.html",
{"parks": parks, "search_term": query, "submission_id": submission_id},
)
@login_required
@@ -97,190 +110,253 @@ def search_ride_models(request: HttpRequest) -> HttpResponse:
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')
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
})
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'
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']
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', '')
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', '')
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)):
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}
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
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')
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':
if request.method != "POST":
return HttpResponse("Invalid request method", status=405)
notes = request.POST.get('notes')
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:
if "stats" in edited_changes:
edited_stats = {}
for key in edited_changes['stats']:
if new_value := request.POST.get(f'stats.{key}'):
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
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']
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']:
if field in ["latitude", "longitude"]:
try:
location_data[field] = float(new_value)
except ValueError:
return HttpResponse(f"Invalid value for {field}", status=400)
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']:
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']:
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}
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
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)
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):
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', '')
_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,
})
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):
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'))
_update_submission_notes(submission, request.POST.get("notes"))
status = request.GET.get('status', 'PENDING')
submission_type = request.GET.get('submission_type', '')
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)
return render(request, "moderation/partials/submission_list.html", context)
@login_required
def escalate_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
@@ -299,11 +375,16 @@ def escalate_submission(request: HttpRequest, submission_id: int) -> HttpRespons
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,
})
return render(
request,
"moderation/partials/dashboard_content.html",
{
"submissions": queryset,
"user": request.user,
},
)
@login_required
def approve_photo(request: HttpRequest, submission_id: int) -> HttpResponse:
@@ -315,11 +396,15 @@ def approve_photo(request: HttpRequest, submission_id: int) -> HttpResponse:
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})
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"""
@@ -330,8 +415,12 @@ def reject_photo(request: HttpRequest, submission_id: int) -> HttpResponse:
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})
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."""