code commit

This commit is contained in:
pacnpal
2024-11-13 21:59:49 +00:00
parent 8f7f7add2d
commit 8265348a83
26 changed files with 1336 additions and 289 deletions

View File

View File

@@ -0,0 +1,228 @@
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
User = get_user_model()
class Command(BaseCommand):
help = 'Seeds test submissions for moderation dashboard'
def handle(self, *args, **kwargs):
# Ensure we have a test user
user, created = User.objects.get_or_create(
username='test_user',
email='test@example.com'
)
if created:
user.set_password('testpass123')
user.save()
self.stdout.write(self.style.SUCCESS('Created test user'))
# Get content types
park_ct = ContentType.objects.get_for_model(Park)
ride_ct = ContentType.objects.get_for_model(Ride)
# Create test park for edit submissions
test_park, created = Park.objects.get_or_create(
name='Test Park',
defaults={
'description': 'A test theme park located in Orlando, Florida',
'status': 'OPERATING',
'operating_season': 'Year-round',
'size_acres': 100.50,
'website': 'https://testpark.example.com'
}
)
# Create test ride for edit submissions
test_ride, created = Ride.objects.get_or_create(
name='Test Coaster',
park=test_park,
defaults={
'description': 'A thrilling steel roller coaster with multiple inversions',
'status': 'OPERATING',
'category': 'RC',
'capacity_per_hour': 1200,
'ride_duration_seconds': 180,
'min_height_in': 48,
'opening_date': date(2020, 6, 15)
}
)
# Create EditSubmissions
# New park creation with detailed information
EditSubmission.objects.create(
user=user,
content_type=park_ct,
submission_type='CREATE',
changes={
'name': 'Adventure World Orlando',
'description': ('A brand new theme park coming to Orlando featuring five uniquely themed lands: '
'Future Frontier, Ancient Mysteries, Ocean Depths, Sky Kingdom, and Fantasy Forest. '
'The park will feature state-of-the-art attractions including 3 roller coasters, '
'4 dark rides, and multiple family attractions in each themed area.'),
'status': 'UNDER_CONSTRUCTION',
'opening_date': '2024-06-01',
'operating_season': 'Year-round with extended hours during summer and holidays',
'size_acres': 250.75,
'website': 'https://adventureworld.example.com',
'location': {
'street_address': '1234 Theme Park Way',
'city': 'Orlando',
'state': 'Florida',
'country': 'United States',
'postal_code': '32819',
'latitude': '28.538336',
'longitude': '-81.379234'
}
},
reason=('Submitting new theme park details based on official press release and construction permits. '
'The park has begun vertical construction and has announced its opening date.'),
source=('Official press release: https://adventureworld.example.com/press/announcement\n'
'Construction permits: Orange County Building Department #2023-12345'),
status='PENDING'
)
# Existing park edit with comprehensive updates
EditSubmission.objects.create(
user=user,
content_type=park_ct,
object_id=test_park.id,
submission_type='EDIT',
changes={
'description': ('A world-class theme park featuring 12 uniquely themed areas and over 50 attractions. '
'Recent expansion added the new "Cosmic Adventures" area with 2 roller coasters and '
'3 family attractions. The park now offers enhanced dining options and night-time '
'spectacular "Starlight Dreams".'),
'status': 'OPERATING',
'website': 'https://testpark.example.com',
'size_acres': 120.25,
'operating_season': ('Year-round with extended hours (9AM-11PM) during summer. '
'Special events during Halloween and Christmas seasons.'),
'location': {
'street_address': '5678 Park Boulevard',
'city': 'Orlando',
'state': 'Florida',
'country': 'United States',
'postal_code': '32830',
'latitude': '28.538336',
'longitude': '-81.379234'
}
},
reason=('Updating park information to reflect recent expansion and operational changes. '
'The new Cosmic Adventures area opened last month and operating hours have been extended.'),
source=('Park press release: https://testpark.example.com/news/expansion\n'
'Official park map: https://testpark.example.com/map\n'
'Personal visit and photos from opening day of new area'),
status='PENDING'
)
# New ride creation with detailed specifications
EditSubmission.objects.create(
user=user,
content_type=ride_ct,
submission_type='CREATE',
changes={
'name': 'Thunderbolt: The Ultimate Launch Coaster',
'park': test_park.id,
'description': ('A cutting-edge steel launch coaster featuring the world\'s tallest inversion (160 ft) '
'and fastest launch acceleration (0-80 mph in 2 seconds). The ride features a unique '
'triple launch system, 5 inversions including a zero-g roll and cobra roll, and a '
'first-of-its-kind vertical helix element. Total track length is 4,500 feet with a '
'maximum height of 375 feet.'),
'status': 'UNDER_CONSTRUCTION',
'category': 'RC',
'opening_date': '2024-07-01',
'capacity_per_hour': 1400,
'ride_duration_seconds': 210,
'min_height_in': 52,
'manufacturer': 1, # Assuming manufacturer ID
'park_area': 1, # Assuming park area ID
'stats': {
'height_ft': 375,
'length_ft': 4500,
'speed_mph': 80,
'inversions': 5,
'launch_type': 'LSM',
'track_material': 'STEEL',
'roller_coaster_type': 'SITDOWN',
'trains_count': 3,
'cars_per_train': 6,
'seats_per_car': 4
}
},
reason=('Submitting details for the new flagship roller coaster announced by the park. '
'Construction has begun and track pieces are arriving on site.'),
source=('Official announcement: https://testpark.example.com/thunderbolt\n'
'Construction photos: https://coasterfan.com/thunderbolt-construction\n'
'Manufacturer specifications sheet'),
status='PENDING'
)
# Existing ride edit with technical updates
EditSubmission.objects.create(
user=user,
content_type=ride_ct,
object_id=test_ride.id,
submission_type='EDIT',
changes={
'description': ('A high-speed steel roller coaster featuring 4 inversions and a unique '
'dual-loading station system. Recent upgrades include new magnetic braking '
'system and enhanced on-board audio experience.'),
'status': 'OPERATING',
'capacity_per_hour': 1500, # Increased after station upgrades
'ride_duration_seconds': 185,
'min_height_in': 48,
'max_height_in': 80,
'stats': {
'trains_count': 3,
'cars_per_train': 8,
'seats_per_car': 4
}
},
reason=('Updating ride information to reflect recent upgrades including new braking system, '
'audio system, and increased capacity due to improved loading efficiency.'),
source=('Park operations manual\n'
'Maintenance records\n'
'Personal observation and timing of new ride cycle'),
status='PENDING'
)
# Create PhotoSubmissions with detailed captions
# Park photo submission
image_data = b'GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
dummy_image = SimpleUploadedFile('park_entrance.gif', image_data, content_type='image/gif')
PhotoSubmission.objects.create(
user=user,
content_type=park_ct,
object_id=test_park.id,
photo=dummy_image,
caption=('Main entrance plaza of Test Park showing the newly installed digital display board '
'and renovated ticketing area. Photo taken during morning park opening.'),
date_taken=date(2024, 1, 15),
status='PENDING'
)
# Ride photo submission
dummy_image2 = SimpleUploadedFile('coaster_track.gif', image_data, content_type='image/gif')
PhotoSubmission.objects.create(
user=user,
content_type=ride_ct,
object_id=test_ride.id,
photo=dummy_image2,
caption=('Test Coaster\'s first drop and loop element showing the new paint scheme. '
'Photo taken from the guest pathway near Station Alpha.'),
date_taken=date(2024, 1, 20),
status='PENDING'
)
self.stdout.write(self.style.SUCCESS('Successfully seeded test submissions'))

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.1.3 on 2024-11-13 19:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="editsubmission",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
max_length=20,
),
),
migrations.AlterField(
model_name="photosubmission",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
max_length=20,
),
),
]

View File

@@ -0,0 +1,32 @@
from django.db import migrations
def update_statuses(apps, schema_editor):
EditSubmission = apps.get_model('moderation', 'EditSubmission')
PhotoSubmission = apps.get_model('moderation', 'PhotoSubmission')
# Update EditSubmissions
EditSubmission.objects.filter(status='NEW').update(status='PENDING')
# Update PhotoSubmissions
PhotoSubmission.objects.filter(status='NEW').update(status='PENDING')
PhotoSubmission.objects.filter(status='AUTO_APPROVED').update(status='APPROVED')
def reverse_statuses(apps, schema_editor):
EditSubmission = apps.get_model('moderation', 'EditSubmission')
PhotoSubmission = apps.get_model('moderation', 'PhotoSubmission')
# Reverse EditSubmissions
EditSubmission.objects.filter(status='PENDING').update(status='NEW')
# Reverse PhotoSubmissions
PhotoSubmission.objects.filter(status='PENDING').update(status='NEW')
class Migration(migrations.Migration):
dependencies = [
('moderation', '0002_alter_editsubmission_status_and_more'),
]
operations = [
migrations.RunPython(update_statuses, reverse_statuses),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.1.3 on 2024-11-13 20:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0003_update_existing_statuses"),
]
operations = [
migrations.AddField(
model_name="editsubmission",
name="moderator_changes",
field=models.JSONField(
blank=True,
help_text="Moderator's edited version of the changes before approval",
null=True,
),
),
]

View File

@@ -15,7 +15,7 @@ UserType = Union[AbstractBaseUser, AnonymousUser]
class EditSubmission(models.Model):
STATUS_CHOICES = [
("NEW", "New"),
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
@@ -49,13 +49,20 @@ class EditSubmission(models.Model):
changes = models.JSONField(
help_text="JSON representation of the changes or new object data"
)
# Moderator's edited version of changes before approval
moderator_changes = models.JSONField(
null=True,
blank=True,
help_text="Moderator's edited version of the changes before approval"
)
# Metadata
reason = models.TextField(help_text="Why this edit/addition is needed")
source = models.TextField(
blank=True, help_text="Source of information (if applicable)"
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="NEW")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
created_at = models.DateTimeField(auto_now_add=True)
# Review details
@@ -133,7 +140,10 @@ class EditSubmission(models.Model):
raise ValueError("Could not resolve model class")
try:
resolved_data = self._resolve_foreign_keys(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
@@ -168,7 +178,7 @@ class EditSubmission(models.Model):
return obj
except Exception as e:
if self.status != "REJECTED": # Don't override if already rejected due to duplicate
self.status = "NEW" # Reset status if approval failed
self.status = "PENDING" # Reset status if approval failed
self.save()
raise ValueError(f"Error approving submission: {str(e)}") from e
@@ -189,10 +199,10 @@ class EditSubmission(models.Model):
class PhotoSubmission(models.Model):
STATUS_CHOICES = [
("NEW", "New"),
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("AUTO_APPROVED", "Auto Approved"),
("ESCALATED", "Escalated"),
]
# Who submitted the photo
@@ -213,7 +223,7 @@ class PhotoSubmission(models.Model):
date_taken = models.DateField(null=True, blank=True)
# Metadata
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="NEW")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
created_at = models.DateTimeField(auto_now_add=True)
# Review details
@@ -268,22 +278,10 @@ class PhotoSubmission(models.Model):
self.notes = notes
self.save()
def auto_approve(self) -> None:
"""Auto-approve the photo submission (for moderators/admins)"""
from media.models import Photo
self.status = "AUTO_APPROVED"
self.handled_by = self.user
def escalate(self, moderator: UserType, notes: str = "") -> None:
"""Escalate the photo submission to admin"""
self.status = "ESCALATED"
self.handled_by = moderator # type: ignore
self.handled_at = timezone.now()
# Create the approved photo
Photo.objects.create(
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.object_id,
image=self.photo,
caption=self.caption,
is_approved=True,
)
self.notes = notes
self.save()

View File

@@ -0,0 +1,54 @@
from django import template
from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist
from rides.models import CATEGORY_CHOICES
register = template.Library()
@register.filter
def get_object(value, model_path):
"""
Template filter to get an object instance from its ID and model path.
Usage: {{ value|get_object:'app_label.ModelName' }}
"""
if not value:
return None
try:
app_label, model_name = model_path.split('.')
model = apps.get_model(app_label, model_name)
return model.objects.get(id=value)
except (ValueError, LookupError, ObjectDoesNotExist):
return None
@register.filter
def get_category_display(value):
"""
Template filter to get the display name for a ride category.
Usage: {{ value|get_category_display }}
"""
try:
return dict(CATEGORY_CHOICES).get(value, value)
except (KeyError, AttributeError):
return value
@register.filter
def get_object_name(value, model_path):
"""
Template filter to get an object's name from its ID and model path.
Usage: {{ value|get_object_name:'app_label.ModelName' }}
"""
obj = get_object(value, model_path)
return obj.name if obj else None
@register.filter
def get_park_area_name(value, park_id):
"""
Template filter to get a park area's name from its ID and park ID.
Usage: {{ value|get_park_area_name:park_id }}
"""
try:
ParkArea = apps.get_model('parks', 'ParkArea')
area = ParkArea.objects.get(id=value, park_id=park_id)
return area.name
except (ValueError, LookupError, ObjectDoesNotExist):
return None

View File

@@ -16,7 +16,14 @@ urlpatterns = [
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/manufacturers/', views.search_manufacturers, name='search_manufacturers'),
path('search/designers/', views.search_designers, name='search_designers'),
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'),

View File

@@ -10,6 +10,10 @@ from typing import Optional, Any, cast
from accounts.models import User
from .models import EditSubmission, PhotoSubmission
from parks.models import Park, ParkArea
from designers.models import Designer
from companies.models import Manufacturer
from rides.models import RideModel
MODERATOR_ROLES = ['MODERATOR', 'ADMIN', 'SUPERUSER']
@@ -29,6 +33,108 @@ class ModeratorRequiredMixin(UserPassesTestMixin):
return super().handle_no_permission()
raise PermissionDenied("You do not have moderator permissions.")
@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')
# If no query, show first 10 parks
if not query:
parks = Park.objects.all().order_by('name')[:10]
else:
parks = Park.objects.filter(name__icontains=query).order_by('name')[:10]
context = {
'parks': parks,
'search_term': query,
'submission_id': submission_id
}
return render(request, 'moderation/partials/park_search_results.html', context)
@login_required
def search_manufacturers(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for searching manufacturers 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')
# If no query, show first 10 manufacturers
if not query:
manufacturers = Manufacturer.objects.all().order_by('name')[:10]
else:
manufacturers = Manufacturer.objects.filter(name__icontains=query).order_by('name')[:10]
context = {
'manufacturers': manufacturers,
'search_term': query,
'submission_id': submission_id
}
return render(request, 'moderation/partials/manufacturer_search_results.html', context)
@login_required
def search_designers(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for searching designers 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')
# If no query, show first 10 designers
if not query:
designers = Designer.objects.all().order_by('name')[:10]
else:
designers = Designer.objects.filter(name__icontains=query).order_by('name')[:10]
context = {
'designers': designers,
'search_term': query,
'submission_id': submission_id
}
return render(request, 'moderation/partials/designer_search_results.html', context)
@login_required
def search_ride_models(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for searching ride models in moderation dashboard"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
query = request.GET.get('q', '').strip()
submission_id = request.GET.get('submission_id')
manufacturer_id = request.GET.get('manufacturer')
queryset = RideModel.objects.all()
if manufacturer_id:
queryset = queryset.filter(manufacturer_id=manufacturer_id)
# If no query, show first 10 models
if not query:
ride_models = queryset.order_by('name')[:10]
else:
ride_models = queryset.filter(name__icontains=query).order_by('name')[:10]
context = {
'ride_models': ride_models,
'search_term': query,
'submission_id': submission_id
}
return render(request, 'moderation/partials/ride_model_search_results.html', context)
class DashboardView(LoginRequiredMixin, ModeratorRequiredMixin, ListView):
template_name = 'moderation/dashboard.html'
context_object_name = 'submissions'
@@ -40,7 +146,7 @@ class DashboardView(LoginRequiredMixin, ModeratorRequiredMixin, ListView):
return [self.template_name]
def get_queryset(self):
status = self.request.GET.get('status', 'NEW')
status = self.request.GET.get('status', 'PENDING')
submission_type = self.request.GET.get('submission_type', '')
if submission_type == 'photo':
@@ -63,7 +169,7 @@ def submission_list(request: HttpRequest) -> HttpResponse:
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
status = request.GET.get('status', 'NEW')
status = request.GET.get('status', 'PENDING')
submission_type = request.GET.get('submission_type', '')
if submission_type == 'photo':
@@ -80,6 +186,11 @@ def submission_list(request: HttpRequest) -> HttpResponse:
context = {
'submissions': queryset,
'user': request.user,
'parks': [(p.id, str(p)) for p in Park.objects.all()],
'designers': [(d.id, str(d)) for d in Designer.objects.all()],
'manufacturers': [(m.id, str(m)) for m in Manufacturer.objects.all()],
'ride_models': [(m.id, str(m)) for m in RideModel.objects.all()],
'owners': [(u.id, str(u)) for u in User.objects.filter(role__in=['OWNER', 'ADMIN', 'SUPERUSER'])]
}
# If it's an HTMX request, return just the content
@@ -89,6 +200,64 @@ def submission_list(request: HttpRequest) -> HttpResponse:
# For direct URL access, return the full dashboard template
return render(request, 'moderation/dashboard.html', 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':
# Handle the edit submission
notes = request.POST.get('notes')
if not notes:
return HttpResponse("Notes are required when editing a submission", status=400)
try:
# Update the moderator_changes with the edited values
edited_changes = submission.changes.copy()
for field in submission.changes.keys():
if field == 'stats':
edited_stats = {}
for key in submission.changes['stats'].keys():
if new_value := request.POST.get(f'stats.{key}'):
edited_stats[key] = new_value
edited_changes['stats'] = edited_stats
else:
if new_value := request.POST.get(field):
# Handle special field types
if field in ['latitude', 'longitude', '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
submission.moderator_changes = edited_changes
submission.notes = notes
submission.save()
# Return the updated submission
context = {
'submission': submission,
'user': request.user,
'parks': [(p.id, str(p)) for p in Park.objects.all()],
'designers': [(d.id, str(d)) for d in Designer.objects.all()],
'manufacturers': [(m.id, str(m)) for m in Manufacturer.objects.all()],
'ride_models': [(m.id, str(m)) for m in RideModel.objects.all()],
'owners': [(u.id, str(u)) for u in User.objects.filter(role__in=['OWNER', 'ADMIN', 'SUPERUSER'])],
'park_areas': [(a.id, str(a)) for a in ParkArea.objects.filter(park_id=edited_changes.get('park'))] if edited_changes.get('park') else []
}
return render(request, 'moderation/partials/submission_list.html', context)
except Exception as e:
return HttpResponse(str(e), status=400)
return HttpResponse("Invalid request method", status=405)
@login_required
def approve_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
"""HTMX endpoint for approving a submission"""
@@ -105,7 +274,7 @@ def approve_submission(request: HttpRequest, submission_id: int) -> HttpResponse
_update_submission_notes(submission, request.POST.get('notes'))
# Get updated queryset with filters
status = request.GET.get('status', 'NEW')
status = request.GET.get('status', 'PENDING')
submission_type = request.GET.get('submission_type', '')
if submission_type == 'photo':
@@ -143,7 +312,7 @@ def reject_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
_update_submission_notes(submission, request.POST.get('notes'))
# Get updated queryset with filters
status = request.GET.get('status', 'NEW')
status = request.GET.get('status', 'PENDING')
submission_type = request.GET.get('submission_type', '')
if submission_type == 'photo':
@@ -179,7 +348,7 @@ def escalate_submission(request: HttpRequest, submission_id: int) -> HttpRespons
_update_submission_notes(submission, request.POST.get('notes'))
# Get updated queryset with filters
status = request.GET.get('status', 'NEW')
status = request.GET.get('status', 'PENDING')
submission_type = request.GET.get('submission_type', '')
if submission_type == 'photo':