mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 10:51:08 -05:00
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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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), [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user