mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:51:09 -05:00
Refactor API structure and add comprehensive user management features
- Restructure API v1 with improved serializers organization - Add user deletion requests and moderation queue system - Implement bulk moderation operations and permissions - Add user profile enhancements with display names and avatars - Expand ride and park API endpoints with better filtering - Add manufacturer API with detailed ride relationships - Improve authentication flows and error handling - Update frontend documentation and API specifications
This commit is contained in:
164
backend/apps/accounts/management/commands/delete_user.py
Normal file
164
backend/apps/accounts/management/commands/delete_user.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Django management command to delete a user while preserving their submissions.
|
||||
|
||||
Usage:
|
||||
uv run manage.py delete_user <username>
|
||||
uv run manage.py delete_user --user-id <user_id>
|
||||
uv run manage.py delete_user <username> --dry-run
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.services import UserDeletionService
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Delete a user while preserving all their submissions"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"username", nargs="?", type=str, help="Username of the user to delete"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
type=str,
|
||||
help="User ID of the user to delete (alternative to username)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be deleted without actually deleting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Skip confirmation prompt"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = options.get("username")
|
||||
user_id = options.get("user_id")
|
||||
dry_run = options.get("dry_run", False)
|
||||
force = options.get("force", False)
|
||||
|
||||
# Validate arguments
|
||||
if not username and not user_id:
|
||||
raise CommandError("You must provide either a username or --user-id")
|
||||
|
||||
if username and user_id:
|
||||
raise CommandError("You cannot provide both username and --user-id")
|
||||
|
||||
# Find the user
|
||||
try:
|
||||
if username:
|
||||
user = User.objects.get(username=username)
|
||||
else:
|
||||
user = User.objects.get(user_id=user_id)
|
||||
except User.DoesNotExist:
|
||||
identifier = username or user_id
|
||||
raise CommandError(f'User "{identifier}" does not exist')
|
||||
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise CommandError(f"Cannot delete user: {reason}")
|
||||
|
||||
# Count submissions
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "top_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
}
|
||||
|
||||
total_submissions = sum(submission_counts.values())
|
||||
|
||||
# Display user information
|
||||
self.stdout.write(self.style.WARNING("\nUser Information:"))
|
||||
self.stdout.write(f" Username: {user.username}")
|
||||
self.stdout.write(f" User ID: {user.user_id}")
|
||||
self.stdout.write(f" Email: {user.email}")
|
||||
self.stdout.write(f" Date Joined: {user.date_joined}")
|
||||
self.stdout.write(f" Role: {user.role}")
|
||||
|
||||
# Display submission counts
|
||||
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
|
||||
for submission_type, count in submission_counts.items():
|
||||
if count > 0:
|
||||
self.stdout.write(
|
||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
||||
)
|
||||
|
||||
self.stdout.write(f"\nTotal submissions: {total_submissions}")
|
||||
|
||||
if total_submissions > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nAll {total_submissions} submissions will be transferred to the "deleted_user" placeholder.'
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("\nNo submissions found for this user.")
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
|
||||
return
|
||||
|
||||
# Confirmation prompt
|
||||
if not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f'\nThis will permanently delete the user "{user.username}" '
|
||||
f"but preserve all {total_submissions} submissions."
|
||||
)
|
||||
)
|
||||
confirm = input("Are you sure you want to continue? (yes/no): ")
|
||||
if confirm.lower() not in ["yes", "y"]:
|
||||
self.stdout.write(self.style.ERROR("Operation cancelled."))
|
||||
return
|
||||
|
||||
# Perform the deletion
|
||||
try:
|
||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'
|
||||
)
|
||||
)
|
||||
|
||||
preserved_count = sum(result["preserved_submissions"].values())
|
||||
if preserved_count > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Preserved {preserved_count} submissions under user "{result["transferred_to"]["username"]}"'
|
||||
)
|
||||
)
|
||||
|
||||
# Show detailed preservation summary
|
||||
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
|
||||
for submission_type, count in result["preserved_submissions"].items():
|
||||
if count > 0:
|
||||
self.stdout.write(
|
||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise CommandError(f"Error deleting user: {str(e)}")
|
||||
@@ -0,0 +1,219 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 14:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"accounts",
|
||||
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
|
||||
),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserDeletionRequest",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"verification_code",
|
||||
models.CharField(
|
||||
help_text="Unique verification code sent to user's email",
|
||||
max_length=32,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(
|
||||
help_text="When this deletion request expires"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_sent_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When the verification email was sent",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=5,
|
||||
help_text="Maximum number of verification attempts allowed",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_used",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this deletion request has been used",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="deletion_request",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserDeletionRequestEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"verification_code",
|
||||
models.CharField(
|
||||
help_text="Unique verification code sent to user's email",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(
|
||||
help_text="When this deletion request expires"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_sent_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When the verification email was sent",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=5,
|
||||
help_text="Maximum number of verification attempts allowed",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_used",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this deletion request has been used",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.userdeletionrequest",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["verification_code"], name="accounts_us_verific_94460d_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userdeletionrequest",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
|
||||
hash="c1735fe8eb50247b0afe2bea9d32f83c31da6419",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_b982c",
|
||||
table="accounts_userdeletionrequest",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userdeletionrequest",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
|
||||
hash="6bf807ce3bed069ab30462d3fd7688a7593a7fd0",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_27723",
|
||||
table="accounts_userdeletionrequest",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,309 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 15:10
|
||||
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0004_userdeletionrequest_userdeletionrequestevent_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="activity_visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_friend_requests",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_messages",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_profile_comments",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="email_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="last_password_change",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="login_history_retention",
|
||||
field=models.IntegerField(default=90),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="login_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="notification_preferences",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="privacy_level",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="push_notifications",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="search_visibility",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="session_timeout",
|
||||
field=models.IntegerField(default=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_email",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_join_date",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_photos",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_real_name",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_reviews",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_statistics",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_top_lists",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="two_factor_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="activity_visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_friend_requests",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_messages",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_profile_comments",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="email_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="last_password_change",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="login_history_retention",
|
||||
field=models.IntegerField(default=90),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="login_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="notification_preferences",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="privacy_level",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="push_notifications",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="search_visibility",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="session_timeout",
|
||||
field=models.IntegerField(default=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_email",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_join_date",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_photos",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_real_name",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_reviews",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_statistics",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_top_lists",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="two_factor_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="63ede44a0db376d673078f3464edc89aa8ca80c7",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="9157131b568edafe1e5fcdf313bfeaaa8adcfee4",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,456 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 15:29
|
||||
|
||||
import cloudflare_images.field
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"accounts",
|
||||
"0005_remove_user_insert_insert_remove_user_update_update_and_more",
|
||||
),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="userprofile",
|
||||
name="avatar",
|
||||
field=cloudflare_images.field.CloudflareImagesField(
|
||||
blank=True, null=True, upload_to="", variant="public"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofileevent",
|
||||
name="avatar",
|
||||
field=cloudflare_images.field.CloudflareImagesField(
|
||||
blank=True, null=True, upload_to="", variant="public"
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NotificationPreference",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("submission_approved_email", models.BooleanField(default=True)),
|
||||
("submission_approved_push", models.BooleanField(default=True)),
|
||||
("submission_approved_inapp", models.BooleanField(default=True)),
|
||||
("submission_rejected_email", models.BooleanField(default=True)),
|
||||
("submission_rejected_push", models.BooleanField(default=True)),
|
||||
("submission_rejected_inapp", models.BooleanField(default=True)),
|
||||
("submission_pending_email", models.BooleanField(default=False)),
|
||||
("submission_pending_push", models.BooleanField(default=False)),
|
||||
("submission_pending_inapp", models.BooleanField(default=True)),
|
||||
("review_reply_email", models.BooleanField(default=True)),
|
||||
("review_reply_push", models.BooleanField(default=True)),
|
||||
("review_reply_inapp", models.BooleanField(default=True)),
|
||||
("review_helpful_email", models.BooleanField(default=False)),
|
||||
("review_helpful_push", models.BooleanField(default=True)),
|
||||
("review_helpful_inapp", models.BooleanField(default=True)),
|
||||
("friend_request_email", models.BooleanField(default=True)),
|
||||
("friend_request_push", models.BooleanField(default=True)),
|
||||
("friend_request_inapp", models.BooleanField(default=True)),
|
||||
("friend_accepted_email", models.BooleanField(default=False)),
|
||||
("friend_accepted_push", models.BooleanField(default=True)),
|
||||
("friend_accepted_inapp", models.BooleanField(default=True)),
|
||||
("message_received_email", models.BooleanField(default=True)),
|
||||
("message_received_push", models.BooleanField(default=True)),
|
||||
("message_received_inapp", models.BooleanField(default=True)),
|
||||
("system_announcement_email", models.BooleanField(default=True)),
|
||||
("system_announcement_push", models.BooleanField(default=False)),
|
||||
("system_announcement_inapp", models.BooleanField(default=True)),
|
||||
("account_security_email", models.BooleanField(default=True)),
|
||||
("account_security_push", models.BooleanField(default=True)),
|
||||
("account_security_inapp", models.BooleanField(default=True)),
|
||||
("feature_update_email", models.BooleanField(default=True)),
|
||||
("feature_update_push", models.BooleanField(default=False)),
|
||||
("feature_update_inapp", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_email", models.BooleanField(default=False)),
|
||||
("achievement_unlocked_push", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_inapp", models.BooleanField(default=True)),
|
||||
("milestone_reached_email", models.BooleanField(default=False)),
|
||||
("milestone_reached_push", models.BooleanField(default=True)),
|
||||
("milestone_reached_inapp", models.BooleanField(default=True)),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notification_preference",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification Preference",
|
||||
"verbose_name_plural": "Notification Preferences",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NotificationPreferenceEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("submission_approved_email", models.BooleanField(default=True)),
|
||||
("submission_approved_push", models.BooleanField(default=True)),
|
||||
("submission_approved_inapp", models.BooleanField(default=True)),
|
||||
("submission_rejected_email", models.BooleanField(default=True)),
|
||||
("submission_rejected_push", models.BooleanField(default=True)),
|
||||
("submission_rejected_inapp", models.BooleanField(default=True)),
|
||||
("submission_pending_email", models.BooleanField(default=False)),
|
||||
("submission_pending_push", models.BooleanField(default=False)),
|
||||
("submission_pending_inapp", models.BooleanField(default=True)),
|
||||
("review_reply_email", models.BooleanField(default=True)),
|
||||
("review_reply_push", models.BooleanField(default=True)),
|
||||
("review_reply_inapp", models.BooleanField(default=True)),
|
||||
("review_helpful_email", models.BooleanField(default=False)),
|
||||
("review_helpful_push", models.BooleanField(default=True)),
|
||||
("review_helpful_inapp", models.BooleanField(default=True)),
|
||||
("friend_request_email", models.BooleanField(default=True)),
|
||||
("friend_request_push", models.BooleanField(default=True)),
|
||||
("friend_request_inapp", models.BooleanField(default=True)),
|
||||
("friend_accepted_email", models.BooleanField(default=False)),
|
||||
("friend_accepted_push", models.BooleanField(default=True)),
|
||||
("friend_accepted_inapp", models.BooleanField(default=True)),
|
||||
("message_received_email", models.BooleanField(default=True)),
|
||||
("message_received_push", models.BooleanField(default=True)),
|
||||
("message_received_inapp", models.BooleanField(default=True)),
|
||||
("system_announcement_email", models.BooleanField(default=True)),
|
||||
("system_announcement_push", models.BooleanField(default=False)),
|
||||
("system_announcement_inapp", models.BooleanField(default=True)),
|
||||
("account_security_email", models.BooleanField(default=True)),
|
||||
("account_security_push", models.BooleanField(default=True)),
|
||||
("account_security_inapp", models.BooleanField(default=True)),
|
||||
("feature_update_email", models.BooleanField(default=True)),
|
||||
("feature_update_push", models.BooleanField(default=False)),
|
||||
("feature_update_inapp", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_email", models.BooleanField(default=False)),
|
||||
("achievement_unlocked_push", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_inapp", models.BooleanField(default=True)),
|
||||
("milestone_reached_email", models.BooleanField(default=False)),
|
||||
("milestone_reached_push", models.BooleanField(default=True)),
|
||||
("milestone_reached_inapp", models.BooleanField(default=True)),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.notificationpreference",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserNotification",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notification_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("submission_approved", "Submission Approved"),
|
||||
("submission_rejected", "Submission Rejected"),
|
||||
("submission_pending", "Submission Pending Review"),
|
||||
("review_reply", "Review Reply"),
|
||||
("review_helpful", "Review Marked Helpful"),
|
||||
("friend_request", "Friend Request"),
|
||||
("friend_accepted", "Friend Request Accepted"),
|
||||
("message_received", "Message Received"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
("system_announcement", "System Announcement"),
|
||||
("account_security", "Account Security"),
|
||||
("feature_update", "Feature Update"),
|
||||
("maintenance", "Maintenance Notice"),
|
||||
("achievement_unlocked", "Achievement Unlocked"),
|
||||
("milestone_reached", "Milestone Reached"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("message", models.TextField()),
|
||||
("object_id", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
default="normal",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("is_read", models.BooleanField(default=False)),
|
||||
("read_at", models.DateTimeField(blank=True, null=True)),
|
||||
("email_sent", models.BooleanField(default=False)),
|
||||
("email_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("push_sent", models.BooleanField(default=False)),
|
||||
("push_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("extra_data", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("expires_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserNotificationEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notification_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("submission_approved", "Submission Approved"),
|
||||
("submission_rejected", "Submission Rejected"),
|
||||
("submission_pending", "Submission Pending Review"),
|
||||
("review_reply", "Review Reply"),
|
||||
("review_helpful", "Review Marked Helpful"),
|
||||
("friend_request", "Friend Request"),
|
||||
("friend_accepted", "Friend Request Accepted"),
|
||||
("message_received", "Message Received"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
("system_announcement", "System Announcement"),
|
||||
("account_security", "Account Security"),
|
||||
("feature_update", "Feature Update"),
|
||||
("maintenance", "Maintenance Notice"),
|
||||
("achievement_unlocked", "Achievement Unlocked"),
|
||||
("milestone_reached", "Milestone Reached"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("message", models.TextField()),
|
||||
("object_id", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
default="normal",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("is_read", models.BooleanField(default=False)),
|
||||
("read_at", models.DateTimeField(blank=True, null=True)),
|
||||
("email_sent", models.BooleanField(default=False)),
|
||||
("email_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("push_sent", models.BooleanField(default=False)),
|
||||
("push_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("extra_data", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("expires_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.usernotification",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="notificationpreference",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="bbaa03794722dab95c97ed93731d8b55f314dbdc",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_4a06b",
|
||||
table="accounts_notificationpreference",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="notificationpreference",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="0de72b66f87f795aaeb49be8e4e57d632781bd3a",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_d3fc0",
|
||||
table="accounts_notificationpreference",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["user", "notification_type"],
|
||||
name="accounts_us_user_id_8cea97_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="accounts_us_created_a62f54_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["expires_at"], name="accounts_us_expires_f267b1_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="usernotification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="822a189e675a5903841d19738c29aa94267417f1",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_2794b",
|
||||
table="accounts_usernotification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="usernotification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="1fd24a77684747bd9a521447a2978529085b6c07",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_15c54",
|
||||
table="accounts_usernotification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 19:09
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0006_alter_userprofile_avatar_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofile",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofileevent",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="97e02685f062c04c022f6975784dce80396d4371",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="e074b317983a921b440b0c8754ba04a31ea513dd",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,11 +1,16 @@
|
||||
from django.dispatch import receiver
|
||||
from django.db.models.signals import post_save
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
from cloudflare_images.field import CloudflareImagesField
|
||||
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
@@ -34,6 +39,11 @@ class User(AbstractUser):
|
||||
LIGHT = "light", _("Light")
|
||||
DARK = "dark", _("Dark")
|
||||
|
||||
class PrivacyLevel(models.TextChoices):
|
||||
PUBLIC = "public", _("Public")
|
||||
FRIENDS = "friends", _("Friends Only")
|
||||
PRIVATE = "private", _("Private")
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
@@ -60,6 +70,54 @@ class User(AbstractUser):
|
||||
default=ThemePreference.LIGHT,
|
||||
)
|
||||
|
||||
# Notification preferences
|
||||
email_notifications = models.BooleanField(default=True)
|
||||
push_notifications = models.BooleanField(default=False)
|
||||
|
||||
# Privacy settings
|
||||
privacy_level = models.CharField(
|
||||
max_length=10,
|
||||
choices=PrivacyLevel.choices,
|
||||
default=PrivacyLevel.PUBLIC,
|
||||
)
|
||||
show_email = models.BooleanField(default=False)
|
||||
show_real_name = models.BooleanField(default=True)
|
||||
show_join_date = models.BooleanField(default=True)
|
||||
show_statistics = models.BooleanField(default=True)
|
||||
show_reviews = models.BooleanField(default=True)
|
||||
show_photos = models.BooleanField(default=True)
|
||||
show_top_lists = models.BooleanField(default=True)
|
||||
allow_friend_requests = models.BooleanField(default=True)
|
||||
allow_messages = models.BooleanField(default=True)
|
||||
allow_profile_comments = models.BooleanField(default=False)
|
||||
search_visibility = models.BooleanField(default=True)
|
||||
activity_visibility = models.CharField(
|
||||
max_length=10,
|
||||
choices=PrivacyLevel.choices,
|
||||
default=PrivacyLevel.FRIENDS,
|
||||
)
|
||||
|
||||
# Security settings
|
||||
two_factor_enabled = models.BooleanField(default=False)
|
||||
login_notifications = models.BooleanField(default=True)
|
||||
session_timeout = models.IntegerField(default=30) # days
|
||||
login_history_retention = models.IntegerField(default=90) # days
|
||||
last_password_change = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Display name - core user data for better performance
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
)
|
||||
|
||||
# Detailed notification preferences (JSON field for flexibility)
|
||||
notification_preferences = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.get_display_name()
|
||||
|
||||
@@ -68,6 +126,9 @@ class User(AbstractUser):
|
||||
|
||||
def get_display_name(self):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
if self.display_name:
|
||||
return self.display_name
|
||||
# Fallback to profile display_name for backward compatibility
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
@@ -92,10 +153,10 @@ class UserProfile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
)
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True)
|
||||
avatar = CloudflareImagesField(blank=True, null=True)
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
@@ -112,18 +173,37 @@ class UserProfile(models.Model):
|
||||
flat_ride_credits = models.IntegerField(default=0)
|
||||
water_ride_credits = models.IntegerField(default=0)
|
||||
|
||||
def get_avatar(self):
|
||||
def get_avatar_url(self):
|
||||
"""
|
||||
Return the avatar URL or serve a pre-generated avatar based on the
|
||||
first letter of the username
|
||||
Return the avatar URL or generate a default letter-based avatar URL
|
||||
"""
|
||||
if self.avatar:
|
||||
return self.avatar.url
|
||||
first_letter = self.user.username.upper()
|
||||
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
|
||||
if os.path.exists(avatar_path):
|
||||
return f"/{avatar_path}"
|
||||
return "/static/images/default-avatar.png"
|
||||
# Return Cloudflare Images URL with avatar variant
|
||||
return self.avatar.url_variant("avatar")
|
||||
|
||||
# Generate default letter-based avatar using first letter of username
|
||||
first_letter = self.user.username[0].upper() if self.user.username else "U"
|
||||
# Use a service like UI Avatars or generate a simple colored avatar
|
||||
return f"https://ui-avatars.com/api/?name={first_letter}&size=200&background=random&color=fff&bold=true"
|
||||
|
||||
def get_avatar_variants(self):
|
||||
"""
|
||||
Return avatar variants for different use cases
|
||||
"""
|
||||
if self.avatar:
|
||||
return {
|
||||
"thumbnail": self.avatar.url_variant("thumbnail"),
|
||||
"avatar": self.avatar.url_variant("avatar"),
|
||||
"large": self.avatar.url_variant("large"),
|
||||
}
|
||||
|
||||
# For default avatars, return the same URL for all variants
|
||||
default_url = self.get_avatar_url()
|
||||
return {
|
||||
"thumbnail": default_url,
|
||||
"avatar": default_url,
|
||||
"large": default_url,
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If no display name is set, use the username
|
||||
@@ -220,3 +300,334 @@ class TopListItem(TrackedModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserDeletionRequest(models.Model):
|
||||
"""
|
||||
Model to track user deletion requests with email verification.
|
||||
|
||||
When a user requests to delete their account, a verification code
|
||||
is sent to their email. The deletion is only processed when they
|
||||
provide the correct code.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User, on_delete=models.CASCADE, related_name="deletion_request"
|
||||
)
|
||||
|
||||
verification_code = models.CharField(
|
||||
max_length=32,
|
||||
unique=True,
|
||||
help_text="Unique verification code sent to user's email",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(help_text="When this deletion request expires")
|
||||
|
||||
email_sent_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When the verification email was sent"
|
||||
)
|
||||
|
||||
attempts = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
)
|
||||
|
||||
max_attempts = models.PositiveIntegerField(
|
||||
default=5, help_text="Maximum number of verification attempts allowed"
|
||||
)
|
||||
|
||||
is_used = models.BooleanField(
|
||||
default=False, help_text="Whether this deletion request has been used"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["verification_code"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
models.Index(fields=["user", "is_used"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Deletion request for {self.user.username} - {self.verification_code}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.verification_code:
|
||||
self.verification_code = self.generate_verification_code()
|
||||
|
||||
if not self.expires_at:
|
||||
# Deletion requests expire after 24 hours
|
||||
self.expires_at = timezone.now() + timedelta(hours=24)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def generate_verification_code():
|
||||
"""Generate a unique 8-character verification code."""
|
||||
while True:
|
||||
# Generate a random 8-character alphanumeric code
|
||||
code = "".join(
|
||||
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
|
||||
)
|
||||
|
||||
# Ensure it's unique
|
||||
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
|
||||
return code
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if this deletion request has expired."""
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if this deletion request is still valid."""
|
||||
return (
|
||||
not self.is_used
|
||||
and not self.is_expired()
|
||||
and self.attempts < self.max_attempts
|
||||
)
|
||||
|
||||
def increment_attempts(self):
|
||||
"""Increment the number of verification attempts."""
|
||||
self.attempts += 1
|
||||
self.save(update_fields=["attempts"])
|
||||
|
||||
def mark_as_used(self):
|
||||
"""Mark this deletion request as used."""
|
||||
self.is_used = True
|
||||
self.save(update_fields=["is_used"])
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Remove expired deletion requests."""
|
||||
expired_requests = cls.objects.filter(
|
||||
expires_at__lt=timezone.now(), is_used=False
|
||||
)
|
||||
count = expired_requests.count()
|
||||
expired_requests.delete()
|
||||
return count
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserNotification(TrackedModel):
|
||||
"""
|
||||
Model to store user notifications for various events.
|
||||
|
||||
This includes submission approvals, rejections, system announcements,
|
||||
and other user-relevant notifications.
|
||||
"""
|
||||
|
||||
class NotificationType(models.TextChoices):
|
||||
# Submission related
|
||||
SUBMISSION_APPROVED = "submission_approved", _("Submission Approved")
|
||||
SUBMISSION_REJECTED = "submission_rejected", _("Submission Rejected")
|
||||
SUBMISSION_PENDING = "submission_pending", _("Submission Pending Review")
|
||||
|
||||
# Review related
|
||||
REVIEW_REPLY = "review_reply", _("Review Reply")
|
||||
REVIEW_HELPFUL = "review_helpful", _("Review Marked Helpful")
|
||||
|
||||
# Social related
|
||||
FRIEND_REQUEST = "friend_request", _("Friend Request")
|
||||
FRIEND_ACCEPTED = "friend_accepted", _("Friend Request Accepted")
|
||||
MESSAGE_RECEIVED = "message_received", _("Message Received")
|
||||
PROFILE_COMMENT = "profile_comment", _("Profile Comment")
|
||||
|
||||
# System related
|
||||
SYSTEM_ANNOUNCEMENT = "system_announcement", _("System Announcement")
|
||||
ACCOUNT_SECURITY = "account_security", _("Account Security")
|
||||
FEATURE_UPDATE = "feature_update", _("Feature Update")
|
||||
MAINTENANCE = "maintenance", _("Maintenance Notice")
|
||||
|
||||
# Achievement related
|
||||
ACHIEVEMENT_UNLOCKED = "achievement_unlocked", _("Achievement Unlocked")
|
||||
MILESTONE_REACHED = "milestone_reached", _("Milestone Reached")
|
||||
|
||||
class Priority(models.TextChoices):
|
||||
LOW = "low", _("Low")
|
||||
NORMAL = "normal", _("Normal")
|
||||
HIGH = "high", _("High")
|
||||
URGENT = "urgent", _("Urgent")
|
||||
|
||||
# Core fields
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="notifications"
|
||||
)
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length=30, choices=NotificationType.choices
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
message = models.TextField()
|
||||
|
||||
# Optional related object (submission, review, etc.)
|
||||
content_type = models.ForeignKey(
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
related_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Metadata
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=Priority.choices, default=Priority.NORMAL
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
is_read = models.BooleanField(default=False)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Delivery tracking
|
||||
email_sent = models.BooleanField(default=False)
|
||||
email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
push_sent = models.BooleanField(default=False)
|
||||
push_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Additional data (JSON field for flexibility)
|
||||
extra_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "is_read"]),
|
||||
models.Index(fields=["user", "notification_type"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username}: {self.title}"
|
||||
|
||||
def mark_as_read(self):
|
||||
"""Mark notification as read."""
|
||||
if not self.is_read:
|
||||
self.is_read = True
|
||||
self.read_at = timezone.now()
|
||||
self.save(update_fields=["is_read", "read_at"])
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if notification has expired."""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Remove expired notifications."""
|
||||
expired_notifications = cls.objects.filter(expires_at__lt=timezone.now())
|
||||
count = expired_notifications.count()
|
||||
expired_notifications.delete()
|
||||
return count
|
||||
|
||||
@classmethod
|
||||
def mark_all_read_for_user(cls, user):
|
||||
"""Mark all notifications as read for a specific user."""
|
||||
return cls.objects.filter(user=user, is_read=False).update(
|
||||
is_read=True, read_at=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class NotificationPreference(TrackedModel):
|
||||
"""
|
||||
User preferences for different types of notifications.
|
||||
|
||||
This allows users to control which notifications they receive
|
||||
and through which channels (email, push, in-app).
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User, on_delete=models.CASCADE, related_name="notification_preference"
|
||||
)
|
||||
|
||||
# Submission notifications
|
||||
submission_approved_email = models.BooleanField(default=True)
|
||||
submission_approved_push = models.BooleanField(default=True)
|
||||
submission_approved_inapp = models.BooleanField(default=True)
|
||||
|
||||
submission_rejected_email = models.BooleanField(default=True)
|
||||
submission_rejected_push = models.BooleanField(default=True)
|
||||
submission_rejected_inapp = models.BooleanField(default=True)
|
||||
|
||||
submission_pending_email = models.BooleanField(default=False)
|
||||
submission_pending_push = models.BooleanField(default=False)
|
||||
submission_pending_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Review notifications
|
||||
review_reply_email = models.BooleanField(default=True)
|
||||
review_reply_push = models.BooleanField(default=True)
|
||||
review_reply_inapp = models.BooleanField(default=True)
|
||||
|
||||
review_helpful_email = models.BooleanField(default=False)
|
||||
review_helpful_push = models.BooleanField(default=True)
|
||||
review_helpful_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Social notifications
|
||||
friend_request_email = models.BooleanField(default=True)
|
||||
friend_request_push = models.BooleanField(default=True)
|
||||
friend_request_inapp = models.BooleanField(default=True)
|
||||
|
||||
friend_accepted_email = models.BooleanField(default=False)
|
||||
friend_accepted_push = models.BooleanField(default=True)
|
||||
friend_accepted_inapp = models.BooleanField(default=True)
|
||||
|
||||
message_received_email = models.BooleanField(default=True)
|
||||
message_received_push = models.BooleanField(default=True)
|
||||
message_received_inapp = models.BooleanField(default=True)
|
||||
|
||||
# System notifications
|
||||
system_announcement_email = models.BooleanField(default=True)
|
||||
system_announcement_push = models.BooleanField(default=False)
|
||||
system_announcement_inapp = models.BooleanField(default=True)
|
||||
|
||||
account_security_email = models.BooleanField(default=True)
|
||||
account_security_push = models.BooleanField(default=True)
|
||||
account_security_inapp = models.BooleanField(default=True)
|
||||
|
||||
feature_update_email = models.BooleanField(default=True)
|
||||
feature_update_push = models.BooleanField(default=False)
|
||||
feature_update_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Achievement notifications
|
||||
achievement_unlocked_email = models.BooleanField(default=False)
|
||||
achievement_unlocked_push = models.BooleanField(default=True)
|
||||
achievement_unlocked_inapp = models.BooleanField(default=True)
|
||||
|
||||
milestone_reached_email = models.BooleanField(default=False)
|
||||
milestone_reached_push = models.BooleanField(default=True)
|
||||
milestone_reached_inapp = models.BooleanField(default=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Notification Preference"
|
||||
verbose_name_plural = "Notification Preferences"
|
||||
|
||||
def __str__(self):
|
||||
return f"Notification preferences for {self.user.username}"
|
||||
|
||||
def should_send_notification(self, notification_type, channel):
|
||||
"""
|
||||
Check if a notification should be sent for a specific type and channel.
|
||||
|
||||
Args:
|
||||
notification_type: The type of notification (from UserNotification.NotificationType)
|
||||
channel: The delivery channel ('email', 'push', 'inapp')
|
||||
|
||||
Returns:
|
||||
bool: True if notification should be sent, False otherwise
|
||||
"""
|
||||
field_name = f"{notification_type}_{channel}"
|
||||
return getattr(self, field_name, False)
|
||||
|
||||
|
||||
# Signal handlers for automatic notification preference creation
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_notification_preference(sender, instance, created, **kwargs):
|
||||
"""Create notification preferences when a new user is created."""
|
||||
if created:
|
||||
NotificationPreference.objects.create(user=instance)
|
||||
|
||||
364
backend/apps/accounts/services.py
Normal file
364
backend/apps/accounts/services.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
User management services for ThrillWiki.
|
||||
|
||||
This module contains services for user account management including
|
||||
user deletion while preserving submissions.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from apps.email_service.services import EmailService
|
||||
from .models import User, UserProfile, UserDeletionRequest
|
||||
|
||||
|
||||
class UserDeletionService:
|
||||
"""Service for handling user deletion while preserving submissions."""
|
||||
|
||||
DELETED_USER_USERNAME = "deleted_user"
|
||||
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
|
||||
DELETED_DISPLAY_NAME = "Deleted User"
|
||||
|
||||
@classmethod
|
||||
def get_or_create_deleted_user(cls) -> User:
|
||||
"""Get or create the system deleted user placeholder."""
|
||||
deleted_user, created = User.objects.get_or_create(
|
||||
username=cls.DELETED_USER_USERNAME,
|
||||
defaults={
|
||||
"email": cls.DELETED_USER_EMAIL,
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"is_active": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
"role": User.Roles.USER,
|
||||
"is_banned": True,
|
||||
"ban_reason": "System placeholder for deleted users",
|
||||
"ban_date": timezone.now(),
|
||||
},
|
||||
)
|
||||
|
||||
if created:
|
||||
# Create profile for deleted user
|
||||
UserProfile.objects.create(
|
||||
user=deleted_user,
|
||||
display_name=cls.DELETED_DISPLAY_NAME,
|
||||
bio="This user account has been deleted.",
|
||||
)
|
||||
|
||||
return deleted_user
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def delete_user_preserve_submissions(cls, user: User) -> dict:
|
||||
"""
|
||||
Delete a user while preserving all their submissions.
|
||||
|
||||
This method:
|
||||
1. Transfers all user submissions to a system "deleted_user" placeholder
|
||||
2. Deletes the user's profile and account data
|
||||
3. Returns a summary of what was preserved
|
||||
|
||||
Args:
|
||||
user: The user to delete
|
||||
|
||||
Returns:
|
||||
dict: Summary of preserved submissions
|
||||
"""
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
raise ValueError("Cannot delete the system deleted user placeholder")
|
||||
|
||||
deleted_user = cls.get_or_create_deleted_user()
|
||||
|
||||
# Count submissions before transfer
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "top_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"moderated_park_reviews": getattr(
|
||||
user, "moderated_park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"moderated_ride_reviews": getattr(
|
||||
user, "moderated_ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"handled_submissions": getattr(
|
||||
user, "handled_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"handled_photos": getattr(
|
||||
user, "handled_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
}
|
||||
|
||||
# Transfer all submissions to deleted user
|
||||
# Reviews
|
||||
if hasattr(user, "park_reviews"):
|
||||
getattr(user, "park_reviews").update(user=deleted_user)
|
||||
if hasattr(user, "ride_reviews"):
|
||||
getattr(user, "ride_reviews").update(user=deleted_user)
|
||||
|
||||
# Photos
|
||||
if hasattr(user, "uploaded_park_photos"):
|
||||
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
|
||||
if hasattr(user, "uploaded_ride_photos"):
|
||||
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
|
||||
|
||||
# Top Lists
|
||||
if hasattr(user, "top_lists"):
|
||||
getattr(user, "top_lists").update(user=deleted_user)
|
||||
|
||||
# Moderation submissions
|
||||
if hasattr(user, "edit_submissions"):
|
||||
getattr(user, "edit_submissions").update(user=deleted_user)
|
||||
if hasattr(user, "photo_submissions"):
|
||||
getattr(user, "photo_submissions").update(user=deleted_user)
|
||||
|
||||
# Moderation actions - these can be set to NULL since they're not user content
|
||||
if hasattr(user, "moderated_park_reviews"):
|
||||
getattr(user, "moderated_park_reviews").update(moderated_by=None)
|
||||
if hasattr(user, "moderated_ride_reviews"):
|
||||
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
|
||||
if hasattr(user, "handled_submissions"):
|
||||
getattr(user, "handled_submissions").update(handled_by=None)
|
||||
if hasattr(user, "handled_photos"):
|
||||
getattr(user, "handled_photos").update(handled_by=None)
|
||||
|
||||
# Store user info for the summary
|
||||
user_info = {
|
||||
"username": user.username,
|
||||
"user_id": user.user_id,
|
||||
"email": user.email,
|
||||
"date_joined": user.date_joined,
|
||||
}
|
||||
|
||||
# Delete the user (this will cascade delete the profile)
|
||||
user.delete()
|
||||
|
||||
return {
|
||||
"deleted_user": user_info,
|
||||
"preserved_submissions": submission_counts,
|
||||
"transferred_to": {
|
||||
"username": deleted_user.username,
|
||||
"user_id": deleted_user.user_id,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a user can be safely deleted.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
tuple: (can_delete: bool, reason: Optional[str])
|
||||
"""
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
return False, "Cannot delete the system deleted user placeholder"
|
||||
|
||||
if user.is_superuser:
|
||||
return False, "Cannot delete superuser accounts"
|
||||
|
||||
# Add any other business rules here
|
||||
|
||||
return True, None
|
||||
|
||||
@classmethod
|
||||
def request_user_deletion(cls, user: User) -> UserDeletionRequest:
|
||||
"""
|
||||
Create a user deletion request and send verification email.
|
||||
|
||||
Args:
|
||||
user: The user requesting deletion
|
||||
|
||||
Returns:
|
||||
UserDeletionRequest: The created deletion request
|
||||
"""
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = cls.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise ValueError(f"Cannot delete user: {reason}")
|
||||
|
||||
# Remove any existing deletion request for this user
|
||||
UserDeletionRequest.objects.filter(user=user).delete()
|
||||
|
||||
# Create new deletion request
|
||||
deletion_request = UserDeletionRequest.objects.create(user=user)
|
||||
|
||||
# Send verification email
|
||||
cls.send_deletion_verification_email(deletion_request)
|
||||
|
||||
return deletion_request
|
||||
|
||||
@classmethod
|
||||
def send_deletion_verification_email(cls, deletion_request: UserDeletionRequest):
|
||||
"""
|
||||
Send verification email for account deletion.
|
||||
|
||||
Args:
|
||||
deletion_request: The deletion request to send email for
|
||||
"""
|
||||
user = deletion_request.user
|
||||
|
||||
# Get current site for email service
|
||||
try:
|
||||
site = Site.objects.get_current()
|
||||
except Site.DoesNotExist:
|
||||
# Fallback to default site
|
||||
site = Site.objects.get_or_create(
|
||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
|
||||
)[0]
|
||||
|
||||
# Prepare email context
|
||||
context = {
|
||||
"user": user,
|
||||
"verification_code": deletion_request.verification_code,
|
||||
"expires_at": deletion_request.expires_at,
|
||||
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
|
||||
"frontend_domain": getattr(
|
||||
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
|
||||
),
|
||||
}
|
||||
|
||||
# Render email content
|
||||
subject = f"Confirm Account Deletion - {context['site_name']}"
|
||||
|
||||
# Create email message with 1-hour expiration notice
|
||||
message = f"""
|
||||
Hello {user.get_display_name()},
|
||||
|
||||
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
|
||||
|
||||
Verification Code: {deletion_request.verification_code}
|
||||
|
||||
This code will expire in 1 hour on {deletion_request.expires_at.strftime('%B %d, %Y at %I:%M %p UTC')}.
|
||||
|
||||
IMPORTANT: This action cannot be undone. Your account will be permanently deleted, but all your reviews, photos, and other contributions will be preserved on the site.
|
||||
|
||||
If you did not request this deletion, please ignore this email and your account will remain active.
|
||||
|
||||
To complete the deletion, enter the verification code in the account deletion form on our website.
|
||||
|
||||
Best regards,
|
||||
The ThrillWiki Team
|
||||
""".strip()
|
||||
|
||||
# Send email using custom email service
|
||||
try:
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject=subject,
|
||||
text=message,
|
||||
site=site,
|
||||
from_email="no-reply@thrillwiki.com",
|
||||
)
|
||||
|
||||
# Update email sent timestamp
|
||||
deletion_request.email_sent_at = timezone.now()
|
||||
deletion_request.save(update_fields=["email_sent_at"])
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but don't fail the request creation
|
||||
print(f"Failed to send deletion verification email to {user.email}: {e}")
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def verify_and_delete_user(cls, verification_code: str) -> dict:
|
||||
"""
|
||||
Verify deletion code and delete the user account.
|
||||
|
||||
Args:
|
||||
verification_code: The verification code from the email
|
||||
|
||||
Returns:
|
||||
dict: Summary of the deletion
|
||||
|
||||
Raises:
|
||||
ValueError: If verification fails
|
||||
"""
|
||||
try:
|
||||
deletion_request = UserDeletionRequest.objects.get(
|
||||
verification_code=verification_code
|
||||
)
|
||||
except UserDeletionRequest.DoesNotExist:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Check if request is still valid
|
||||
if not deletion_request.is_valid():
|
||||
if deletion_request.is_expired():
|
||||
raise ValueError("Verification code has expired")
|
||||
elif deletion_request.is_used:
|
||||
raise ValueError("Verification code has already been used")
|
||||
elif deletion_request.attempts >= deletion_request.max_attempts:
|
||||
raise ValueError("Too many verification attempts")
|
||||
else:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Increment attempts
|
||||
deletion_request.increment_attempts()
|
||||
|
||||
# Mark as used
|
||||
deletion_request.mark_as_used()
|
||||
|
||||
# Delete the user
|
||||
user = deletion_request.user
|
||||
result = cls.delete_user_preserve_submissions(user)
|
||||
|
||||
# Add deletion request info to result
|
||||
result["deletion_request"] = {
|
||||
"verification_code": verification_code,
|
||||
"created_at": deletion_request.created_at,
|
||||
"verified_at": timezone.now(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def cancel_deletion_request(cls, user: User) -> bool:
|
||||
"""
|
||||
Cancel a pending deletion request.
|
||||
|
||||
Args:
|
||||
user: The user whose deletion request to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if a request was cancelled, False if no request existed
|
||||
"""
|
||||
try:
|
||||
deletion_request = getattr(user, "deletion_request", None)
|
||||
if deletion_request:
|
||||
deletion_request.delete()
|
||||
return True
|
||||
return False
|
||||
except UserDeletionRequest.DoesNotExist:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired_deletion_requests(cls) -> int:
|
||||
"""
|
||||
Clean up expired deletion requests.
|
||||
|
||||
Returns:
|
||||
int: Number of expired requests cleaned up
|
||||
"""
|
||||
return UserDeletionRequest.cleanup_expired()
|
||||
379
backend/apps/accounts/services/notification_service.py
Normal file
379
backend/apps/accounts/services/notification_service.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Notification service for creating and managing user notifications.
|
||||
|
||||
This service handles the creation, delivery, and management of notifications
|
||||
for various events including submission approvals/rejections.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from typing import Optional, Dict, Any, List
|
||||
import logging
|
||||
|
||||
from apps.accounts.models.notifications import UserNotification, NotificationPreference
|
||||
from apps.accounts.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for creating and managing user notifications."""
|
||||
|
||||
@staticmethod
|
||||
def create_notification(
|
||||
user: User,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
related_object: Optional[Any] = None,
|
||||
priority: str = UserNotification.Priority.NORMAL,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
expires_at: Optional[timezone.datetime] = None,
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a new notification for a user.
|
||||
|
||||
Args:
|
||||
user: The user to notify
|
||||
notification_type: Type of notification (from UserNotification.NotificationType)
|
||||
title: Notification title
|
||||
message: Notification message
|
||||
related_object: Optional related object (submission, review, etc.)
|
||||
priority: Notification priority
|
||||
extra_data: Additional data to store with notification
|
||||
expires_at: When the notification expires
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
# Get content type and object ID if related object provided
|
||||
content_type = None
|
||||
object_id = None
|
||||
if related_object:
|
||||
content_type = ContentType.objects.get_for_model(related_object)
|
||||
object_id = related_object.pk
|
||||
|
||||
# Create the notification
|
||||
notification = UserNotification.objects.create(
|
||||
user=user,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
content_type=content_type,
|
||||
object_id=object_id,
|
||||
priority=priority,
|
||||
extra_data=extra_data or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
# Send notification through appropriate channels
|
||||
NotificationService._send_notification(notification)
|
||||
|
||||
return notification
|
||||
|
||||
@staticmethod
|
||||
def create_submission_approved_notification(
|
||||
user: User,
|
||||
submission_object: Any,
|
||||
submission_type: str,
|
||||
additional_message: str = "",
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission approval.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The approved submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
additional_message: Additional message from moderator
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} has been approved!"
|
||||
message = f"Great news! Your {submission_type} submission has been approved and is now live on ThrillWiki."
|
||||
|
||||
if additional_message:
|
||||
message += f"\n\nModerator note: {additional_message}"
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"moderator_message": additional_message,
|
||||
"approved_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_APPROVED,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.NORMAL,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_submission_rejected_notification(
|
||||
user: User,
|
||||
submission_object: Any,
|
||||
submission_type: str,
|
||||
rejection_reason: str,
|
||||
additional_message: str = "",
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission rejection.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The rejected submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
rejection_reason: Reason for rejection
|
||||
additional_message: Additional message from moderator
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} needs attention"
|
||||
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
|
||||
message += f"\n\nReason: {rejection_reason}"
|
||||
|
||||
if additional_message:
|
||||
message += f"\n\nModerator note: {additional_message}"
|
||||
|
||||
message += "\n\nYou can edit and resubmit your content from your profile page."
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"rejection_reason": rejection_reason,
|
||||
"moderator_message": additional_message,
|
||||
"rejected_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_REJECTED,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.HIGH,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_submission_pending_notification(
|
||||
user: User, submission_object: Any, submission_type: str
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission pending review.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The pending submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} is under review"
|
||||
message = f"Thanks for your {submission_type} submission! It's now under review by our moderation team."
|
||||
message += "\n\nWe'll notify you once it's been reviewed. This usually takes 1-2 business days."
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"submitted_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_PENDING,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.LOW,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send notification through appropriate channels based on user preferences.
|
||||
|
||||
Args:
|
||||
notification: The notification to send
|
||||
"""
|
||||
user = notification.user
|
||||
|
||||
# Get user's notification preferences
|
||||
try:
|
||||
preferences = user.notification_preference
|
||||
except NotificationPreference.DoesNotExist:
|
||||
# Create default preferences if they don't exist
|
||||
preferences = NotificationPreference.objects.create(user=user)
|
||||
|
||||
# Send email notification if enabled
|
||||
if preferences.should_send_notification(
|
||||
notification.notification_type, "email"
|
||||
):
|
||||
NotificationService._send_email_notification(notification)
|
||||
|
||||
# Send push notification if enabled
|
||||
if preferences.should_send_notification(notification.notification_type, "push"):
|
||||
NotificationService._send_push_notification(notification)
|
||||
|
||||
# In-app notifications are always created (the notification object itself)
|
||||
# The frontend will check preferences when displaying them
|
||||
|
||||
@staticmethod
|
||||
def _send_email_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send email notification to user.
|
||||
|
||||
Args:
|
||||
notification: The notification to send via email
|
||||
"""
|
||||
try:
|
||||
user = notification.user
|
||||
|
||||
# Prepare email context
|
||||
context = {
|
||||
"user": user,
|
||||
"notification": notification,
|
||||
"site_name": "ThrillWiki",
|
||||
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
|
||||
}
|
||||
|
||||
# Render email templates
|
||||
subject = f"ThrillWiki: {notification.title}"
|
||||
html_message = render_to_string("emails/notification.html", context)
|
||||
plain_message = render_to_string("emails/notification.txt", context)
|
||||
|
||||
# Send email
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
html_message=html_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
# Mark as sent
|
||||
notification.email_sent = True
|
||||
notification.email_sent_at = timezone.now()
|
||||
notification.save(update_fields=["email_sent", "email_sent_at"])
|
||||
|
||||
logger.info(
|
||||
f"Email notification sent to {user.email} for notification {notification.id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send email notification {notification.id}: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_push_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send push notification to user.
|
||||
|
||||
Args:
|
||||
notification: The notification to send via push
|
||||
"""
|
||||
try:
|
||||
# TODO: Implement push notification service (Firebase, etc.)
|
||||
# For now, just mark as sent
|
||||
notification.push_sent = True
|
||||
notification.push_sent_at = timezone.now()
|
||||
notification.save(update_fields=["push_sent", "push_sent_at"])
|
||||
|
||||
logger.info(f"Push notification sent for notification {notification.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send push notification {notification.id}: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_notifications(
|
||||
user: User,
|
||||
unread_only: bool = False,
|
||||
notification_types: Optional[List[str]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[UserNotification]:
|
||||
"""
|
||||
Get notifications for a user.
|
||||
|
||||
Args:
|
||||
user: User to get notifications for
|
||||
unread_only: Only return unread notifications
|
||||
notification_types: Filter by notification types
|
||||
limit: Limit number of results
|
||||
|
||||
Returns:
|
||||
List[UserNotification]: List of notifications
|
||||
"""
|
||||
queryset = UserNotification.objects.filter(user=user)
|
||||
|
||||
if unread_only:
|
||||
queryset = queryset.filter(is_read=False)
|
||||
|
||||
if notification_types:
|
||||
queryset = queryset.filter(notification_type__in=notification_types)
|
||||
|
||||
# Exclude expired notifications
|
||||
queryset = queryset.filter(
|
||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
|
||||
)
|
||||
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def mark_notifications_read(
|
||||
user: User, notification_ids: Optional[List[int]] = None
|
||||
) -> int:
|
||||
"""
|
||||
Mark notifications as read for a user.
|
||||
|
||||
Args:
|
||||
user: User whose notifications to mark as read
|
||||
notification_ids: Specific notification IDs to mark as read (if None, marks all)
|
||||
|
||||
Returns:
|
||||
int: Number of notifications marked as read
|
||||
"""
|
||||
queryset = UserNotification.objects.filter(user=user, is_read=False)
|
||||
|
||||
if notification_ids:
|
||||
queryset = queryset.filter(id__in=notification_ids)
|
||||
|
||||
return queryset.update(is_read=True, read_at=timezone.now())
|
||||
|
||||
@staticmethod
|
||||
def cleanup_old_notifications(days: int = 90) -> int:
|
||||
"""
|
||||
Clean up old read notifications.
|
||||
|
||||
Args:
|
||||
days: Number of days to keep read notifications
|
||||
|
||||
Returns:
|
||||
int: Number of notifications deleted
|
||||
"""
|
||||
cutoff_date = timezone.now() - timezone.timedelta(days=days)
|
||||
|
||||
old_notifications = UserNotification.objects.filter(
|
||||
is_read=True, read_at__lt=cutoff_date
|
||||
)
|
||||
|
||||
count = old_notifications.count()
|
||||
old_notifications.delete()
|
||||
|
||||
logger.info(f"Cleaned up {count} old notifications")
|
||||
return count
|
||||
155
backend/apps/accounts/tests/test_user_deletion.py
Normal file
155
backend/apps/accounts/tests/test_user_deletion.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Tests for user deletion while preserving submissions.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django.db import transaction
|
||||
from apps.accounts.services import UserDeletionService
|
||||
from apps.accounts.models import User, UserProfile
|
||||
|
||||
|
||||
class UserDeletionServiceTest(TestCase):
|
||||
"""Test cases for UserDeletionService."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
# Create test users
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass123"
|
||||
)
|
||||
|
||||
self.admin_user = User.objects.create_user(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="adminpass123",
|
||||
is_superuser=True,
|
||||
)
|
||||
|
||||
# Create user profiles
|
||||
UserProfile.objects.create(
|
||||
user=self.user, display_name="Test User", bio="Test bio"
|
||||
)
|
||||
|
||||
UserProfile.objects.create(
|
||||
user=self.admin_user, display_name="Admin User", bio="Admin bio"
|
||||
)
|
||||
|
||||
def test_get_or_create_deleted_user(self):
|
||||
"""Test that deleted user placeholder is created correctly."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
self.assertEqual(deleted_user.username, "deleted_user")
|
||||
self.assertEqual(deleted_user.email, "deleted@thrillwiki.com")
|
||||
self.assertFalse(deleted_user.is_active)
|
||||
self.assertTrue(deleted_user.is_banned)
|
||||
self.assertEqual(deleted_user.role, User.Roles.USER)
|
||||
|
||||
# Check profile was created
|
||||
self.assertTrue(hasattr(deleted_user, "profile"))
|
||||
self.assertEqual(deleted_user.profile.display_name, "Deleted User")
|
||||
|
||||
def test_get_or_create_deleted_user_idempotent(self):
|
||||
"""Test that calling get_or_create_deleted_user multiple times returns same user."""
|
||||
deleted_user1 = UserDeletionService.get_or_create_deleted_user()
|
||||
deleted_user2 = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
self.assertEqual(deleted_user1.id, deleted_user2.id)
|
||||
self.assertEqual(User.objects.filter(username="deleted_user").count(), 1)
|
||||
|
||||
def test_can_delete_user_normal_user(self):
|
||||
"""Test that normal users can be deleted."""
|
||||
can_delete, reason = UserDeletionService.can_delete_user(self.user)
|
||||
|
||||
self.assertTrue(can_delete)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_can_delete_user_superuser(self):
|
||||
"""Test that superusers cannot be deleted."""
|
||||
can_delete, reason = UserDeletionService.can_delete_user(self.admin_user)
|
||||
|
||||
self.assertFalse(can_delete)
|
||||
self.assertEqual(reason, "Cannot delete superuser accounts")
|
||||
|
||||
def test_can_delete_user_deleted_user_placeholder(self):
|
||||
"""Test that deleted user placeholder cannot be deleted."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
can_delete, reason = UserDeletionService.can_delete_user(deleted_user)
|
||||
|
||||
self.assertFalse(can_delete)
|
||||
self.assertEqual(reason, "Cannot delete the system deleted user placeholder")
|
||||
|
||||
def test_delete_user_preserve_submissions_no_submissions(self):
|
||||
"""Test deleting user with no submissions."""
|
||||
user_id = self.user.user_id
|
||||
username = self.user.username
|
||||
|
||||
result = UserDeletionService.delete_user_preserve_submissions(self.user)
|
||||
|
||||
# Check user was deleted
|
||||
self.assertFalse(User.objects.filter(user_id=user_id).exists())
|
||||
|
||||
# Check result structure
|
||||
self.assertIn("deleted_user", result)
|
||||
self.assertIn("preserved_submissions", result)
|
||||
self.assertIn("transferred_to", result)
|
||||
|
||||
self.assertEqual(result["deleted_user"]["username"], username)
|
||||
self.assertEqual(result["deleted_user"]["user_id"], user_id)
|
||||
|
||||
# All submission counts should be 0
|
||||
for count in result["preserved_submissions"].values():
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_delete_user_cannot_delete_deleted_user_placeholder(self):
|
||||
"""Test that attempting to delete the deleted user placeholder raises error."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
UserDeletionService.delete_user_preserve_submissions(deleted_user)
|
||||
|
||||
self.assertIn(
|
||||
"Cannot delete the system deleted user placeholder", str(context.exception)
|
||||
)
|
||||
|
||||
def test_delete_user_with_submissions_transfers_correctly(self):
|
||||
"""Test that user submissions are transferred to deleted user placeholder."""
|
||||
# This test would require creating park/ride data which is complex
|
||||
# For now, we'll test the basic functionality
|
||||
|
||||
# Create deleted user first to ensure it exists
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
# Delete the test user
|
||||
result = UserDeletionService.delete_user_preserve_submissions(self.user)
|
||||
|
||||
# Verify the deleted user placeholder still exists
|
||||
self.assertTrue(User.objects.filter(username="deleted_user").exists())
|
||||
|
||||
# Verify result structure
|
||||
self.assertIn("deleted_user", result)
|
||||
self.assertIn("preserved_submissions", result)
|
||||
self.assertIn("transferred_to", result)
|
||||
|
||||
self.assertEqual(result["transferred_to"]["username"], "deleted_user")
|
||||
|
||||
def test_delete_user_atomic_transaction(self):
|
||||
"""Test that user deletion is atomic."""
|
||||
# This test ensures that if something goes wrong during deletion,
|
||||
# the transaction is rolled back
|
||||
|
||||
original_user_count = User.objects.count()
|
||||
|
||||
# Mock a failure during the deletion process
|
||||
with self.assertRaises(Exception):
|
||||
with transaction.atomic():
|
||||
# Start the deletion process
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
# Simulate an error
|
||||
raise Exception("Simulated error during deletion")
|
||||
|
||||
# Verify user count hasn't changed
|
||||
self.assertEqual(User.objects.count(), original_user_count)
|
||||
|
||||
# Verify our test user still exists
|
||||
self.assertTrue(User.objects.filter(user_id=self.user.user_id).exists())
|
||||
@@ -1,18 +1,108 @@
|
||||
"""
|
||||
Accounts API URL Configuration
|
||||
URL configuration for user account management API endpoints.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# Create router and register ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r"profiles", views.UserProfileViewSet, basename="user-profile")
|
||||
router.register(r"toplists", views.TopListViewSet, basename="top-list")
|
||||
router.register(r"toplist-items", views.TopListItemViewSet, basename="top-list-item")
|
||||
|
||||
urlpatterns = [
|
||||
# Include router URLs for ViewSets
|
||||
path("", include(router.urls)),
|
||||
# Admin endpoints for user management
|
||||
path(
|
||||
"users/<str:user_id>/delete/",
|
||||
views.delete_user_preserve_submissions,
|
||||
name="delete_user_preserve_submissions",
|
||||
),
|
||||
path(
|
||||
"users/<str:user_id>/deletion-check/",
|
||||
views.check_user_deletion_eligibility,
|
||||
name="check_user_deletion_eligibility",
|
||||
),
|
||||
# Self-service account deletion endpoints
|
||||
path(
|
||||
"delete-account/request/",
|
||||
views.request_account_deletion,
|
||||
name="request_account_deletion",
|
||||
),
|
||||
path(
|
||||
"delete-account/verify/",
|
||||
views.verify_account_deletion,
|
||||
name="verify_account_deletion",
|
||||
),
|
||||
path(
|
||||
"delete-account/cancel/",
|
||||
views.cancel_account_deletion,
|
||||
name="cancel_account_deletion",
|
||||
),
|
||||
# User profile endpoints
|
||||
path("profile/", views.get_user_profile, name="get_user_profile"),
|
||||
path("profile/account/", views.update_user_account, name="update_user_account"),
|
||||
path("profile/update/", views.update_user_profile, name="update_user_profile"),
|
||||
# User preferences endpoints
|
||||
path("preferences/", views.get_user_preferences, name="get_user_preferences"),
|
||||
path(
|
||||
"preferences/update/",
|
||||
views.update_user_preferences,
|
||||
name="update_user_preferences",
|
||||
),
|
||||
path(
|
||||
"preferences/theme/",
|
||||
views.update_theme_preference,
|
||||
name="update_theme_preference",
|
||||
),
|
||||
# Notification settings endpoints
|
||||
path(
|
||||
"settings/notifications/",
|
||||
views.get_notification_settings,
|
||||
name="get_notification_settings",
|
||||
),
|
||||
path(
|
||||
"settings/notifications/update/",
|
||||
views.update_notification_settings,
|
||||
name="update_notification_settings",
|
||||
),
|
||||
# Privacy settings endpoints
|
||||
path("settings/privacy/", views.get_privacy_settings, name="get_privacy_settings"),
|
||||
path(
|
||||
"settings/privacy/update/",
|
||||
views.update_privacy_settings,
|
||||
name="update_privacy_settings",
|
||||
),
|
||||
# Security settings endpoints
|
||||
path(
|
||||
"settings/security/", views.get_security_settings, name="get_security_settings"
|
||||
),
|
||||
path(
|
||||
"settings/security/update/",
|
||||
views.update_security_settings,
|
||||
name="update_security_settings",
|
||||
),
|
||||
# User statistics endpoints
|
||||
path("statistics/", views.get_user_statistics, name="get_user_statistics"),
|
||||
# Top lists endpoints
|
||||
path("top-lists/", views.get_user_top_lists, name="get_user_top_lists"),
|
||||
path("top-lists/create/", views.create_top_list, name="create_top_list"),
|
||||
path("top-lists/<int:list_id>/", views.update_top_list, name="update_top_list"),
|
||||
path(
|
||||
"top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"
|
||||
),
|
||||
# Notification endpoints
|
||||
path("notifications/", views.get_user_notifications, name="get_user_notifications"),
|
||||
path(
|
||||
"notifications/mark-read/",
|
||||
views.mark_notifications_read,
|
||||
name="mark_notifications_read",
|
||||
),
|
||||
path(
|
||||
"notification-preferences/",
|
||||
views.get_notification_preferences,
|
||||
name="get_notification_preferences",
|
||||
),
|
||||
path(
|
||||
"notification-preferences/update/",
|
||||
views.update_notification_preferences,
|
||||
name="update_notification_preferences",
|
||||
),
|
||||
# Avatar endpoints
|
||||
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
||||
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,7 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"""User serializer for API responses."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
@@ -87,9 +88,14 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined", "is_active"]
|
||||
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
"""Get user avatar URL."""
|
||||
|
||||
@@ -4,27 +4,27 @@ Migrated from apps.core.views.map_views
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.db.models import Q
|
||||
from django.core.cache import cache
|
||||
from django.contrib.gis.geos import Polygon
|
||||
from django.contrib.gis.db.models.functions import Distance
|
||||
from django.contrib.gis.geos import Point
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema,
|
||||
extend_schema_view,
|
||||
OpenApiParameter,
|
||||
OpenApiExample,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
from apps.parks.models import Park, ParkLocation
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from ..serializers.maps import (
|
||||
MapLocationSerializer,
|
||||
MapLocationsResponseSerializer,
|
||||
MapSearchResultSerializer,
|
||||
MapSearchResponseSerializer,
|
||||
MapLocationDetailSerializer,
|
||||
)
|
||||
@@ -86,7 +86,7 @@ logger = logging.getLogger(__name__)
|
||||
examples=[
|
||||
OpenApiExample("All types", value="park,ride"),
|
||||
OpenApiExample("Parks only", value="park"),
|
||||
OpenApiExample("Rides only", value="ride")
|
||||
OpenApiExample("Rides only", value="ride"),
|
||||
],
|
||||
),
|
||||
OpenApiParameter(
|
||||
@@ -97,7 +97,7 @@ logger = logging.getLogger(__name__)
|
||||
description="Enable location clustering for high-density areas. Default: false",
|
||||
examples=[
|
||||
OpenApiExample("Enable clustering", value=True),
|
||||
OpenApiExample("Disable clustering", value=False)
|
||||
OpenApiExample("Disable clustering", value=False),
|
||||
],
|
||||
),
|
||||
OpenApiParameter(
|
||||
@@ -109,7 +109,7 @@ logger = logging.getLogger(__name__)
|
||||
examples=[
|
||||
OpenApiExample("Park name", value="Cedar Point"),
|
||||
OpenApiExample("Ride type", value="roller coaster"),
|
||||
OpenApiExample("Location", value="Ohio")
|
||||
OpenApiExample("Location", value="Ohio"),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -150,27 +150,28 @@ class MapLocationsAPIView(APIView):
|
||||
|
||||
# Get parks if requested
|
||||
if "park" in types:
|
||||
parks_query = Park.objects.select_related("location", "operator").filter(
|
||||
location__point__isnull=False
|
||||
)
|
||||
parks_query = Park.objects.select_related(
|
||||
"location", "operator"
|
||||
).filter(location__point__isnull=False)
|
||||
|
||||
# Apply bounds filtering
|
||||
if all([north, south, east, west]):
|
||||
try:
|
||||
bounds_polygon = Polygon.from_bbox((
|
||||
float(west), float(south), float(east), float(north)
|
||||
))
|
||||
bounds_polygon = Polygon.from_bbox(
|
||||
(float(west), float(south), float(east), float(north))
|
||||
)
|
||||
parks_query = parks_query.filter(
|
||||
location__point__within=bounds_polygon)
|
||||
location__point__within=bounds_polygon
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply text search
|
||||
if query:
|
||||
parks_query = parks_query.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(location__city__icontains=query) |
|
||||
Q(location__state__icontains=query)
|
||||
Q(name__icontains=query)
|
||||
| Q(location__city__icontains=query)
|
||||
| Q(location__state__icontains=query)
|
||||
)
|
||||
|
||||
# Serialize parks
|
||||
@@ -180,46 +181,75 @@ class MapLocationsAPIView(APIView):
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"latitude": (
|
||||
park.location.latitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
park.location.longitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"status": park.status,
|
||||
"location": {
|
||||
"city": park.location.city if hasattr(park, 'location') and park.location else "",
|
||||
"state": park.location.state if hasattr(park, 'location') and park.location else "",
|
||||
"country": park.location.country if hasattr(park, 'location') and park.location else "",
|
||||
"formatted_address": park.location.formatted_address if hasattr(park, 'location') and park.location else "",
|
||||
"city": (
|
||||
park.location.city
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
park.location.state
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
park.location.country
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"formatted_address": (
|
||||
park.location.formatted_address
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": park.coaster_count or 0,
|
||||
"ride_count": park.ride_count or 0,
|
||||
"average_rating": float(park.average_rating) if park.average_rating else None,
|
||||
"average_rating": (
|
||||
float(park.average_rating)
|
||||
if park.average_rating
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
locations.append(park_data)
|
||||
|
||||
# Get rides if requested
|
||||
if "ride" in types:
|
||||
rides_query = Ride.objects.select_related("park__location", "manufacturer").filter(
|
||||
park__location__point__isnull=False
|
||||
)
|
||||
rides_query = Ride.objects.select_related(
|
||||
"park__location", "manufacturer"
|
||||
).filter(park__location__point__isnull=False)
|
||||
|
||||
# Apply bounds filtering
|
||||
if all([north, south, east, west]):
|
||||
try:
|
||||
bounds_polygon = Polygon.from_bbox((
|
||||
float(west), float(south), float(east), float(north)
|
||||
))
|
||||
bounds_polygon = Polygon.from_bbox(
|
||||
(float(west), float(south), float(east), float(north))
|
||||
)
|
||||
rides_query = rides_query.filter(
|
||||
park__location__point__within=bounds_polygon)
|
||||
park__location__point__within=bounds_polygon
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply text search
|
||||
if query:
|
||||
rides_query = rides_query.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(park__name__icontains=query) |
|
||||
Q(park__location__city__icontains=query)
|
||||
Q(name__icontains=query)
|
||||
| Q(park__name__icontains=query)
|
||||
| Q(park__location__city__icontains=query)
|
||||
)
|
||||
|
||||
# Serialize rides
|
||||
@@ -229,18 +259,48 @@ class MapLocationsAPIView(APIView):
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"latitude": (
|
||||
ride.park.location.latitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
ride.park.location.longitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"status": ride.status,
|
||||
"location": {
|
||||
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"formatted_address": ride.park.location.formatted_address if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"city": (
|
||||
ride.park.location.city
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
ride.park.location.state
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
ride.park.location.country
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"formatted_address": (
|
||||
ride.park.location.formatted_address
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"category": ride.get_category_display() if ride.category else None,
|
||||
"average_rating": float(ride.average_rating) if ride.average_rating else None,
|
||||
"category": (
|
||||
ride.get_category_display() if ride.category else None
|
||||
),
|
||||
"average_rating": (
|
||||
float(ride.average_rating)
|
||||
if ride.average_rating
|
||||
else None
|
||||
),
|
||||
"park_name": ride.park.name,
|
||||
},
|
||||
}
|
||||
@@ -324,8 +384,9 @@ class MapLocationDetailAPIView(APIView):
|
||||
try:
|
||||
if location_type == "park":
|
||||
try:
|
||||
obj = Park.objects.select_related(
|
||||
"location", "operator").get(id=location_id)
|
||||
obj = Park.objects.select_related("location", "operator").get(
|
||||
id=location_id
|
||||
)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"status": "error", "message": "Park not found"},
|
||||
@@ -334,7 +395,8 @@ class MapLocationDetailAPIView(APIView):
|
||||
elif location_type == "ride":
|
||||
try:
|
||||
obj = Ride.objects.select_related(
|
||||
"park__location", "manufacturer").get(id=location_id)
|
||||
"park__location", "manufacturer"
|
||||
).get(id=location_id)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"status": "error", "message": "Ride not found"},
|
||||
@@ -354,23 +416,59 @@ class MapLocationDetailAPIView(APIView):
|
||||
"name": obj.name,
|
||||
"slug": obj.slug,
|
||||
"description": obj.description,
|
||||
"latitude": obj.location.latitude if hasattr(obj, 'location') and obj.location else None,
|
||||
"longitude": obj.location.longitude if hasattr(obj, 'location') and obj.location else None,
|
||||
"latitude": (
|
||||
obj.location.latitude
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
obj.location.longitude
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else None
|
||||
),
|
||||
"status": obj.status,
|
||||
"location": {
|
||||
"street_address": obj.location.street_address if hasattr(obj, 'location') and obj.location else "",
|
||||
"city": obj.location.city if hasattr(obj, 'location') and obj.location else "",
|
||||
"state": obj.location.state if hasattr(obj, 'location') and obj.location else "",
|
||||
"country": obj.location.country if hasattr(obj, 'location') and obj.location else "",
|
||||
"postal_code": obj.location.postal_code if hasattr(obj, 'location') and obj.location else "",
|
||||
"formatted_address": obj.location.formatted_address if hasattr(obj, 'location') and obj.location else "",
|
||||
"street_address": (
|
||||
obj.location.street_address
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"city": (
|
||||
obj.location.city
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
obj.location.state
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
obj.location.country
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"postal_code": (
|
||||
obj.location.postal_code
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"formatted_address": (
|
||||
obj.location.formatted_address
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"size_acres": float(obj.size_acres) if obj.size_acres else None,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
},
|
||||
"nearby_locations": [], # TODO: Implement nearby locations
|
||||
}
|
||||
@@ -381,31 +479,73 @@ class MapLocationDetailAPIView(APIView):
|
||||
"name": obj.name,
|
||||
"slug": obj.slug,
|
||||
"description": obj.description,
|
||||
"latitude": obj.park.location.latitude if hasattr(obj.park, 'location') and obj.park.location else None,
|
||||
"longitude": obj.park.location.longitude if hasattr(obj.park, 'location') and obj.park.location else None,
|
||||
"latitude": (
|
||||
obj.park.location.latitude
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
obj.park.location.longitude
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else None
|
||||
),
|
||||
"status": obj.status,
|
||||
"location": {
|
||||
"street_address": obj.park.location.street_address if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"city": obj.park.location.city if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"state": obj.park.location.state if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"country": obj.park.location.country if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"postal_code": obj.park.location.postal_code if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"formatted_address": obj.park.location.formatted_address if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"street_address": (
|
||||
obj.park.location.street_address
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"city": (
|
||||
obj.park.location.city
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
obj.park.location.state
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
obj.park.location.country
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"postal_code": (
|
||||
obj.park.location.postal_code
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"formatted_address": (
|
||||
obj.park.location.formatted_address
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"category": (
|
||||
obj.get_category_display() if obj.category else None
|
||||
),
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
"manufacturer": (
|
||||
obj.manufacturer.name if obj.manufacturer else None
|
||||
),
|
||||
},
|
||||
"nearby_locations": [], # TODO: Implement nearby locations
|
||||
}
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"data": data,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
|
||||
@@ -484,51 +624,106 @@ class MapSearchAPIView(APIView):
|
||||
|
||||
# Search parks
|
||||
if "park" in types:
|
||||
parks_query = Park.objects.select_related("location").filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(location__city__icontains=query) |
|
||||
Q(location__state__icontains=query)
|
||||
).filter(location__point__isnull=False)
|
||||
parks_query = (
|
||||
Park.objects.select_related("location")
|
||||
.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(location__city__icontains=query)
|
||||
| Q(location__state__icontains=query)
|
||||
)
|
||||
.filter(location__point__isnull=False)
|
||||
)
|
||||
|
||||
for park in parks_query[:50]: # Limit results
|
||||
results.append({
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"location": {
|
||||
"city": park.location.city if hasattr(park, 'location') and park.location else "",
|
||||
"state": park.location.state if hasattr(park, 'location') and park.location else "",
|
||||
"country": park.location.country if hasattr(park, 'location') and park.location else "",
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": (
|
||||
park.location.latitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
park.location.longitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"location": {
|
||||
"city": (
|
||||
park.location.city
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
park.location.state
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
park.location.country
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
}
|
||||
)
|
||||
|
||||
# Search rides
|
||||
if "ride" in types:
|
||||
rides_query = Ride.objects.select_related("park__location").filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(park__name__icontains=query) |
|
||||
Q(park__location__city__icontains=query)
|
||||
).filter(park__location__point__isnull=False)
|
||||
rides_query = (
|
||||
Ride.objects.select_related("park__location")
|
||||
.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(park__name__icontains=query)
|
||||
| Q(park__location__city__icontains=query)
|
||||
)
|
||||
.filter(park__location__point__isnull=False)
|
||||
)
|
||||
|
||||
for ride in rides_query[:50]: # Limit results
|
||||
results.append({
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"location": {
|
||||
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": (
|
||||
ride.park.location.latitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
ride.park.location.longitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"location": {
|
||||
"city": (
|
||||
ride.park.location.city
|
||||
if hasattr(ride.park, "location")
|
||||
and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
ride.park.location.state
|
||||
if hasattr(ride.park, "location")
|
||||
and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
ride.park.location.country
|
||||
if hasattr(ride.park, "location")
|
||||
and ride.park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
}
|
||||
)
|
||||
|
||||
total_count = len(results)
|
||||
|
||||
@@ -537,14 +732,16 @@ class MapSearchAPIView(APIView):
|
||||
end_idx = start_idx + page_size
|
||||
paginated_results = results[start_idx:end_idx]
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"results": paginated_results,
|
||||
"query": query,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"results": paginated_results,
|
||||
"query": query,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
|
||||
@@ -622,13 +819,19 @@ class MapBoundsAPIView(APIView):
|
||||
# Validate bounds
|
||||
if north <= south:
|
||||
return Response(
|
||||
{"status": "error", "message": "North bound must be greater than south bound"},
|
||||
{
|
||||
"status": "error",
|
||||
"message": "North bound must be greater than south bound",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if west >= east:
|
||||
return Response(
|
||||
{"status": "error", "message": "West bound must be less than east bound"},
|
||||
{
|
||||
"status": "error",
|
||||
"message": "West bound must be less than east bound",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -645,15 +848,25 @@ class MapBoundsAPIView(APIView):
|
||||
)
|
||||
|
||||
for park in parks_query[:100]: # Limit results
|
||||
locations.append({
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"status": park.status,
|
||||
})
|
||||
locations.append(
|
||||
{
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": (
|
||||
park.location.latitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
park.location.longitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"status": park.status,
|
||||
}
|
||||
)
|
||||
|
||||
# Get rides within bounds
|
||||
if "ride" in types:
|
||||
@@ -662,32 +875,47 @@ class MapBoundsAPIView(APIView):
|
||||
)
|
||||
|
||||
for ride in rides_query[:100]: # Limit results
|
||||
locations.append({
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"status": ride.status,
|
||||
})
|
||||
locations.append(
|
||||
{
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": (
|
||||
ride.park.location.latitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
ride.park.location.longitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"status": ride.status,
|
||||
}
|
||||
)
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"locations": locations,
|
||||
"bounds": {
|
||||
"north": north,
|
||||
"south": south,
|
||||
"east": east,
|
||||
"west": west,
|
||||
},
|
||||
"total_count": len(locations),
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"locations": locations,
|
||||
"bounds": {
|
||||
"north": north,
|
||||
"south": south,
|
||||
"east": east,
|
||||
"west": west,
|
||||
},
|
||||
"total_count": len(locations),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"status": "error", "message": "Failed to retrieve locations within bounds"},
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Failed to retrieve locations within bounds",
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@@ -710,21 +938,25 @@ class MapStatsAPIView(APIView):
|
||||
try:
|
||||
# Count locations with coordinates
|
||||
parks_with_location = Park.objects.filter(
|
||||
location__point__isnull=False).count()
|
||||
location__point__isnull=False
|
||||
).count()
|
||||
rides_with_location = Ride.objects.filter(
|
||||
park__location__point__isnull=False).count()
|
||||
park__location__point__isnull=False
|
||||
).count()
|
||||
total_locations = parks_with_location + rides_with_location
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"data": {
|
||||
"total_locations": total_locations,
|
||||
"parks_with_location": parks_with_location,
|
||||
"rides_with_location": rides_with_location,
|
||||
"cache_hits": 0, # TODO: Implement cache statistics
|
||||
"cache_misses": 0, # TODO: Implement cache statistics
|
||||
},
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"total_locations": total_locations,
|
||||
"parks_with_location": parks_with_location,
|
||||
"rides_with_location": rides_with_location,
|
||||
"cache_hits": 0, # TODO: Implement cache statistics
|
||||
"cache_misses": 0, # TODO: Implement cache statistics
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
|
||||
@@ -764,10 +996,12 @@ class MapCacheAPIView(APIView):
|
||||
else:
|
||||
cleared_count = 0
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
|
||||
@@ -787,10 +1021,12 @@ class MapCacheAPIView(APIView):
|
||||
else:
|
||||
invalidated_count = 0
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
|
||||
|
||||
@@ -16,8 +16,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import NotFound
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
@@ -422,9 +421,17 @@ class ParkSearchSuggestionsAPIView(APIView):
|
||||
@extend_schema(
|
||||
summary="Set park banner and card images",
|
||||
description="Set banner_image and card_image for a park from existing park photos",
|
||||
request=("ParkImageSettingsInputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
|
||||
request=(
|
||||
"ParkImageSettingsInputSerializer"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
),
|
||||
responses={
|
||||
200: ("ParkDetailOutputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
|
||||
200: (
|
||||
"ParkDetailOutputSerializer"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
),
|
||||
400: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
@@ -462,5 +469,6 @@ class ParkImageSettingsAPIView(APIView):
|
||||
|
||||
# Return updated park data
|
||||
output_serializer = ParkDetailOutputSerializer(
|
||||
park, context={"request": request})
|
||||
park, context={"request": request}
|
||||
)
|
||||
return Response(output_serializer.data)
|
||||
|
||||
@@ -6,39 +6,43 @@ Enhanced from rogue implementation to maintain full feature parity.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.parks.models import Park, ParkPhoto
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name='Park Photo with Cloudflare Images',
|
||||
summary='Complete park photo response',
|
||||
description='Example response showing all fields including Cloudflare Images URLs and variants',
|
||||
name="Park Photo with Cloudflare Images",
|
||||
summary="Complete park photo response",
|
||||
description="Example response showing all fields including Cloudflare Images URLs and variants",
|
||||
value={
|
||||
'id': 456,
|
||||
'image': 'https://imagedelivery.net/account-hash/def456ghi789/public',
|
||||
'image_url': 'https://imagedelivery.net/account-hash/def456ghi789/public',
|
||||
'image_variants': {
|
||||
'thumbnail': 'https://imagedelivery.net/account-hash/def456ghi789/thumbnail',
|
||||
'medium': 'https://imagedelivery.net/account-hash/def456ghi789/medium',
|
||||
'large': 'https://imagedelivery.net/account-hash/def456ghi789/large',
|
||||
'public': 'https://imagedelivery.net/account-hash/def456ghi789/public'
|
||||
"id": 456,
|
||||
"image": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
"image_url": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def456ghi789/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def456ghi789/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def456ghi789/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
},
|
||||
'caption': 'Beautiful park entrance',
|
||||
'alt_text': 'Main entrance gate with decorative archway',
|
||||
'is_primary': True,
|
||||
'is_approved': True,
|
||||
'created_at': '2023-01-01T12:00:00Z',
|
||||
'updated_at': '2023-01-01T12:00:00Z',
|
||||
'date_taken': '2023-01-01T11:00:00Z',
|
||||
'uploaded_by_username': 'parkfan456',
|
||||
'file_size': 1536000,
|
||||
'dimensions': [1600, 900],
|
||||
'park_slug': 'cedar-point',
|
||||
'park_name': 'Cedar Point'
|
||||
}
|
||||
"caption": "Beautiful park entrance",
|
||||
"alt_text": "Main entrance gate with decorative archway",
|
||||
"is_primary": True,
|
||||
"is_approved": True,
|
||||
"created_at": "2023-01-01T12:00:00Z",
|
||||
"updated_at": "2023-01-01T12:00:00Z",
|
||||
"date_taken": "2023-01-01T11:00:00Z",
|
||||
"uploaded_by_username": "parkfan456",
|
||||
"file_size": 1536000,
|
||||
"dimensions": [1600, 900],
|
||||
"park_slug": "cedar-point",
|
||||
"park_name": "Cedar Point",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -76,8 +80,7 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.URLField(
|
||||
help_text="Full URL to the Cloudflare Images asset",
|
||||
allow_null=True
|
||||
help_text="Full URL to the Cloudflare Images asset", allow_null=True
|
||||
)
|
||||
)
|
||||
def get_image_url(self, obj):
|
||||
@@ -89,7 +92,7 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
@extend_schema_field(
|
||||
serializers.DictField(
|
||||
child=serializers.URLField(),
|
||||
help_text="Available Cloudflare Images variants with their URLs"
|
||||
help_text="Available Cloudflare Images variants with their URLs",
|
||||
)
|
||||
)
|
||||
def get_image_variants(self, obj):
|
||||
@@ -99,10 +102,10 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Common variants for park photos
|
||||
variants = {
|
||||
'thumbnail': f"{obj.image.url}/thumbnail",
|
||||
'medium': f"{obj.image.url}/medium",
|
||||
'large': f"{obj.image.url}/large",
|
||||
'public': f"{obj.image.url}/public"
|
||||
"thumbnail": f"{obj.image.url}/thumbnail",
|
||||
"medium": f"{obj.image.url}/medium",
|
||||
"large": f"{obj.image.url}/large",
|
||||
"public": f"{obj.image.url}/public",
|
||||
}
|
||||
return variants
|
||||
|
||||
|
||||
@@ -44,7 +44,11 @@ urlpatterns = [
|
||||
# Detail and action endpoints
|
||||
path("<int:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
# Park image settings endpoint
|
||||
path("<int:pk>/image-settings/", ParkImageSettingsAPIView.as_view(), name="park-image-settings"),
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
ParkImageSettingsAPIView.as_view(),
|
||||
name="park-image-settings",
|
||||
),
|
||||
# Park photo endpoints - domain-specific photo management
|
||||
path("<int:park_pk>/photos/", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -29,37 +29,51 @@ app_name = "api_v1_ride_models"
|
||||
urlpatterns = [
|
||||
# Core ride model endpoints - nested under manufacturer
|
||||
path("", RideModelListCreateAPIView.as_view(), name="ride-model-list-create"),
|
||||
path("<slug:ride_model_slug>/", RideModelDetailAPIView.as_view(), name="ride-model-detail"),
|
||||
|
||||
path(
|
||||
"<slug:ride_model_slug>/",
|
||||
RideModelDetailAPIView.as_view(),
|
||||
name="ride-model-detail",
|
||||
),
|
||||
# Search and filtering (global, not manufacturer-specific)
|
||||
path("search/", RideModelSearchAPIView.as_view(), name="ride-model-search"),
|
||||
path("filter-options/", RideModelFilterOptionsAPIView.as_view(),
|
||||
name="ride-model-filter-options"),
|
||||
|
||||
path(
|
||||
"filter-options/",
|
||||
RideModelFilterOptionsAPIView.as_view(),
|
||||
name="ride-model-filter-options",
|
||||
),
|
||||
# Statistics (global, not manufacturer-specific)
|
||||
path("stats/", RideModelStatsAPIView.as_view(), name="ride-model-stats"),
|
||||
|
||||
# Ride model variants - using slug-based lookup
|
||||
path("<slug:ride_model_slug>/variants/",
|
||||
RideModelVariantListCreateAPIView.as_view(),
|
||||
name="ride-model-variant-list-create"),
|
||||
path("<slug:ride_model_slug>/variants/<int:pk>/",
|
||||
RideModelVariantDetailAPIView.as_view(),
|
||||
name="ride-model-variant-detail"),
|
||||
|
||||
path(
|
||||
"<slug:ride_model_slug>/variants/",
|
||||
RideModelVariantListCreateAPIView.as_view(),
|
||||
name="ride-model-variant-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/variants/<int:pk>/",
|
||||
RideModelVariantDetailAPIView.as_view(),
|
||||
name="ride-model-variant-detail",
|
||||
),
|
||||
# Technical specifications - using slug-based lookup
|
||||
path("<slug:ride_model_slug>/technical-specs/",
|
||||
RideModelTechnicalSpecListCreateAPIView.as_view(),
|
||||
name="ride-model-technical-spec-list-create"),
|
||||
path("<slug:ride_model_slug>/technical-specs/<int:pk>/",
|
||||
RideModelTechnicalSpecDetailAPIView.as_view(),
|
||||
name="ride-model-technical-spec-detail"),
|
||||
|
||||
path(
|
||||
"<slug:ride_model_slug>/technical-specs/",
|
||||
RideModelTechnicalSpecListCreateAPIView.as_view(),
|
||||
name="ride-model-technical-spec-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/technical-specs/<int:pk>/",
|
||||
RideModelTechnicalSpecDetailAPIView.as_view(),
|
||||
name="ride-model-technical-spec-detail",
|
||||
),
|
||||
# Photos - using slug-based lookup
|
||||
path("<slug:ride_model_slug>/photos/",
|
||||
RideModelPhotoListCreateAPIView.as_view(),
|
||||
name="ride-model-photo-list-create"),
|
||||
path("<slug:ride_model_slug>/photos/<int:pk>/",
|
||||
RideModelPhotoDetailAPIView.as_view(),
|
||||
name="ride-model-photo-detail"),
|
||||
path(
|
||||
"<slug:ride_model_slug>/photos/",
|
||||
RideModelPhotoListCreateAPIView.as_view(),
|
||||
name="ride-model-photo-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/photos/<int:pk>/",
|
||||
RideModelPhotoDetailAPIView.as_view(),
|
||||
name="ride-model-photo-detail",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@ This module implements comprehensive endpoints for ride model management:
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
@@ -36,25 +36,31 @@ from apps.api.v1.serializers.ride_models import (
|
||||
RideModelVariantOutputSerializer,
|
||||
RideModelVariantCreateInputSerializer,
|
||||
RideModelVariantUpdateInputSerializer,
|
||||
RideModelTechnicalSpecOutputSerializer,
|
||||
RideModelTechnicalSpecCreateInputSerializer,
|
||||
RideModelTechnicalSpecUpdateInputSerializer,
|
||||
RideModelPhotoOutputSerializer,
|
||||
RideModelPhotoCreateInputSerializer,
|
||||
RideModelPhotoUpdateInputSerializer,
|
||||
RideModelStatsOutputSerializer,
|
||||
)
|
||||
|
||||
# Attempt to import models; fall back gracefully if not present
|
||||
try:
|
||||
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
from apps.rides.models import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
from apps.rides.models.company import Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except ImportError:
|
||||
try:
|
||||
# Try alternative import path
|
||||
from apps.rides.models.rides import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
from apps.rides.models.rides import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
from apps.rides.models.rides import Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except ImportError:
|
||||
RideModel = None
|
||||
@@ -82,7 +88,10 @@ class RideModelListCreateAPIView(APIView):
|
||||
description="List ride models with comprehensive filtering and pagination.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
|
||||
@@ -97,10 +106,14 @@ class RideModelListCreateAPIView(APIView):
|
||||
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="target_market", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
name="target_market",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="is_discontinued", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL
|
||||
name="is_discontinued",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.BOOL,
|
||||
),
|
||||
],
|
||||
responses={200: RideModelListOutputSerializer(many=True)},
|
||||
@@ -123,7 +136,11 @@ class RideModelListCreateAPIView(APIView):
|
||||
except Company.DoesNotExist:
|
||||
raise NotFound("Manufacturer not found")
|
||||
|
||||
qs = RideModel.objects.filter(manufacturer=manufacturer).select_related("manufacturer").prefetch_related("photos")
|
||||
qs = (
|
||||
RideModel.objects.filter(manufacturer=manufacturer)
|
||||
.select_related("manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
filter_serializer = RideModelFilterInputSerializer(data=request.query_params)
|
||||
@@ -134,9 +151,9 @@ class RideModelListCreateAPIView(APIView):
|
||||
if filters.get("search"):
|
||||
search_term = filters["search"]
|
||||
qs = qs.filter(
|
||||
Q(name__icontains=search_term) |
|
||||
Q(description__icontains=search_term) |
|
||||
Q(manufacturer__name__icontains=search_term)
|
||||
Q(name__icontains=search_term)
|
||||
| Q(description__icontains=search_term)
|
||||
| Q(manufacturer__name__icontains=search_term)
|
||||
)
|
||||
|
||||
# Category filter
|
||||
@@ -160,10 +177,12 @@ class RideModelListCreateAPIView(APIView):
|
||||
# Year filters
|
||||
if filters.get("first_installation_year_min"):
|
||||
qs = qs.filter(
|
||||
first_installation_year__gte=filters["first_installation_year_min"])
|
||||
first_installation_year__gte=filters["first_installation_year_min"]
|
||||
)
|
||||
if filters.get("first_installation_year_max"):
|
||||
qs = qs.filter(
|
||||
first_installation_year__lte=filters["first_installation_year_max"])
|
||||
first_installation_year__lte=filters["first_installation_year_max"]
|
||||
)
|
||||
|
||||
# Installation count filter
|
||||
if filters.get("min_installations"):
|
||||
@@ -172,18 +191,22 @@ class RideModelListCreateAPIView(APIView):
|
||||
# Height filters
|
||||
if filters.get("min_height_ft"):
|
||||
qs = qs.filter(
|
||||
typical_height_range_max_ft__gte=filters["min_height_ft"])
|
||||
typical_height_range_max_ft__gte=filters["min_height_ft"]
|
||||
)
|
||||
if filters.get("max_height_ft"):
|
||||
qs = qs.filter(
|
||||
typical_height_range_min_ft__lte=filters["max_height_ft"])
|
||||
typical_height_range_min_ft__lte=filters["max_height_ft"]
|
||||
)
|
||||
|
||||
# Speed filters
|
||||
if filters.get("min_speed_mph"):
|
||||
qs = qs.filter(
|
||||
typical_speed_range_max_mph__gte=filters["min_speed_mph"])
|
||||
typical_speed_range_max_mph__gte=filters["min_speed_mph"]
|
||||
)
|
||||
if filters.get("max_speed_mph"):
|
||||
qs = qs.filter(
|
||||
typical_speed_range_min_mph__lte=filters["max_speed_mph"])
|
||||
typical_speed_range_min_mph__lte=filters["max_speed_mph"]
|
||||
)
|
||||
|
||||
# Ordering
|
||||
ordering = filters.get("ordering", "manufacturer__name,name")
|
||||
@@ -203,7 +226,10 @@ class RideModelListCreateAPIView(APIView):
|
||||
description="Create a new ride model for a specific manufacturer.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
request=RideModelCreateInputSerializer,
|
||||
@@ -262,13 +288,17 @@ class RideModelListCreateAPIView(APIView):
|
||||
class RideModelDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def _get_ride_model_or_404(self, manufacturer_slug: str, ride_model_slug: str) -> Any:
|
||||
def _get_ride_model_or_404(
|
||||
self, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Any:
|
||||
if not MODELS_AVAILABLE:
|
||||
raise NotFound("Ride model models not available")
|
||||
try:
|
||||
return RideModel.objects.select_related("manufacturer").prefetch_related(
|
||||
"photos", "variants", "technical_specs"
|
||||
).get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
|
||||
return (
|
||||
RideModel.objects.select_related("manufacturer")
|
||||
.prefetch_related("photos", "variants", "technical_specs")
|
||||
.get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
|
||||
)
|
||||
except RideModel.DoesNotExist:
|
||||
raise NotFound("Ride model not found")
|
||||
|
||||
@@ -277,16 +307,24 @@ class RideModelDetailAPIView(APIView):
|
||||
description="Get detailed information about a specific ride model.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={200: RideModelDetailOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
|
||||
def get(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
serializer = RideModelDetailOutputSerializer(
|
||||
ride_model, context={"request": request}
|
||||
@@ -298,17 +336,25 @@ class RideModelDetailAPIView(APIView):
|
||||
description="Update a ride model (partial update supported).",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
request=RideModelUpdateInputSerializer,
|
||||
responses={200: RideModelDetailOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def patch(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
|
||||
def patch(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
serializer_in = RideModelUpdateInputSerializer(data=request.data, partial=True)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
@@ -331,7 +377,9 @@ class RideModelDetailAPIView(APIView):
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
|
||||
def put(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
# Full replace - reuse patch behavior for simplicity
|
||||
return self.patch(request, manufacturer_slug, ride_model_slug)
|
||||
|
||||
@@ -340,16 +388,24 @@ class RideModelDetailAPIView(APIView):
|
||||
description="Delete a ride model.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={204: None},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def delete(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
|
||||
def delete(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
ride_model.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -366,7 +422,10 @@ class RideModelSearchAPIView(APIView):
|
||||
description="Search ride models by name, description, or manufacturer.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True
|
||||
name="q",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
)
|
||||
],
|
||||
responses={200: RideModelListOutputSerializer(many=True)},
|
||||
@@ -384,15 +443,15 @@ class RideModelSearchAPIView(APIView):
|
||||
"id": 1,
|
||||
"name": "Hyper Coaster",
|
||||
"manufacturer": {"name": "Bolliger & Mabillard"},
|
||||
"category": "RC"
|
||||
"category": "RC",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
qs = RideModel.objects.filter(
|
||||
Q(name__icontains=q) |
|
||||
Q(description__icontains=q) |
|
||||
Q(manufacturer__name__icontains=q)
|
||||
Q(name__icontains=q)
|
||||
| Q(description__icontains=q)
|
||||
| Q(manufacturer__name__icontains=q)
|
||||
).select_related("manufacturer")[:20]
|
||||
|
||||
results = [
|
||||
@@ -426,54 +485,65 @@ class RideModelFilterOptionsAPIView(APIView):
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return filter options for ride models."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response({
|
||||
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
|
||||
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
|
||||
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
|
||||
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
|
||||
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
|
||||
}
|
||||
)
|
||||
|
||||
# Get actual data from database
|
||||
manufacturers = Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"],
|
||||
ride_models__isnull=False
|
||||
).distinct().values("id", "name", "slug")
|
||||
manufacturers = (
|
||||
Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"], ride_models__isnull=False
|
||||
)
|
||||
.distinct()
|
||||
.values("id", "name", "slug")
|
||||
)
|
||||
|
||||
categories = RideModel.objects.exclude(category="").values_list(
|
||||
"category", flat=True
|
||||
).distinct()
|
||||
categories = (
|
||||
RideModel.objects.exclude(category="")
|
||||
.values_list("category", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
target_markets = RideModel.objects.exclude(target_market="").values_list(
|
||||
"target_market", flat=True
|
||||
).distinct()
|
||||
target_markets = (
|
||||
RideModel.objects.exclude(target_market="")
|
||||
.values_list("target_market", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return Response({
|
||||
"categories": [
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
"target_markets": [
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
"manufacturers": list(manufacturers),
|
||||
"ordering_options": [
|
||||
("name", "Name A-Z"),
|
||||
("-name", "Name Z-A"),
|
||||
("manufacturer__name", "Manufacturer A-Z"),
|
||||
("-manufacturer__name", "Manufacturer Z-A"),
|
||||
("first_installation_year", "Oldest First"),
|
||||
("-first_installation_year", "Newest First"),
|
||||
("total_installations", "Fewest Installations"),
|
||||
("-total_installations", "Most Installations"),
|
||||
],
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"categories": [
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
"target_markets": [
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
"manufacturers": list(manufacturers),
|
||||
"ordering_options": [
|
||||
("name", "Name A-Z"),
|
||||
("-name", "Name Z-A"),
|
||||
("manufacturer__name", "Manufacturer A-Z"),
|
||||
("-manufacturer__name", "Manufacturer Z-A"),
|
||||
("first_installation_year", "Oldest First"),
|
||||
("-first_installation_year", "Newest First"),
|
||||
("total_installations", "Fewest Installations"),
|
||||
("-total_installations", "Most Installations"),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL STATISTICS ===
|
||||
@@ -491,69 +561,84 @@ class RideModelStatsAPIView(APIView):
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get ride model statistics."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response({
|
||||
"total_models": 50,
|
||||
"total_installations": 500,
|
||||
"active_manufacturers": 15,
|
||||
"discontinued_models": 10,
|
||||
"by_category": {"RC": 30, "FR": 15, "WR": 5},
|
||||
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
|
||||
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
|
||||
"recent_models": 3,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"total_models": 50,
|
||||
"total_installations": 500,
|
||||
"active_manufacturers": 15,
|
||||
"discontinued_models": 10,
|
||||
"by_category": {"RC": 30, "FR": 15, "WR": 5},
|
||||
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
|
||||
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
|
||||
"recent_models": 3,
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate statistics
|
||||
total_models = RideModel.objects.count()
|
||||
total_installations = RideModel.objects.aggregate(
|
||||
total=Count('rides')
|
||||
)['total'] or 0
|
||||
total_installations = (
|
||||
RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
|
||||
)
|
||||
|
||||
active_manufacturers = Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"],
|
||||
ride_models__isnull=False
|
||||
).distinct().count()
|
||||
active_manufacturers = (
|
||||
Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"], ride_models__isnull=False
|
||||
)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
discontinued_models = RideModel.objects.filter(is_discontinued=True).count()
|
||||
|
||||
# Category breakdown
|
||||
by_category = {}
|
||||
category_counts = RideModel.objects.exclude(category="").values(
|
||||
"category"
|
||||
).annotate(count=Count("id"))
|
||||
category_counts = (
|
||||
RideModel.objects.exclude(category="")
|
||||
.values("category")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
for item in category_counts:
|
||||
by_category[item["category"]] = item["count"]
|
||||
|
||||
# Target market breakdown
|
||||
by_target_market = {}
|
||||
market_counts = RideModel.objects.exclude(target_market="").values(
|
||||
"target_market"
|
||||
).annotate(count=Count("id"))
|
||||
market_counts = (
|
||||
RideModel.objects.exclude(target_market="")
|
||||
.values("target_market")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
for item in market_counts:
|
||||
by_target_market[item["target_market"]] = item["count"]
|
||||
|
||||
# Manufacturer breakdown (top 10)
|
||||
by_manufacturer = {}
|
||||
manufacturer_counts = RideModel.objects.filter(
|
||||
manufacturer__isnull=False
|
||||
).values("manufacturer__name").annotate(count=Count("id")).order_by("-count")[:10]
|
||||
manufacturer_counts = (
|
||||
RideModel.objects.filter(manufacturer__isnull=False)
|
||||
.values("manufacturer__name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:10]
|
||||
)
|
||||
for item in manufacturer_counts:
|
||||
by_manufacturer[item["manufacturer__name"]] = item["count"]
|
||||
|
||||
# Recent models (last 30 days)
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
recent_models = RideModel.objects.filter(
|
||||
created_at__gte=thirty_days_ago).count()
|
||||
created_at__gte=thirty_days_ago
|
||||
).count()
|
||||
|
||||
return Response({
|
||||
"total_models": total_models,
|
||||
"total_installations": total_installations,
|
||||
"active_manufacturers": active_manufacturers,
|
||||
"discontinued_models": discontinued_models,
|
||||
"by_category": by_category,
|
||||
"by_target_market": by_target_market,
|
||||
"by_manufacturer": by_manufacturer,
|
||||
"recent_models": recent_models,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"total_models": total_models,
|
||||
"total_installations": total_installations,
|
||||
"active_manufacturers": active_manufacturers,
|
||||
"discontinued_models": discontinued_models,
|
||||
"by_category": by_category,
|
||||
"by_target_market": by_target_market,
|
||||
"by_manufacturer": by_manufacturer,
|
||||
"recent_models": recent_models,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL VARIANTS ===
|
||||
@@ -592,7 +677,7 @@ class RideModelVariantListCreateAPIView(APIView):
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Variants not available"},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -653,7 +738,8 @@ class RideModelVariantDetailAPIView(APIView):
|
||||
def patch(self, request: Request, ride_model_pk: int, pk: int) -> Response:
|
||||
variant = self._get_variant_or_404(ride_model_pk, pk)
|
||||
serializer_in = RideModelVariantUpdateInputSerializer(
|
||||
data=request.data, partial=True)
|
||||
data=request.data, partial=True
|
||||
)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
|
||||
for field, value in serializer_in.validated_data.items():
|
||||
@@ -677,25 +763,30 @@ class RideModelVariantDetailAPIView(APIView):
|
||||
# Note: Similar patterns would be implemented for RideModelTechnicalSpec and RideModelPhoto
|
||||
# For brevity, I'm including the class definitions but not the full implementations
|
||||
|
||||
|
||||
class RideModelTechnicalSpecListCreateAPIView(APIView):
|
||||
"""CRUD operations for ride model technical specifications."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variants...
|
||||
|
||||
|
||||
class RideModelTechnicalSpecDetailAPIView(APIView):
|
||||
"""CRUD operations for individual technical specifications."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variant detail...
|
||||
|
||||
|
||||
class RideModelPhotoListCreateAPIView(APIView):
|
||||
"""CRUD operations for ride model photos."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variants...
|
||||
|
||||
|
||||
class RideModelPhotoDetailAPIView(APIView):
|
||||
"""CRUD operations for individual ride model photos."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variant detail...
|
||||
|
||||
@@ -5,42 +5,46 @@ This module contains serializers for ride-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.rides.models import Ride, RidePhoto
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name='Ride Photo with Cloudflare Images',
|
||||
summary='Complete ride photo response',
|
||||
description='Example response showing all fields including Cloudflare Images URLs and variants',
|
||||
name="Ride Photo with Cloudflare Images",
|
||||
summary="Complete ride photo response",
|
||||
description="Example response showing all fields including Cloudflare Images URLs and variants",
|
||||
value={
|
||||
'id': 123,
|
||||
'image': 'https://imagedelivery.net/account-hash/abc123def456/public',
|
||||
'image_url': 'https://imagedelivery.net/account-hash/abc123def456/public',
|
||||
'image_variants': {
|
||||
'thumbnail': 'https://imagedelivery.net/account-hash/abc123def456/thumbnail',
|
||||
'medium': 'https://imagedelivery.net/account-hash/abc123def456/medium',
|
||||
'large': 'https://imagedelivery.net/account-hash/abc123def456/large',
|
||||
'public': 'https://imagedelivery.net/account-hash/abc123def456/public'
|
||||
"id": 123,
|
||||
"image": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
'caption': 'Amazing roller coaster photo',
|
||||
'alt_text': 'Steel roller coaster with multiple inversions',
|
||||
'is_primary': True,
|
||||
'is_approved': True,
|
||||
'photo_type': 'exterior',
|
||||
'created_at': '2023-01-01T12:00:00Z',
|
||||
'updated_at': '2023-01-01T12:00:00Z',
|
||||
'date_taken': '2023-01-01T10:00:00Z',
|
||||
'uploaded_by_username': 'photographer123',
|
||||
'file_size': 2048576,
|
||||
'dimensions': [1920, 1080],
|
||||
'ride_slug': 'steel-vengeance',
|
||||
'ride_name': 'Steel Vengeance',
|
||||
'park_slug': 'cedar-point',
|
||||
'park_name': 'Cedar Point'
|
||||
}
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"alt_text": "Steel roller coaster with multiple inversions",
|
||||
"is_primary": True,
|
||||
"is_approved": True,
|
||||
"photo_type": "exterior",
|
||||
"created_at": "2023-01-01T12:00:00Z",
|
||||
"updated_at": "2023-01-01T12:00:00Z",
|
||||
"date_taken": "2023-01-01T10:00:00Z",
|
||||
"uploaded_by_username": "photographer123",
|
||||
"file_size": 2048576,
|
||||
"dimensions": [1920, 1080],
|
||||
"ride_slug": "steel-vengeance",
|
||||
"ride_name": "Steel Vengeance",
|
||||
"park_slug": "cedar-point",
|
||||
"park_name": "Cedar Point",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -78,8 +82,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.URLField(
|
||||
help_text="Full URL to the Cloudflare Images asset",
|
||||
allow_null=True
|
||||
help_text="Full URL to the Cloudflare Images asset", allow_null=True
|
||||
)
|
||||
)
|
||||
def get_image_url(self, obj):
|
||||
@@ -91,7 +94,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
@extend_schema_field(
|
||||
serializers.DictField(
|
||||
child=serializers.URLField(),
|
||||
help_text="Available Cloudflare Images variants with their URLs"
|
||||
help_text="Available Cloudflare Images variants with their URLs",
|
||||
)
|
||||
)
|
||||
def get_image_variants(self, obj):
|
||||
@@ -101,10 +104,10 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Common variants for ride photos
|
||||
variants = {
|
||||
'thumbnail': f"{obj.image.url}/thumbnail",
|
||||
'medium': f"{obj.image.url}/medium",
|
||||
'large': f"{obj.image.url}/large",
|
||||
'public': f"{obj.image.url}/public"
|
||||
"thumbnail": f"{obj.image.url}/thumbnail",
|
||||
"medium": f"{obj.image.url}/medium",
|
||||
"large": f"{obj.image.url}/large",
|
||||
"public": f"{obj.image.url}/public",
|
||||
}
|
||||
return variants
|
||||
|
||||
|
||||
@@ -50,13 +50,18 @@ urlpatterns = [
|
||||
name="ride-search-suggestions",
|
||||
),
|
||||
# Ride model management endpoints - nested under rides/manufacturers
|
||||
path("manufacturers/<slug:manufacturer_slug>/",
|
||||
include("apps.api.v1.rides.manufacturers.urls")),
|
||||
path(
|
||||
"manufacturers/<slug:manufacturer_slug>/",
|
||||
include("apps.api.v1.rides.manufacturers.urls"),
|
||||
),
|
||||
# Detail and action endpoints
|
||||
path("<int:pk>/", RideDetailAPIView.as_view(), name="ride-detail"),
|
||||
# Ride image settings endpoint
|
||||
path("<int:pk>/image-settings/", RideImageSettingsAPIView.as_view(),
|
||||
name="ride-image-settings"),
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
RideImageSettingsAPIView.as_view(),
|
||||
name="ride-image-settings",
|
||||
),
|
||||
# Ride photo endpoints - domain-specific photo management
|
||||
path("<int:ride_pk>/photos/", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -21,7 +21,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.exceptions import NotFound
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
@@ -73,136 +73,202 @@ class RideListCreateAPIView(APIView):
|
||||
description="List rides with comprehensive filtering options including category, status, manufacturer, designer, ride model, and more.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Page number for pagination"
|
||||
name="page",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Page number for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Number of results per page (max 1000)"
|
||||
name="page_size",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Number of results per page (max 1000)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Search in ride names and descriptions"
|
||||
name="search",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Search in ride names and descriptions",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by park slug"
|
||||
name="park_slug",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by park slug",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="park_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by park ID"
|
||||
name="park_id",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by park ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR"
|
||||
name="category",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="status", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP"
|
||||
name="status",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="manufacturer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by manufacturer company ID"
|
||||
name="manufacturer_id",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by manufacturer company ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by manufacturer company slug"
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by manufacturer company slug",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="designer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by designer company ID"
|
||||
name="designer_id",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by designer company ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="designer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by designer company slug"
|
||||
name="designer_slug",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by designer company slug",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by specific ride model ID"
|
||||
name="ride_model_id",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by specific ride model ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by ride model slug (requires manufacturer_slug)"
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride model slug (requires manufacturer_slug)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="roller_coaster_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)"
|
||||
name="roller_coaster_type",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="track_material", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)"
|
||||
name="track_material",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="launch_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)"
|
||||
name="launch_type",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter by minimum average rating (1-10)"
|
||||
name="min_rating",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter by minimum average rating (1-10)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter by maximum average rating (1-10)"
|
||||
name="max_rating",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter by maximum average rating (1-10)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by minimum height requirement in inches"
|
||||
name="min_height_requirement",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by minimum height requirement in inches",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by maximum height requirement in inches"
|
||||
name="max_height_requirement",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by maximum height requirement in inches",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by minimum hourly capacity"
|
||||
name="min_capacity",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by minimum hourly capacity",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by maximum hourly capacity"
|
||||
name="max_capacity",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by maximum hourly capacity",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by minimum height in feet"
|
||||
name="min_height_ft",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by minimum height in feet",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by maximum height in feet"
|
||||
name="max_height_ft",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by maximum height in feet",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by minimum speed in mph"
|
||||
name="min_speed_mph",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by minimum speed in mph",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by maximum speed in mph"
|
||||
name="max_speed_mph",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by maximum speed in mph",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter roller coasters by minimum number of inversions"
|
||||
name="min_inversions",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter roller coasters by minimum number of inversions",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter roller coasters by maximum number of inversions"
|
||||
name="max_inversions",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter roller coasters by maximum number of inversions",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="has_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL,
|
||||
description="Filter roller coasters that have inversions (true) or don't have inversions (false)"
|
||||
name="has_inversions",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.BOOL,
|
||||
description="Filter roller coasters that have inversions (true) or don't have inversions (false)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by opening year"
|
||||
name="opening_year",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by opening year",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by minimum opening year"
|
||||
name="min_opening_year",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by minimum opening year",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by maximum opening year"
|
||||
name="max_opening_year",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by maximum opening year",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph"
|
||||
name="ordering",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph",
|
||||
),
|
||||
],
|
||||
responses={200: RideListOutputSerializer(many=True)},
|
||||
@@ -220,17 +286,25 @@ class RideListCreateAPIView(APIView):
|
||||
)
|
||||
|
||||
# Start with base queryset with optimized joins
|
||||
qs = Ride.objects.all().select_related(
|
||||
"park", "manufacturer", "designer", "ride_model", "ride_model__manufacturer"
|
||||
).prefetch_related("coaster_stats") # type: ignore
|
||||
qs = (
|
||||
Ride.objects.all()
|
||||
.select_related(
|
||||
"park",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"ride_model__manufacturer",
|
||||
)
|
||||
.prefetch_related("coaster_stats")
|
||||
) # type: ignore
|
||||
|
||||
# Text search
|
||||
search = request.query_params.get("search")
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
models.Q(name__icontains=search) |
|
||||
models.Q(description__icontains=search) |
|
||||
models.Q(park__name__icontains=search)
|
||||
models.Q(name__icontains=search)
|
||||
| models.Q(description__icontains=search)
|
||||
| models.Q(park__name__icontains=search)
|
||||
)
|
||||
|
||||
# Park filters
|
||||
@@ -292,7 +366,7 @@ class RideListCreateAPIView(APIView):
|
||||
if ride_model_slug and manufacturer_slug_for_model:
|
||||
qs = qs.filter(
|
||||
ride_model__slug=ride_model_slug,
|
||||
ride_model__manufacturer__slug=manufacturer_slug_for_model
|
||||
ride_model__manufacturer__slug=manufacturer_slug_for_model,
|
||||
)
|
||||
|
||||
# Rating filters
|
||||
@@ -422,24 +496,36 @@ class RideListCreateAPIView(APIView):
|
||||
|
||||
has_inversions = request.query_params.get("has_inversions")
|
||||
if has_inversions is not None:
|
||||
if has_inversions.lower() in ['true', '1', 'yes']:
|
||||
if has_inversions.lower() in ["true", "1", "yes"]:
|
||||
qs = qs.filter(coaster_stats__inversions__gt=0)
|
||||
elif has_inversions.lower() in ['false', '0', 'no']:
|
||||
elif has_inversions.lower() in ["false", "0", "no"]:
|
||||
qs = qs.filter(coaster_stats__inversions=0)
|
||||
|
||||
# Ordering
|
||||
ordering = request.query_params.get("ordering", "name")
|
||||
valid_orderings = [
|
||||
"name", "-name", "opening_date", "-opening_date",
|
||||
"average_rating", "-average_rating", "capacity_per_hour", "-capacity_per_hour",
|
||||
"created_at", "-created_at", "height_ft", "-height_ft", "speed_mph", "-speed_mph"
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"average_rating",
|
||||
"-average_rating",
|
||||
"capacity_per_hour",
|
||||
"-capacity_per_hour",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"height_ft",
|
||||
"-height_ft",
|
||||
"speed_mph",
|
||||
"-speed_mph",
|
||||
]
|
||||
|
||||
if ordering in valid_orderings:
|
||||
if ordering in ["height_ft", "-height_ft", "speed_mph", "-speed_mph"]:
|
||||
# For coaster stats ordering, we need to join and order by the stats
|
||||
ordering_field = ordering.replace("height_ft", "coaster_stats__height_ft").replace(
|
||||
"speed_mph", "coaster_stats__speed_mph")
|
||||
ordering_field = ordering.replace(
|
||||
"height_ft", "coaster_stats__height_ft"
|
||||
).replace("speed_mph", "coaster_stats__speed_mph")
|
||||
qs = qs.order_by(ordering_field)
|
||||
else:
|
||||
qs = qs.order_by(ordering)
|
||||
@@ -592,16 +678,24 @@ class FilterOptionsAPIView(APIView):
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
{"value": "opening_date",
|
||||
"label": "Opening Date (Oldest First)"},
|
||||
{"value": "-opening_date",
|
||||
"label": "Opening Date (Newest First)"},
|
||||
{
|
||||
"value": "opening_date",
|
||||
"label": "Opening Date (Oldest First)",
|
||||
},
|
||||
{
|
||||
"value": "-opening_date",
|
||||
"label": "Opening Date (Newest First)",
|
||||
},
|
||||
{"value": "average_rating", "label": "Rating (Lowest First)"},
|
||||
{"value": "-average_rating", "label": "Rating (Highest First)"},
|
||||
{"value": "capacity_per_hour",
|
||||
"label": "Capacity (Lowest First)"},
|
||||
{"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)"},
|
||||
{
|
||||
"value": "capacity_per_hour",
|
||||
"label": "Capacity (Lowest First)",
|
||||
},
|
||||
{
|
||||
"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)",
|
||||
},
|
||||
{"value": "height_ft", "label": "Height (Shortest First)"},
|
||||
{"value": "-height_ft", "label": "Height (Tallest First)"},
|
||||
{"value": "speed_mph", "label": "Speed (Slowest First)"},
|
||||
@@ -611,16 +705,39 @@ class FilterOptionsAPIView(APIView):
|
||||
],
|
||||
"filter_ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1},
|
||||
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
|
||||
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
|
||||
"height_requirement": {
|
||||
"min": 30,
|
||||
"max": 90,
|
||||
"step": 1,
|
||||
"unit": "inches",
|
||||
},
|
||||
"capacity": {
|
||||
"min": 0,
|
||||
"max": 5000,
|
||||
"step": 50,
|
||||
"unit": "riders/hour",
|
||||
},
|
||||
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
|
||||
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
|
||||
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
|
||||
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
|
||||
"inversions": {
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"unit": "inversions",
|
||||
},
|
||||
"opening_year": {
|
||||
"min": 1800,
|
||||
"max": 2030,
|
||||
"step": 1,
|
||||
"unit": "year",
|
||||
},
|
||||
},
|
||||
"boolean_filters": [
|
||||
{"key": "has_inversions", "label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions"},
|
||||
{
|
||||
"key": "has_inversions",
|
||||
"label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions",
|
||||
},
|
||||
],
|
||||
}
|
||||
return Response(data)
|
||||
@@ -682,8 +799,10 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "average_rating", "label": "Rating (Lowest First)"},
|
||||
{"value": "-average_rating", "label": "Rating (Highest First)"},
|
||||
{"value": "capacity_per_hour", "label": "Capacity (Lowest First)"},
|
||||
{"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)"},
|
||||
{
|
||||
"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)",
|
||||
},
|
||||
{"value": "height_ft", "label": "Height (Shortest First)"},
|
||||
{"value": "-height_ft", "label": "Height (Tallest First)"},
|
||||
{"value": "speed_mph", "label": "Speed (Slowest First)"},
|
||||
@@ -693,16 +812,39 @@ class FilterOptionsAPIView(APIView):
|
||||
],
|
||||
"filter_ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1},
|
||||
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
|
||||
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
|
||||
"height_requirement": {
|
||||
"min": 30,
|
||||
"max": 90,
|
||||
"step": 1,
|
||||
"unit": "inches",
|
||||
},
|
||||
"capacity": {
|
||||
"min": 0,
|
||||
"max": 5000,
|
||||
"step": 50,
|
||||
"unit": "riders/hour",
|
||||
},
|
||||
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
|
||||
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
|
||||
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
|
||||
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
|
||||
"inversions": {
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"unit": "inversions",
|
||||
},
|
||||
"opening_year": {
|
||||
"min": 1800,
|
||||
"max": 2030,
|
||||
"step": 1,
|
||||
"unit": "year",
|
||||
},
|
||||
},
|
||||
"boolean_filters": [
|
||||
{"key": "has_inversions", "label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions"},
|
||||
{
|
||||
"key": "has_inversions",
|
||||
"label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -853,7 +995,8 @@ class RideImageSettingsAPIView(APIView):
|
||||
|
||||
# Return updated ride data
|
||||
output_serializer = RideDetailOutputSerializer(
|
||||
ride, context={"request": request})
|
||||
ride, context={"request": request}
|
||||
)
|
||||
return Response(output_serializer.data)
|
||||
|
||||
|
||||
|
||||
@@ -264,7 +264,6 @@ __all__ = [
|
||||
"LocationOutputSerializer",
|
||||
"CompanyOutputSerializer",
|
||||
"UserModel",
|
||||
|
||||
# Parks exports
|
||||
"ParkListOutputSerializer",
|
||||
"ParkDetailOutputSerializer",
|
||||
@@ -279,7 +278,6 @@ __all__ = [
|
||||
"ParkLocationUpdateInputSerializer",
|
||||
"ParkSuggestionSerializer",
|
||||
"ParkSuggestionOutputSerializer",
|
||||
|
||||
# Companies exports
|
||||
"CompanyDetailOutputSerializer",
|
||||
"CompanyCreateInputSerializer",
|
||||
@@ -287,7 +285,6 @@ __all__ = [
|
||||
"RideModelDetailOutputSerializer",
|
||||
"RideModelCreateInputSerializer",
|
||||
"RideModelUpdateInputSerializer",
|
||||
|
||||
# Rides exports
|
||||
"RideParkOutputSerializer",
|
||||
"RideModelOutputSerializer",
|
||||
@@ -305,7 +302,6 @@ __all__ = [
|
||||
"RideReviewOutputSerializer",
|
||||
"RideReviewCreateInputSerializer",
|
||||
"RideReviewUpdateInputSerializer",
|
||||
|
||||
# Services exports
|
||||
"HealthCheckOutputSerializer",
|
||||
"PerformanceMetricsOutputSerializer",
|
||||
|
||||
873
backend/apps/api/v1/serializers/accounts.py
Normal file
873
backend/apps/api/v1/serializers/accounts.py
Normal file
@@ -0,0 +1,873 @@
|
||||
"""
|
||||
User accounts and settings serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to user account management,
|
||||
profile settings, preferences, privacy, notifications, and security.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.accounts.models import (
|
||||
User,
|
||||
UserProfile,
|
||||
TopList,
|
||||
UserNotification,
|
||||
NotificationPreference,
|
||||
)
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
# === USER PROFILE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Profile Example",
|
||||
summary="Complete user profile",
|
||||
description="Full user profile with all fields",
|
||||
value={
|
||||
"user_id": "1234",
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"theme_preference": "dark",
|
||||
"profile": {
|
||||
"profile_id": "5678",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar": "https://example.com/avatars/user.jpg",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Love roller coasters and theme parks!",
|
||||
"twitter": "https://twitter.com/thrillseeker",
|
||||
"instagram": "https://instagram.com/thrillseeker",
|
||||
"youtube": "https://youtube.com/thrillseeker",
|
||||
"discord": "thrillseeker#1234",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user profile data."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
avatar_variants = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"profile_id",
|
||||
"display_name",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"avatar_variants",
|
||||
"pronouns",
|
||||
"bio",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"youtube",
|
||||
"discord",
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
]
|
||||
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the avatar URL with fallback to default letter-based avatar."""
|
||||
return obj.get_avatar_url()
|
||||
|
||||
def get_avatar_variants(self, obj):
|
||||
"""Get avatar variants for different use cases."""
|
||||
return obj.get_avatar_variants()
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display name uniqueness - now checks User model first."""
|
||||
user = self.context["request"].user
|
||||
# Check User model for display_name uniqueness (primary location)
|
||||
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
# Also check UserProfile for backward compatibility during transition
|
||||
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Complete User Example",
|
||||
summary="Complete user with profile",
|
||||
description="Full user object with embedded profile",
|
||||
value={
|
||||
"user_id": "1234",
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"theme_preference": "dark",
|
||||
"profile": {
|
||||
"profile_id": "5678",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar": "https://example.com/avatars/user.jpg",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Love roller coasters and theme parks!",
|
||||
"twitter": "https://twitter.com/thrillseeker",
|
||||
"instagram": "https://instagram.com/thrillseeker",
|
||||
"youtube": "https://youtube.com/thrillseeker",
|
||||
"discord": "thrillseeker#1234",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class CompleteUserSerializer(serializers.ModelSerializer):
|
||||
"""Complete user serializer with profile data."""
|
||||
|
||||
profile = UserProfileSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"user_id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
"role",
|
||||
"theme_preference",
|
||||
"profile",
|
||||
]
|
||||
read_only_fields = ["user_id", "date_joined", "role"]
|
||||
|
||||
|
||||
# === USER SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Preferences Example",
|
||||
summary="User preferences and settings",
|
||||
description="User's preference settings",
|
||||
value={
|
||||
"theme_preference": "dark",
|
||||
"email_notifications": True,
|
||||
"push_notifications": False,
|
||||
"privacy_level": "public",
|
||||
"show_email": False,
|
||||
"show_real_name": True,
|
||||
"show_statistics": True,
|
||||
"allow_friend_requests": True,
|
||||
"allow_messages": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserPreferencesSerializer(serializers.Serializer):
|
||||
"""Serializer for user preferences and settings."""
|
||||
|
||||
theme_preference = serializers.ChoiceField(
|
||||
choices=User.ThemePreference.choices, help_text="User's theme preference"
|
||||
)
|
||||
email_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Whether to receive email notifications"
|
||||
)
|
||||
push_notifications = serializers.BooleanField(
|
||||
default=False, help_text="Whether to receive push notifications"
|
||||
)
|
||||
privacy_level = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
help_text="Profile visibility level",
|
||||
)
|
||||
show_email = serializers.BooleanField(
|
||||
default=False, help_text="Whether to show email on profile"
|
||||
)
|
||||
show_real_name = serializers.BooleanField(
|
||||
default=True, help_text="Whether to show real name on profile"
|
||||
)
|
||||
show_statistics = serializers.BooleanField(
|
||||
default=True, help_text="Whether to show ride statistics on profile"
|
||||
)
|
||||
allow_friend_requests = serializers.BooleanField(
|
||||
default=True, help_text="Whether to allow friend requests"
|
||||
)
|
||||
allow_messages = serializers.BooleanField(
|
||||
default=True, help_text="Whether to allow direct messages"
|
||||
)
|
||||
|
||||
|
||||
# === NOTIFICATION SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Notification Settings Example",
|
||||
summary="User notification preferences",
|
||||
description="Detailed notification settings",
|
||||
value={
|
||||
"email_notifications": {
|
||||
"new_reviews": True,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
"weekly_digest": False,
|
||||
"new_features": True,
|
||||
"security_alerts": True,
|
||||
},
|
||||
"push_notifications": {
|
||||
"new_reviews": False,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
},
|
||||
"in_app_notifications": {
|
||||
"new_reviews": True,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
"system_announcements": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class NotificationSettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for detailed notification settings."""
|
||||
|
||||
class EmailNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=True)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
weekly_digest = serializers.BooleanField(default=False)
|
||||
new_features = serializers.BooleanField(default=True)
|
||||
security_alerts = serializers.BooleanField(default=True)
|
||||
|
||||
class PushNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=False)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
|
||||
class InAppNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=True)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
system_announcements = serializers.BooleanField(default=True)
|
||||
|
||||
email_notifications = EmailNotificationsSerializer()
|
||||
push_notifications = PushNotificationsSerializer()
|
||||
in_app_notifications = InAppNotificationsSerializer()
|
||||
|
||||
|
||||
# === PRIVACY SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Privacy Settings Example",
|
||||
summary="User privacy settings",
|
||||
description="Detailed privacy and visibility settings",
|
||||
value={
|
||||
"profile_visibility": "public",
|
||||
"show_email": False,
|
||||
"show_real_name": True,
|
||||
"show_join_date": True,
|
||||
"show_statistics": True,
|
||||
"show_reviews": True,
|
||||
"show_photos": True,
|
||||
"show_top_lists": True,
|
||||
"allow_friend_requests": True,
|
||||
"allow_messages": True,
|
||||
"allow_profile_comments": False,
|
||||
"search_visibility": True,
|
||||
"activity_visibility": "friends",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PrivacySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for privacy and visibility settings."""
|
||||
|
||||
profile_visibility = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
help_text="Overall profile visibility",
|
||||
)
|
||||
show_email = serializers.BooleanField(
|
||||
default=False, help_text="Show email address on profile"
|
||||
)
|
||||
show_real_name = serializers.BooleanField(
|
||||
default=True, help_text="Show real name on profile"
|
||||
)
|
||||
show_join_date = serializers.BooleanField(
|
||||
default=True, help_text="Show join date on profile"
|
||||
)
|
||||
show_statistics = serializers.BooleanField(
|
||||
default=True, help_text="Show ride statistics on profile"
|
||||
)
|
||||
show_reviews = serializers.BooleanField(
|
||||
default=True, help_text="Show reviews on profile"
|
||||
)
|
||||
show_photos = serializers.BooleanField(
|
||||
default=True, help_text="Show uploaded photos on profile"
|
||||
)
|
||||
show_top_lists = serializers.BooleanField(
|
||||
default=True, help_text="Show top lists on profile"
|
||||
)
|
||||
allow_friend_requests = serializers.BooleanField(
|
||||
default=True, help_text="Allow others to send friend requests"
|
||||
)
|
||||
allow_messages = serializers.BooleanField(
|
||||
default=True, help_text="Allow others to send direct messages"
|
||||
)
|
||||
allow_profile_comments = serializers.BooleanField(
|
||||
default=False, help_text="Allow others to comment on profile"
|
||||
)
|
||||
search_visibility = serializers.BooleanField(
|
||||
default=True, help_text="Allow profile to appear in search results"
|
||||
)
|
||||
activity_visibility = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
help_text="Who can see your activity feed",
|
||||
)
|
||||
|
||||
|
||||
# === SECURITY SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Security Settings Example",
|
||||
summary="User security settings",
|
||||
description="Account security and authentication settings",
|
||||
value={
|
||||
"two_factor_enabled": False,
|
||||
"login_notifications": True,
|
||||
"session_timeout": 30,
|
||||
"require_password_change": False,
|
||||
"last_password_change": "2024-01-01T00:00:00Z",
|
||||
"active_sessions": 2,
|
||||
"login_history_retention": 90,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SecuritySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for security settings."""
|
||||
|
||||
two_factor_enabled = serializers.BooleanField(
|
||||
default=False, help_text="Whether two-factor authentication is enabled"
|
||||
)
|
||||
login_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Send notifications for new logins"
|
||||
)
|
||||
session_timeout = serializers.IntegerField(
|
||||
default=30, min_value=5, max_value=180, help_text="Session timeout in days"
|
||||
)
|
||||
require_password_change = serializers.BooleanField(
|
||||
default=False, help_text="Whether password change is required"
|
||||
)
|
||||
last_password_change = serializers.DateTimeField(
|
||||
read_only=True, help_text="When password was last changed"
|
||||
)
|
||||
active_sessions = serializers.IntegerField(
|
||||
read_only=True, help_text="Number of active sessions"
|
||||
)
|
||||
login_history_retention = serializers.IntegerField(
|
||||
default=90,
|
||||
min_value=30,
|
||||
max_value=365,
|
||||
help_text="How long to keep login history (days)",
|
||||
)
|
||||
|
||||
|
||||
# === USER STATISTICS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Statistics Example",
|
||||
summary="User activity statistics",
|
||||
description="Comprehensive user activity and contribution statistics",
|
||||
value={
|
||||
"ride_credits": {
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
"total_credits": 307,
|
||||
},
|
||||
"contributions": {
|
||||
"park_reviews": 25,
|
||||
"ride_reviews": 87,
|
||||
"photos_uploaded": 156,
|
||||
"top_lists_created": 8,
|
||||
"helpful_votes_received": 342,
|
||||
},
|
||||
"activity": {
|
||||
"days_active": 45,
|
||||
"last_active": "2024-01-15T10:30:00Z",
|
||||
"average_review_rating": 4.2,
|
||||
"most_reviewed_park": "Cedar Point",
|
||||
"favorite_ride_type": "Roller Coaster",
|
||||
},
|
||||
"achievements": {
|
||||
"first_review": True,
|
||||
"photo_contributor": True,
|
||||
"top_reviewer": False,
|
||||
"park_explorer": True,
|
||||
"coaster_enthusiast": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserStatisticsSerializer(serializers.Serializer):
|
||||
"""Serializer for user statistics and achievements."""
|
||||
|
||||
class RideCreditsSerializer(serializers.Serializer):
|
||||
coaster_credits = serializers.IntegerField()
|
||||
dark_ride_credits = serializers.IntegerField()
|
||||
flat_ride_credits = serializers.IntegerField()
|
||||
water_ride_credits = serializers.IntegerField()
|
||||
total_credits = serializers.IntegerField()
|
||||
|
||||
class ContributionsSerializer(serializers.Serializer):
|
||||
park_reviews = serializers.IntegerField()
|
||||
ride_reviews = serializers.IntegerField()
|
||||
photos_uploaded = serializers.IntegerField()
|
||||
top_lists_created = serializers.IntegerField()
|
||||
helpful_votes_received = serializers.IntegerField()
|
||||
|
||||
class ActivitySerializer(serializers.Serializer):
|
||||
days_active = serializers.IntegerField()
|
||||
last_active = serializers.DateTimeField()
|
||||
average_review_rating = serializers.FloatField()
|
||||
most_reviewed_park = serializers.CharField()
|
||||
favorite_ride_type = serializers.CharField()
|
||||
|
||||
class AchievementsSerializer(serializers.Serializer):
|
||||
first_review = serializers.BooleanField()
|
||||
photo_contributor = serializers.BooleanField()
|
||||
top_reviewer = serializers.BooleanField()
|
||||
park_explorer = serializers.BooleanField()
|
||||
coaster_enthusiast = serializers.BooleanField()
|
||||
|
||||
ride_credits = RideCreditsSerializer()
|
||||
contributions = ContributionsSerializer()
|
||||
activity = ActivitySerializer()
|
||||
achievements = AchievementsSerializer()
|
||||
|
||||
|
||||
# === TOP LISTS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Top List Example",
|
||||
summary="User's top list",
|
||||
description="A user's ranked list of rides or parks",
|
||||
value={
|
||||
"id": 1,
|
||||
"title": "My Top 10 Roller Coasters",
|
||||
"category": "RC",
|
||||
"description": "My favorite roller coasters from around the world",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"items_count": 10,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class TopListSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user's top lists."""
|
||||
|
||||
items_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"category",
|
||||
"description",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"items_count",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_items_count(self, obj):
|
||||
"""Get the number of items in the list."""
|
||||
return obj.items.count()
|
||||
|
||||
|
||||
# === ACCOUNT UPDATE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Account Update Example",
|
||||
summary="Update account information",
|
||||
description="Update basic account information",
|
||||
value={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "newemail@example.com",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class AccountUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating account information."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
]
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email uniqueness."""
|
||||
user = self.context["request"].user
|
||||
if User.objects.filter(email=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Email already in use")
|
||||
return value
|
||||
|
||||
|
||||
# === PROFILE UPDATE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Profile Update Example",
|
||||
summary="Update profile information",
|
||||
description="Update profile information and social links",
|
||||
value={
|
||||
"display_name": "New Display Name",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Updated bio text",
|
||||
"twitter": "https://twitter.com/newhandle",
|
||||
"instagram": "",
|
||||
"youtube": "https://youtube.com/newchannel",
|
||||
"discord": "newhandle#5678",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating profile information."""
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"display_name",
|
||||
"pronouns",
|
||||
"bio",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"youtube",
|
||||
"discord",
|
||||
]
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display name uniqueness - now checks User model first."""
|
||||
user = self.context["request"].user
|
||||
# Check User model for display_name uniqueness (primary location)
|
||||
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
# Also check UserProfile for backward compatibility during transition
|
||||
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
return value
|
||||
|
||||
|
||||
# === THEME PREFERENCE SERIALIZER ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Theme Update Example",
|
||||
summary="Update theme preference",
|
||||
description="Update user's theme preference",
|
||||
value={
|
||||
"theme_preference": "dark",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ThemePreferenceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating theme preference."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["theme_preference"]
|
||||
|
||||
|
||||
# === NOTIFICATION SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Notification Example",
|
||||
summary="User notification",
|
||||
description="A notification sent to a user",
|
||||
value={
|
||||
"id": 1,
|
||||
"notification_type": "submission_approved",
|
||||
"title": "Your submission has been approved!",
|
||||
"message": "Your photo submission for Cedar Point has been approved and is now live on the site.",
|
||||
"priority": "normal",
|
||||
"is_read": False,
|
||||
"read_at": None,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"expires_at": None,
|
||||
"extra_data": {"submission_id": 123, "park_name": "Cedar Point"},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserNotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user notifications."""
|
||||
|
||||
class Meta:
|
||||
model = UserNotification
|
||||
fields = [
|
||||
"id",
|
||||
"notification_type",
|
||||
"title",
|
||||
"message",
|
||||
"priority",
|
||||
"is_read",
|
||||
"read_at",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"extra_data",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"notification_type",
|
||||
"title",
|
||||
"message",
|
||||
"priority",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"extra_data",
|
||||
]
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Notification Preferences Example",
|
||||
summary="User notification preferences",
|
||||
description="Comprehensive notification preferences for all channels",
|
||||
value={
|
||||
"submission_approved_email": True,
|
||||
"submission_approved_push": True,
|
||||
"submission_approved_inapp": True,
|
||||
"submission_rejected_email": True,
|
||||
"submission_rejected_push": True,
|
||||
"submission_rejected_inapp": True,
|
||||
"submission_pending_email": False,
|
||||
"submission_pending_push": False,
|
||||
"submission_pending_inapp": True,
|
||||
"review_reply_email": True,
|
||||
"review_reply_push": True,
|
||||
"review_reply_inapp": True,
|
||||
"review_helpful_email": False,
|
||||
"review_helpful_push": True,
|
||||
"review_helpful_inapp": True,
|
||||
"friend_request_email": True,
|
||||
"friend_request_push": True,
|
||||
"friend_request_inapp": True,
|
||||
"friend_accepted_email": False,
|
||||
"friend_accepted_push": True,
|
||||
"friend_accepted_inapp": True,
|
||||
"message_received_email": True,
|
||||
"message_received_push": True,
|
||||
"message_received_inapp": True,
|
||||
"system_announcement_email": True,
|
||||
"system_announcement_push": False,
|
||||
"system_announcement_inapp": True,
|
||||
"account_security_email": True,
|
||||
"account_security_push": True,
|
||||
"account_security_inapp": True,
|
||||
"feature_update_email": True,
|
||||
"feature_update_push": False,
|
||||
"feature_update_inapp": True,
|
||||
"achievement_unlocked_email": False,
|
||||
"achievement_unlocked_push": True,
|
||||
"achievement_unlocked_inapp": True,
|
||||
"milestone_reached_email": False,
|
||||
"milestone_reached_push": True,
|
||||
"milestone_reached_inapp": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class NotificationPreferenceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notification preferences."""
|
||||
|
||||
class Meta:
|
||||
model = NotificationPreference
|
||||
fields = [
|
||||
# Submission notifications
|
||||
"submission_approved_email",
|
||||
"submission_approved_push",
|
||||
"submission_approved_inapp",
|
||||
"submission_rejected_email",
|
||||
"submission_rejected_push",
|
||||
"submission_rejected_inapp",
|
||||
"submission_pending_email",
|
||||
"submission_pending_push",
|
||||
"submission_pending_inapp",
|
||||
# Review notifications
|
||||
"review_reply_email",
|
||||
"review_reply_push",
|
||||
"review_reply_inapp",
|
||||
"review_helpful_email",
|
||||
"review_helpful_push",
|
||||
"review_helpful_inapp",
|
||||
# Social notifications
|
||||
"friend_request_email",
|
||||
"friend_request_push",
|
||||
"friend_request_inapp",
|
||||
"friend_accepted_email",
|
||||
"friend_accepted_push",
|
||||
"friend_accepted_inapp",
|
||||
"message_received_email",
|
||||
"message_received_push",
|
||||
"message_received_inapp",
|
||||
# System notifications
|
||||
"system_announcement_email",
|
||||
"system_announcement_push",
|
||||
"system_announcement_inapp",
|
||||
"account_security_email",
|
||||
"account_security_push",
|
||||
"account_security_inapp",
|
||||
"feature_update_email",
|
||||
"feature_update_push",
|
||||
"feature_update_inapp",
|
||||
# Achievement notifications
|
||||
"achievement_unlocked_email",
|
||||
"achievement_unlocked_push",
|
||||
"achievement_unlocked_inapp",
|
||||
"milestone_reached_email",
|
||||
"milestone_reached_push",
|
||||
"milestone_reached_inapp",
|
||||
]
|
||||
|
||||
|
||||
# === NOTIFICATION ACTIONS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Mark Notifications Read Example",
|
||||
summary="Mark notifications as read",
|
||||
description="Mark specific notifications as read",
|
||||
value={"notification_ids": [1, 2, 3, 4, 5]},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MarkNotificationsReadSerializer(serializers.Serializer):
|
||||
"""Serializer for marking notifications as read."""
|
||||
|
||||
notification_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of notification IDs to mark as read",
|
||||
)
|
||||
|
||||
def validate_notification_ids(self, value):
|
||||
"""Validate that all notification IDs belong to the requesting user."""
|
||||
user = self.context["request"].user
|
||||
valid_ids = UserNotification.objects.filter(
|
||||
id__in=value, user=user
|
||||
).values_list("id", flat=True)
|
||||
|
||||
invalid_ids = set(value) - set(valid_ids)
|
||||
if invalid_ids:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid notification IDs: {list(invalid_ids)}"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Avatar Upload Example",
|
||||
summary="Upload user avatar",
|
||||
description="Upload a new avatar image",
|
||||
value={"avatar": "base64_encoded_image_data_or_file_upload"},
|
||||
)
|
||||
]
|
||||
)
|
||||
class AvatarUploadSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for uploading user avatar."""
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ["avatar"]
|
||||
|
||||
def validate_avatar(self, value):
|
||||
"""Validate avatar file."""
|
||||
if value:
|
||||
# Add any avatar-specific validation here
|
||||
# The CloudflareImagesField will handle the upload
|
||||
pass
|
||||
return value
|
||||
@@ -64,7 +64,7 @@ class MapLocationSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get location information."""
|
||||
if hasattr(obj, 'location') and obj.location:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
@@ -76,16 +76,20 @@ class MapLocationSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_stats(self, obj) -> dict:
|
||||
"""Get relevant statistics based on object type."""
|
||||
if obj._meta.model_name == 'park':
|
||||
if obj._meta.model_name == "park":
|
||||
return {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
}
|
||||
elif obj._meta.model_name == 'ride':
|
||||
elif obj._meta.model_name == "ride":
|
||||
return {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name if obj.park else None,
|
||||
}
|
||||
return {}
|
||||
@@ -210,7 +214,7 @@ class MapSearchResultSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get location information."""
|
||||
if hasattr(obj, 'location') and obj.location:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
@@ -318,7 +322,7 @@ class MapLocationDetailSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get detailed location information."""
|
||||
if hasattr(obj, 'location') and obj.location:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"street_address": obj.location.street_address,
|
||||
"city": obj.location.city,
|
||||
@@ -332,20 +336,28 @@ class MapLocationDetailSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_stats(self, obj) -> dict:
|
||||
"""Get detailed statistics based on object type."""
|
||||
if obj._meta.model_name == 'park':
|
||||
if obj._meta.model_name == "park":
|
||||
return {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"size_acres": float(obj.size_acres) if obj.size_acres else None,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
}
|
||||
elif obj._meta.model_name == 'ride':
|
||||
elif obj._meta.model_name == "ride":
|
||||
return {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name if obj.park else None,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
|
||||
}
|
||||
return {}
|
||||
@@ -370,13 +382,14 @@ class MapBoundsInputSerializer(serializers.Serializer):
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that bounds make geographic sense."""
|
||||
if attrs['north'] <= attrs['south']:
|
||||
if attrs["north"] <= attrs["south"]:
|
||||
raise serializers.ValidationError(
|
||||
"North bound must be greater than south bound")
|
||||
"North bound must be greater than south bound"
|
||||
)
|
||||
|
||||
# Handle longitude wraparound (e.g., crossing the international date line)
|
||||
# For now, we'll require west < east for simplicity
|
||||
if attrs['west'] >= attrs['east']:
|
||||
if attrs["west"] >= attrs["east"]:
|
||||
raise serializers.ValidationError("West bound must be less than east bound")
|
||||
|
||||
return attrs
|
||||
@@ -396,8 +409,8 @@ class MapSearchInputSerializer(serializers.Serializer):
|
||||
if not value:
|
||||
return []
|
||||
|
||||
valid_types = ['park', 'ride']
|
||||
types = [t.strip().lower() for t in value.split(',')]
|
||||
valid_types = ["park", "ride"]
|
||||
types = [t.strip().lower() for t in value.split(",")]
|
||||
|
||||
for location_type in types:
|
||||
if location_type not in valid_types:
|
||||
|
||||
@@ -113,10 +113,10 @@ class ParkListOutputSerializer(serializers.Serializer):
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public",
|
||||
},
|
||||
"caption": "Beautiful park entrance",
|
||||
"is_primary": True
|
||||
"is_primary": True,
|
||||
}
|
||||
],
|
||||
"primary_photo": {
|
||||
@@ -126,10 +126,10 @@ class ParkListOutputSerializer(serializers.Serializer):
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public",
|
||||
},
|
||||
"caption": "Beautiful park entrance"
|
||||
}
|
||||
"caption": "Beautiful park entrance",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -203,21 +203,28 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
"""Get all approved photos for this park."""
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
photos = ParkPhoto.objects.filter(
|
||||
park=obj,
|
||||
is_approved=True
|
||||
).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos
|
||||
photos = ParkPhoto.objects.filter(park=obj, is_approved=True).order_by(
|
||||
"-is_primary", "-created_at"
|
||||
)[
|
||||
:10
|
||||
] # Limit to 10 photos
|
||||
|
||||
return [
|
||||
{
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url if photo.image else None,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None,
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
} if photo.image else {},
|
||||
"image_variants": (
|
||||
{
|
||||
"thumbnail": (
|
||||
f"{photo.image.url}/thumbnail" if photo.image else None
|
||||
),
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
}
|
||||
if photo.image
|
||||
else {}
|
||||
),
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
@@ -232,9 +239,7 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
try:
|
||||
photo = ParkPhoto.objects.filter(
|
||||
park=obj,
|
||||
is_primary=True,
|
||||
is_approved=True
|
||||
park=obj, is_primary=True, is_approved=True
|
||||
).first()
|
||||
|
||||
if photo and photo.image:
|
||||
@@ -275,12 +280,15 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
latest_photo = ParkPhoto.objects.filter(
|
||||
park=obj,
|
||||
is_approved=True,
|
||||
image__isnull=False
|
||||
).order_by('-created_at').first()
|
||||
latest_photo = (
|
||||
ParkPhoto.objects.filter(
|
||||
park=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
@@ -321,12 +329,15 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
latest_photo = ParkPhoto.objects.filter(
|
||||
park=obj,
|
||||
is_approved=True,
|
||||
image__isnull=False
|
||||
).order_by('-created_at').first()
|
||||
latest_photo = (
|
||||
ParkPhoto.objects.filter(
|
||||
park=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
@@ -362,6 +373,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Validate that the banner image belongs to the same park."""
|
||||
if value is not None:
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
photo = ParkPhoto.objects.get(id=value)
|
||||
# The park will be validated in the view
|
||||
@@ -374,6 +386,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Validate that the card image belongs to the same park."""
|
||||
if value is not None:
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
photo = ParkPhoto.objects.get(id=value)
|
||||
# The park will be validated in the view
|
||||
|
||||
@@ -10,16 +10,17 @@ from apps.accounts.models import User
|
||||
|
||||
class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user information in reviews."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'display_name', 'avatar_url']
|
||||
fields = ["username", "display_name", "avatar_url"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the user's avatar URL."""
|
||||
if hasattr(obj, 'profile') and obj.profile:
|
||||
if hasattr(obj, "profile") and obj.profile:
|
||||
return obj.profile.get_avatar()
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
@@ -30,6 +31,7 @@ class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class LatestReviewSerializer(serializers.Serializer):
|
||||
"""Serializer for latest reviews combining park and ride reviews."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField() # 'park' or 'ride'
|
||||
title = serializers.CharField()
|
||||
@@ -52,35 +54,35 @@ class LatestReviewSerializer(serializers.Serializer):
|
||||
"""Convert review instance to serialized representation."""
|
||||
if isinstance(instance, ParkReview):
|
||||
return {
|
||||
'id': instance.pk,
|
||||
'type': 'park',
|
||||
'title': instance.title,
|
||||
'content_snippet': self._get_content_snippet(instance.content),
|
||||
'rating': instance.rating,
|
||||
'created_at': instance.created_at,
|
||||
'user': ReviewUserSerializer(instance.user).data,
|
||||
'subject_name': instance.park.name,
|
||||
'subject_slug': instance.park.slug,
|
||||
'subject_url': f"/parks/{instance.park.slug}/",
|
||||
'park_name': None,
|
||||
'park_slug': None,
|
||||
'park_url': None,
|
||||
"id": instance.pk,
|
||||
"type": "park",
|
||||
"title": instance.title,
|
||||
"content_snippet": self._get_content_snippet(instance.content),
|
||||
"rating": instance.rating,
|
||||
"created_at": instance.created_at,
|
||||
"user": ReviewUserSerializer(instance.user).data,
|
||||
"subject_name": instance.park.name,
|
||||
"subject_slug": instance.park.slug,
|
||||
"subject_url": f"/parks/{instance.park.slug}/",
|
||||
"park_name": None,
|
||||
"park_slug": None,
|
||||
"park_url": None,
|
||||
}
|
||||
elif isinstance(instance, RideReview):
|
||||
return {
|
||||
'id': instance.pk,
|
||||
'type': 'ride',
|
||||
'title': instance.title,
|
||||
'content_snippet': self._get_content_snippet(instance.content),
|
||||
'rating': instance.rating,
|
||||
'created_at': instance.created_at,
|
||||
'user': ReviewUserSerializer(instance.user).data,
|
||||
'subject_name': instance.ride.name,
|
||||
'subject_slug': instance.ride.slug,
|
||||
'subject_url': f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
|
||||
'park_name': instance.ride.park.name,
|
||||
'park_slug': instance.ride.park.slug,
|
||||
'park_url': f"/parks/{instance.ride.park.slug}/",
|
||||
"id": instance.pk,
|
||||
"type": "ride",
|
||||
"title": instance.title,
|
||||
"content_snippet": self._get_content_snippet(instance.content),
|
||||
"rating": instance.rating,
|
||||
"created_at": instance.created_at,
|
||||
"user": ReviewUserSerializer(instance.user).data,
|
||||
"subject_name": instance.ride.name,
|
||||
"subject_slug": instance.ride.slug,
|
||||
"subject_url": f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
|
||||
"park_name": instance.ride.park.name,
|
||||
"park_slug": instance.ride.park.slug,
|
||||
"park_url": f"/parks/{instance.ride.park.slug}/",
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -91,7 +93,7 @@ class LatestReviewSerializer(serializers.Serializer):
|
||||
|
||||
# Find the last complete word within the limit
|
||||
snippet = content[:max_length]
|
||||
last_space = snippet.rfind(' ')
|
||||
last_space = snippet.rfind(" ")
|
||||
if last_space > 0:
|
||||
snippet = snippet[:last_space]
|
||||
|
||||
|
||||
@@ -20,7 +20,13 @@ from .shared import ModelChoices
|
||||
|
||||
def get_ride_model_classes():
|
||||
"""Get ride model classes dynamically to avoid import issues."""
|
||||
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
from apps.rides.models import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
|
||||
return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
|
||||
|
||||
@@ -73,13 +79,17 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
min_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True)
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
max_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True)
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
min_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True)
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
max_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True)
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
distinguishing_features = serializers.CharField()
|
||||
|
||||
|
||||
@@ -98,7 +108,7 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard"
|
||||
"slug": "bolliger-mabillard",
|
||||
},
|
||||
"target_market": "THRILL",
|
||||
"is_discontinued": False,
|
||||
@@ -110,8 +120,8 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
|
||||
"id": 123,
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"caption": "B&M Hyper Coaster",
|
||||
"photo_type": "PROMOTIONAL"
|
||||
}
|
||||
"photo_type": "PROMOTIONAL",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -171,7 +181,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard"
|
||||
"slug": "bolliger-mabillard",
|
||||
},
|
||||
"typical_height_range_min_ft": 200.0,
|
||||
"typical_height_range_max_ft": 325.0,
|
||||
@@ -194,7 +204,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"caption": "B&M Hyper Coaster",
|
||||
"photo_type": "PROMOTIONAL",
|
||||
"is_primary": True
|
||||
"is_primary": True,
|
||||
}
|
||||
],
|
||||
"variants": [
|
||||
@@ -203,7 +213,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"name": "Mega Coaster",
|
||||
"description": "200-299 ft height variant",
|
||||
"min_height_ft": 200.0,
|
||||
"max_height_ft": 299.0
|
||||
"max_height_ft": 299.0,
|
||||
}
|
||||
],
|
||||
"technical_specs": [
|
||||
@@ -212,7 +222,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"spec_category": "DIMENSIONS",
|
||||
"spec_name": "Track Width",
|
||||
"spec_value": "1435",
|
||||
"spec_unit": "mm"
|
||||
"spec_unit": "mm",
|
||||
}
|
||||
],
|
||||
"installations": [
|
||||
@@ -220,9 +230,9 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"id": 1,
|
||||
"name": "Nitro",
|
||||
"park_name": "Six Flags Great Adventure",
|
||||
"opening_date": "2001-04-07"
|
||||
"opening_date": "2001-04-07",
|
||||
}
|
||||
]
|
||||
],
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -302,9 +312,10 @@ class RideModelDetailOutputSerializer(serializers.Serializer):
|
||||
def get_installations(self, obj):
|
||||
"""Get ride installations using this model."""
|
||||
from django.apps import apps
|
||||
Ride = apps.get_model('rides', 'Ride')
|
||||
|
||||
installations = Ride.objects.filter(ride_model=obj).select_related('park')[:10]
|
||||
Ride = apps.get_model("rides", "Ride")
|
||||
|
||||
installations = Ride.objects.filter(ride_model=obj).select_related("park")[:10]
|
||||
return [
|
||||
{
|
||||
"id": ride.id,
|
||||
@@ -325,9 +336,7 @@ class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(),
|
||||
allow_blank=True,
|
||||
default=""
|
||||
choices=ModelChoices.get_ride_category_choices(), allow_blank=True, default=""
|
||||
)
|
||||
|
||||
# Required manufacturer
|
||||
@@ -356,11 +365,14 @@ class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
# Design characteristics
|
||||
track_type = serializers.CharField(max_length=100, allow_blank=True, default="")
|
||||
support_structure = serializers.CharField(
|
||||
max_length=100, allow_blank=True, default="")
|
||||
max_length=100, allow_blank=True, default=""
|
||||
)
|
||||
train_configuration = serializers.CharField(
|
||||
max_length=200, allow_blank=True, default="")
|
||||
max_length=200, allow_blank=True, default=""
|
||||
)
|
||||
restraint_system = serializers.CharField(
|
||||
max_length=100, allow_blank=True, default="")
|
||||
max_length=100, allow_blank=True, default=""
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = serializers.IntegerField(
|
||||
@@ -375,14 +387,14 @@ class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
notable_features = serializers.CharField(allow_blank=True, default="")
|
||||
target_market = serializers.ChoiceField(
|
||||
choices=[
|
||||
('FAMILY', 'Family'),
|
||||
('THRILL', 'Thrill'),
|
||||
('EXTREME', 'Extreme'),
|
||||
('KIDDIE', 'Kiddie'),
|
||||
('ALL_AGES', 'All Ages'),
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
allow_blank=True,
|
||||
default=""
|
||||
default="",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -434,7 +446,7 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(),
|
||||
allow_blank=True,
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Manufacturer
|
||||
@@ -463,11 +475,14 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
# Design characteristics
|
||||
track_type = serializers.CharField(max_length=100, allow_blank=True, required=False)
|
||||
support_structure = serializers.CharField(
|
||||
max_length=100, allow_blank=True, required=False)
|
||||
max_length=100, allow_blank=True, required=False
|
||||
)
|
||||
train_configuration = serializers.CharField(
|
||||
max_length=200, allow_blank=True, required=False)
|
||||
max_length=200, allow_blank=True, required=False
|
||||
)
|
||||
restraint_system = serializers.CharField(
|
||||
max_length=100, allow_blank=True, required=False)
|
||||
max_length=100, allow_blank=True, required=False
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = serializers.IntegerField(
|
||||
@@ -482,14 +497,14 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
notable_features = serializers.CharField(allow_blank=True, required=False)
|
||||
target_market = serializers.ChoiceField(
|
||||
choices=[
|
||||
('FAMILY', 'Family'),
|
||||
('THRILL', 'Thrill'),
|
||||
('EXTREME', 'Extreme'),
|
||||
('KIDDIE', 'Kiddie'),
|
||||
('ALL_AGES', 'All Ages'),
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
allow_blank=True,
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -541,8 +556,7 @@ class RideModelFilterInputSerializer(serializers.Serializer):
|
||||
|
||||
# Category filter
|
||||
category = serializers.MultipleChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(),
|
||||
required=False
|
||||
choices=ModelChoices.get_ride_category_choices(), required=False
|
||||
)
|
||||
|
||||
# Manufacturer filter
|
||||
@@ -552,13 +566,13 @@ class RideModelFilterInputSerializer(serializers.Serializer):
|
||||
# Market filter
|
||||
target_market = serializers.MultipleChoiceField(
|
||||
choices=[
|
||||
('FAMILY', 'Family'),
|
||||
('THRILL', 'Thrill'),
|
||||
('EXTREME', 'Extreme'),
|
||||
('KIDDIE', 'Kiddie'),
|
||||
('ALL_AGES', 'All Ages'),
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Status filter
|
||||
@@ -711,14 +725,14 @@ class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
|
||||
ride_model_id = serializers.IntegerField()
|
||||
spec_category = serializers.ChoiceField(
|
||||
choices=[
|
||||
('DIMENSIONS', 'Dimensions'),
|
||||
('PERFORMANCE', 'Performance'),
|
||||
('CAPACITY', 'Capacity'),
|
||||
('SAFETY', 'Safety Features'),
|
||||
('ELECTRICAL', 'Electrical Requirements'),
|
||||
('FOUNDATION', 'Foundation Requirements'),
|
||||
('MAINTENANCE', 'Maintenance'),
|
||||
('OTHER', 'Other'),
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
]
|
||||
)
|
||||
spec_name = serializers.CharField(max_length=100)
|
||||
@@ -732,16 +746,16 @@ class RideModelTechnicalSpecUpdateInputSerializer(serializers.Serializer):
|
||||
|
||||
spec_category = serializers.ChoiceField(
|
||||
choices=[
|
||||
('DIMENSIONS', 'Dimensions'),
|
||||
('PERFORMANCE', 'Performance'),
|
||||
('CAPACITY', 'Capacity'),
|
||||
('SAFETY', 'Safety Features'),
|
||||
('ELECTRICAL', 'Electrical Requirements'),
|
||||
('FOUNDATION', 'Foundation Requirements'),
|
||||
('MAINTENANCE', 'Maintenance'),
|
||||
('OTHER', 'Other'),
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
spec_name = serializers.CharField(max_length=100, required=False)
|
||||
spec_value = serializers.CharField(max_length=255, required=False)
|
||||
@@ -761,13 +775,13 @@ class RideModelPhotoCreateInputSerializer(serializers.Serializer):
|
||||
alt_text = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
('PROMOTIONAL', 'Promotional'),
|
||||
('TECHNICAL', 'Technical Drawing'),
|
||||
('INSTALLATION', 'Installation Example'),
|
||||
('RENDERING', '3D Rendering'),
|
||||
('CATALOG', 'Catalog Image'),
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default='PROMOTIONAL'
|
||||
default="PROMOTIONAL",
|
||||
)
|
||||
is_primary = serializers.BooleanField(default=False)
|
||||
photographer = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
@@ -782,20 +796,22 @@ class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
|
||||
alt_text = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
('PROMOTIONAL', 'Promotional'),
|
||||
('TECHNICAL', 'Technical Drawing'),
|
||||
('INSTALLATION', 'Installation Example'),
|
||||
('RENDERING', '3D Rendering'),
|
||||
('CATALOG', 'Catalog Image'),
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
is_primary = serializers.BooleanField(required=False)
|
||||
photographer = serializers.CharField(
|
||||
max_length=255, allow_blank=True, required=False)
|
||||
max_length=255, allow_blank=True, required=False
|
||||
)
|
||||
source = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
copyright_info = serializers.CharField(
|
||||
max_length=255, allow_blank=True, required=False)
|
||||
max_length=255, allow_blank=True, required=False
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL STATS SERIALIZERS ===
|
||||
@@ -809,16 +825,13 @@ class RideModelStatsOutputSerializer(serializers.Serializer):
|
||||
active_manufacturers = serializers.IntegerField()
|
||||
discontinued_models = serializers.IntegerField()
|
||||
by_category = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Model counts by category"
|
||||
child=serializers.IntegerField(), help_text="Model counts by category"
|
||||
)
|
||||
by_target_market = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Model counts by target market"
|
||||
child=serializers.IntegerField(), help_text="Model counts by target market"
|
||||
)
|
||||
by_manufacturer = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Model counts by manufacturer"
|
||||
child=serializers.IntegerField(), help_text="Model counts by manufacturer"
|
||||
)
|
||||
recent_models = serializers.IntegerField(
|
||||
help_text="Models created in the last 30 days"
|
||||
|
||||
@@ -135,11 +135,11 @@ class RideListOutputSerializer(serializers.Serializer):
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"is_primary": True,
|
||||
"photo_type": "exterior"
|
||||
"photo_type": "exterior",
|
||||
}
|
||||
],
|
||||
"primary_photo": {
|
||||
@@ -149,11 +149,11 @@ class RideListOutputSerializer(serializers.Serializer):
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"photo_type": "exterior"
|
||||
}
|
||||
"photo_type": "exterior",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -249,21 +249,28 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
"""Get all approved photos for this ride."""
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
photos = RidePhoto.objects.filter(
|
||||
ride=obj,
|
||||
is_approved=True
|
||||
).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos
|
||||
photos = RidePhoto.objects.filter(ride=obj, is_approved=True).order_by(
|
||||
"-is_primary", "-created_at"
|
||||
)[
|
||||
:10
|
||||
] # Limit to 10 photos
|
||||
|
||||
return [
|
||||
{
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url if photo.image else None,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None,
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
} if photo.image else {},
|
||||
"image_variants": (
|
||||
{
|
||||
"thumbnail": (
|
||||
f"{photo.image.url}/thumbnail" if photo.image else None
|
||||
),
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
}
|
||||
if photo.image
|
||||
else {}
|
||||
),
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
@@ -279,9 +286,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
try:
|
||||
photo = RidePhoto.objects.filter(
|
||||
ride=obj,
|
||||
is_primary=True,
|
||||
is_approved=True
|
||||
ride=obj, is_primary=True, is_approved=True
|
||||
).first()
|
||||
|
||||
if photo and photo.image:
|
||||
@@ -324,12 +329,15 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
latest_photo = RidePhoto.objects.filter(
|
||||
ride=obj,
|
||||
is_approved=True,
|
||||
image__isnull=False
|
||||
).order_by('-created_at').first()
|
||||
latest_photo = (
|
||||
RidePhoto.objects.filter(
|
||||
ride=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
@@ -372,12 +380,15 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
latest_photo = RidePhoto.objects.filter(
|
||||
ride=obj,
|
||||
is_approved=True,
|
||||
image__isnull=False
|
||||
).order_by('-created_at').first()
|
||||
latest_photo = (
|
||||
RidePhoto.objects.filter(
|
||||
ride=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
@@ -410,6 +421,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Validate that the banner image belongs to the same ride."""
|
||||
if value is not None:
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
photo = RidePhoto.objects.get(id=value)
|
||||
# The ride will be validated in the view
|
||||
@@ -422,6 +434,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Validate that the card image belongs to the same ride."""
|
||||
if value is not None:
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
photo = RidePhoto.objects.get(id=value)
|
||||
# The ride will be validated in the view
|
||||
|
||||
@@ -185,19 +185,20 @@ class CompanyOutputSerializer(serializers.Serializer):
|
||||
- MANUFACTURER and DESIGNER are for rides domain
|
||||
"""
|
||||
# Use the URL field from the model if it exists (auto-generated on save)
|
||||
if hasattr(obj, 'url') and obj.url:
|
||||
if hasattr(obj, "url") and obj.url:
|
||||
return obj.url
|
||||
|
||||
# Fallback URL generation (should not be needed if model save works correctly)
|
||||
if hasattr(obj, 'roles') and obj.roles:
|
||||
if hasattr(obj, "roles") and obj.roles:
|
||||
frontend_domain = getattr(
|
||||
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
primary_role = obj.roles[0] if obj.roles else None
|
||||
|
||||
# Only generate URLs for rides domain roles here
|
||||
if primary_role == 'MANUFACTURER':
|
||||
if primary_role == "MANUFACTURER":
|
||||
return f"{frontend_domain}/rides/manufacturers/{obj.slug}/"
|
||||
elif primary_role == 'DESIGNER':
|
||||
elif primary_role == "DESIGNER":
|
||||
return f"{frontend_domain}/rides/designers/{obj.slug}/"
|
||||
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain
|
||||
|
||||
|
||||
@@ -62,88 +62,68 @@ class StatsSerializer(serializers.Serializer):
|
||||
|
||||
# Ride category counts (optional fields since they depend on data)
|
||||
roller_coasters = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as roller coasters"
|
||||
required=False, help_text="Number of rides categorized as roller coasters"
|
||||
)
|
||||
dark_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as dark rides"
|
||||
required=False, help_text="Number of rides categorized as dark rides"
|
||||
)
|
||||
flat_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as flat rides"
|
||||
required=False, help_text="Number of rides categorized as flat rides"
|
||||
)
|
||||
water_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as water rides"
|
||||
required=False, help_text="Number of rides categorized as water rides"
|
||||
)
|
||||
transport_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as transport rides"
|
||||
required=False, help_text="Number of rides categorized as transport rides"
|
||||
)
|
||||
other_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as other"
|
||||
required=False, help_text="Number of rides categorized as other"
|
||||
)
|
||||
|
||||
# Park status counts (optional fields since they depend on data)
|
||||
operating_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of currently operating parks"
|
||||
required=False, help_text="Number of currently operating parks"
|
||||
)
|
||||
temporarily_closed_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of temporarily closed parks"
|
||||
required=False, help_text="Number of temporarily closed parks"
|
||||
)
|
||||
permanently_closed_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of permanently closed parks"
|
||||
required=False, help_text="Number of permanently closed parks"
|
||||
)
|
||||
under_construction_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of parks under construction"
|
||||
required=False, help_text="Number of parks under construction"
|
||||
)
|
||||
demolished_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of demolished parks"
|
||||
required=False, help_text="Number of demolished parks"
|
||||
)
|
||||
relocated_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of relocated parks"
|
||||
required=False, help_text="Number of relocated parks"
|
||||
)
|
||||
|
||||
# Ride status counts (optional fields since they depend on data)
|
||||
operating_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of currently operating rides"
|
||||
required=False, help_text="Number of currently operating rides"
|
||||
)
|
||||
temporarily_closed_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of temporarily closed rides"
|
||||
required=False, help_text="Number of temporarily closed rides"
|
||||
)
|
||||
sbno_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides standing but not operating"
|
||||
required=False, help_text="Number of rides standing but not operating"
|
||||
)
|
||||
closing_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides in the process of closing"
|
||||
required=False, help_text="Number of rides in the process of closing"
|
||||
)
|
||||
permanently_closed_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of permanently closed rides"
|
||||
required=False, help_text="Number of permanently closed rides"
|
||||
)
|
||||
under_construction_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides under construction"
|
||||
required=False, help_text="Number of rides under construction"
|
||||
)
|
||||
demolished_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of demolished rides"
|
||||
required=False, help_text="Number of demolished rides"
|
||||
)
|
||||
relocated_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of relocated rides"
|
||||
required=False, help_text="Number of relocated rides"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
|
||||
@@ -10,7 +10,13 @@ from django.dispatch import receiver
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
|
||||
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
|
||||
from apps.rides.models import (
|
||||
Ride,
|
||||
RollerCoasterStats,
|
||||
RideReview,
|
||||
RidePhoto,
|
||||
Company as RideCompany,
|
||||
)
|
||||
|
||||
|
||||
def invalidate_stats_cache():
|
||||
@@ -23,6 +29,7 @@ def invalidate_stats_cache():
|
||||
cache.delete("platform_stats")
|
||||
# Also update the timestamp for when stats were last invalidated
|
||||
from datetime import datetime
|
||||
|
||||
cache.set("platform_stats_timestamp", datetime.now().isoformat(), 300)
|
||||
|
||||
|
||||
|
||||
@@ -61,11 +61,18 @@ urlpatterns = [
|
||||
# Trending system endpoints
|
||||
path("trending/", TrendingAPIView.as_view(), name="trending"),
|
||||
path("new-content/", NewContentAPIView.as_view(), name="new-content"),
|
||||
path("trending/calculate/", TriggerTrendingCalculationAPIView.as_view(),
|
||||
name="trigger-trending-calculation"),
|
||||
path(
|
||||
"trending/calculate/",
|
||||
TriggerTrendingCalculationAPIView.as_view(),
|
||||
name="trigger-trending-calculation",
|
||||
),
|
||||
# Statistics endpoints
|
||||
path("stats/", StatsAPIView.as_view(), name="stats"),
|
||||
path("stats/recalculate/", StatsRecalculateAPIView.as_view(), name="stats-recalculate"),
|
||||
path(
|
||||
"stats/recalculate/",
|
||||
StatsRecalculateAPIView.as_view(),
|
||||
name="stats-recalculate",
|
||||
),
|
||||
# Reviews endpoints
|
||||
path("reviews/latest/", LatestReviewsAPIView.as_view(), name="latest-reviews"),
|
||||
# Ranking system endpoints
|
||||
@@ -82,6 +89,7 @@ urlpatterns = [
|
||||
path("email/", include("apps.api.v1.email.urls")),
|
||||
path("core/", include("apps.api.v1.core.urls")),
|
||||
path("maps/", include("apps.api.v1.maps.urls")),
|
||||
path("moderation/", include("apps.moderation.urls")),
|
||||
# Include router URLs (for rankings and any other router-registered endpoints)
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -372,7 +372,11 @@ class SocialProvidersAPIView(APIView):
|
||||
"code": "SOCIAL_PROVIDERS_ERROR",
|
||||
"message": "Unable to retrieve social providers",
|
||||
"details": str(e) if str(e) else None,
|
||||
"request_user": str(request.user) if hasattr(request, 'user') else "AnonymousUser",
|
||||
"request_user": (
|
||||
str(request.user)
|
||||
if hasattr(request, "user")
|
||||
else "AnonymousUser"
|
||||
),
|
||||
},
|
||||
"data": None,
|
||||
},
|
||||
|
||||
@@ -107,7 +107,7 @@ class HealthCheckAPIView(APIView):
|
||||
# Process individual health checks
|
||||
for plugin in plugins:
|
||||
# Handle both plugin objects and strings
|
||||
if hasattr(plugin, 'identifier'):
|
||||
if hasattr(plugin, "identifier"):
|
||||
plugin_name = plugin.identifier()
|
||||
plugin_class_name = plugin.__class__.__name__
|
||||
critical_service = getattr(plugin, "critical_service", False)
|
||||
@@ -120,9 +120,7 @@ class HealthCheckAPIView(APIView):
|
||||
response_time = None
|
||||
|
||||
plugin_errors = (
|
||||
errors.get(plugin_class_name, [])
|
||||
if isinstance(errors, dict)
|
||||
else []
|
||||
errors.get(plugin_class_name, []) if isinstance(errors, dict) else []
|
||||
)
|
||||
|
||||
health_data["checks"][plugin_name] = {
|
||||
|
||||
@@ -6,7 +6,6 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework import status
|
||||
from django.db.models import Q
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from itertools import chain
|
||||
@@ -24,6 +23,7 @@ class LatestReviewsAPIView(APIView):
|
||||
Returns a combined list of the most recent reviews across the platform,
|
||||
including username, user avatar, date, score, and review snippet.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
@@ -51,35 +51,35 @@ class LatestReviewsAPIView(APIView):
|
||||
"""Get the latest reviews from both parks and rides."""
|
||||
# Get limit parameter with validation
|
||||
try:
|
||||
limit = int(request.query_params.get('limit', 20))
|
||||
limit = int(request.query_params.get("limit", 20))
|
||||
limit = min(max(limit, 1), 100) # Clamp between 1 and 100
|
||||
except (ValueError, TypeError):
|
||||
limit = 20
|
||||
|
||||
# Get published reviews from both models
|
||||
park_reviews = ParkReview.objects.filter(
|
||||
is_published=True
|
||||
).select_related(
|
||||
'user', 'user__profile', 'park'
|
||||
).order_by('-created_at')[:limit]
|
||||
park_reviews = (
|
||||
ParkReview.objects.filter(is_published=True)
|
||||
.select_related("user", "user__profile", "park")
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
ride_reviews = RideReview.objects.filter(
|
||||
is_published=True
|
||||
).select_related(
|
||||
'user', 'user__profile', 'ride', 'ride__park'
|
||||
).order_by('-created_at')[:limit]
|
||||
ride_reviews = (
|
||||
RideReview.objects.filter(is_published=True)
|
||||
.select_related("user", "user__profile", "ride", "ride__park")
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
# Combine and sort by created_at
|
||||
all_reviews = sorted(
|
||||
chain(park_reviews, ride_reviews),
|
||||
key=attrgetter('created_at'),
|
||||
reverse=True
|
||||
key=attrgetter("created_at"),
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
|
||||
# Serialize the combined results
|
||||
serializer = LatestReviewSerializer(all_reviews, many=True)
|
||||
|
||||
return Response({
|
||||
'count': len(all_reviews),
|
||||
'results': serializer.data
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{"count": len(all_reviews), "results": serializer.data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -9,14 +9,20 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, OpenApiExample
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
|
||||
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
|
||||
from apps.rides.models import (
|
||||
Ride,
|
||||
RollerCoasterStats,
|
||||
RideReview,
|
||||
RidePhoto,
|
||||
Company as RideCompany,
|
||||
)
|
||||
from ..serializers.stats import StatsSerializer
|
||||
|
||||
|
||||
@@ -40,13 +46,13 @@ class StatsAPIView(APIView):
|
||||
Returns:
|
||||
str: Human-readable relative time (e.g., "2 days, 3 hours, 15 minutes ago", "just now")
|
||||
"""
|
||||
if not timestamp_str or timestamp_str == 'just_now':
|
||||
return 'just now'
|
||||
if not timestamp_str or timestamp_str == "just_now":
|
||||
return "just now"
|
||||
|
||||
try:
|
||||
# Parse the ISO timestamp
|
||||
if isinstance(timestamp_str, str):
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
timestamp = timestamp_str
|
||||
|
||||
@@ -60,7 +66,7 @@ class StatsAPIView(APIView):
|
||||
|
||||
# If less than a minute, return "just now"
|
||||
if total_seconds < 60:
|
||||
return 'just now'
|
||||
return "just now"
|
||||
|
||||
# Calculate time components
|
||||
days = diff.days
|
||||
@@ -81,16 +87,16 @@ class StatsAPIView(APIView):
|
||||
|
||||
# Join parts with commas and add "ago"
|
||||
if len(parts) == 0:
|
||||
return 'just now'
|
||||
return "just now"
|
||||
elif len(parts) == 1:
|
||||
return f'{parts[0]} ago'
|
||||
return f"{parts[0]} ago"
|
||||
elif len(parts) == 2:
|
||||
return f'{parts[0]} and {parts[1]} ago'
|
||||
return f"{parts[0]} and {parts[1]} ago"
|
||||
else:
|
||||
return f'{", ".join(parts[:-1])}, and {parts[-1]} ago'
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return 'unknown'
|
||||
return "unknown"
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_platform_stats",
|
||||
@@ -115,9 +121,12 @@ class StatsAPIView(APIView):
|
||||
500: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {"type": "string", "description": "Error message if statistics calculation fails"}
|
||||
}
|
||||
}
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Error message if statistics calculation fails",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Statistics"],
|
||||
examples=[
|
||||
@@ -142,10 +151,10 @@ class StatsAPIView(APIView):
|
||||
"operating_parks": 7,
|
||||
"operating_rides": 10,
|
||||
"last_updated": "2025-08-28T17:34:59.677143+00:00",
|
||||
"relative_last_updated": "just now"
|
||||
}
|
||||
"relative_last_updated": "just now",
|
||||
},
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
def get(self, request):
|
||||
"""Get platform statistics."""
|
||||
@@ -197,76 +206,76 @@ class StatsAPIView(APIView):
|
||||
total_roller_coasters = RollerCoasterStats.objects.count()
|
||||
|
||||
# Ride category counts
|
||||
ride_categories = Ride.objects.values('category').annotate(
|
||||
count=Count('id')
|
||||
).exclude(category='')
|
||||
ride_categories = (
|
||||
Ride.objects.values("category")
|
||||
.annotate(count=Count("id"))
|
||||
.exclude(category="")
|
||||
)
|
||||
|
||||
category_stats = {}
|
||||
for category in ride_categories:
|
||||
category_code = category['category']
|
||||
category_count = category['count']
|
||||
category_code = category["category"]
|
||||
category_count = category["count"]
|
||||
|
||||
# Convert category codes to readable names
|
||||
category_names = {
|
||||
'RC': 'roller_coasters',
|
||||
'DR': 'dark_rides',
|
||||
'FR': 'flat_rides',
|
||||
'WR': 'water_rides',
|
||||
'TR': 'transport_rides',
|
||||
'OT': 'other_rides'
|
||||
"RC": "roller_coasters",
|
||||
"DR": "dark_rides",
|
||||
"FR": "flat_rides",
|
||||
"WR": "water_rides",
|
||||
"TR": "transport_rides",
|
||||
"OT": "other_rides",
|
||||
}
|
||||
|
||||
category_name = category_names.get(
|
||||
category_code, f'category_{category_code.lower()}')
|
||||
category_code, f"category_{category_code.lower()}"
|
||||
)
|
||||
category_stats[category_name] = category_count
|
||||
|
||||
# Park status counts
|
||||
park_statuses = Park.objects.values('status').annotate(
|
||||
count=Count('id')
|
||||
)
|
||||
park_statuses = Park.objects.values("status").annotate(count=Count("id"))
|
||||
|
||||
park_status_stats = {}
|
||||
for status_item in park_statuses:
|
||||
status_code = status_item['status']
|
||||
status_count = status_item['count']
|
||||
status_code = status_item["status"]
|
||||
status_count = status_item["count"]
|
||||
|
||||
# Convert status codes to readable names
|
||||
status_names = {
|
||||
'OPERATING': 'operating_parks',
|
||||
'CLOSED_TEMP': 'temporarily_closed_parks',
|
||||
'CLOSED_PERM': 'permanently_closed_parks',
|
||||
'UNDER_CONSTRUCTION': 'under_construction_parks',
|
||||
'DEMOLISHED': 'demolished_parks',
|
||||
'RELOCATED': 'relocated_parks'
|
||||
"OPERATING": "operating_parks",
|
||||
"CLOSED_TEMP": "temporarily_closed_parks",
|
||||
"CLOSED_PERM": "permanently_closed_parks",
|
||||
"UNDER_CONSTRUCTION": "under_construction_parks",
|
||||
"DEMOLISHED": "demolished_parks",
|
||||
"RELOCATED": "relocated_parks",
|
||||
}
|
||||
|
||||
status_name = status_names.get(status_code, f'status_{status_code.lower()}')
|
||||
status_name = status_names.get(status_code, f"status_{status_code.lower()}")
|
||||
park_status_stats[status_name] = status_count
|
||||
|
||||
# Ride status counts
|
||||
ride_statuses = Ride.objects.values('status').annotate(
|
||||
count=Count('id')
|
||||
)
|
||||
ride_statuses = Ride.objects.values("status").annotate(count=Count("id"))
|
||||
|
||||
ride_status_stats = {}
|
||||
for status_item in ride_statuses:
|
||||
status_code = status_item['status']
|
||||
status_count = status_item['count']
|
||||
status_code = status_item["status"]
|
||||
status_count = status_item["count"]
|
||||
|
||||
# Convert status codes to readable names
|
||||
status_names = {
|
||||
'OPERATING': 'operating_rides',
|
||||
'CLOSED_TEMP': 'temporarily_closed_rides',
|
||||
'SBNO': 'sbno_rides',
|
||||
'CLOSING': 'closing_rides',
|
||||
'CLOSED_PERM': 'permanently_closed_rides',
|
||||
'UNDER_CONSTRUCTION': 'under_construction_rides',
|
||||
'DEMOLISHED': 'demolished_rides',
|
||||
'RELOCATED': 'relocated_rides'
|
||||
"OPERATING": "operating_rides",
|
||||
"CLOSED_TEMP": "temporarily_closed_rides",
|
||||
"SBNO": "sbno_rides",
|
||||
"CLOSING": "closing_rides",
|
||||
"CLOSED_PERM": "permanently_closed_rides",
|
||||
"UNDER_CONSTRUCTION": "under_construction_rides",
|
||||
"DEMOLISHED": "demolished_rides",
|
||||
"RELOCATED": "relocated_rides",
|
||||
}
|
||||
|
||||
status_name = status_names.get(
|
||||
status_code, f'ride_status_{status_code.lower()}')
|
||||
status_code, f"ride_status_{status_code.lower()}"
|
||||
)
|
||||
ride_status_stats[status_name] = status_count
|
||||
|
||||
# Review counts
|
||||
@@ -279,13 +288,13 @@ class StatsAPIView(APIView):
|
||||
last_updated_iso = now.isoformat()
|
||||
|
||||
# Get cached timestamp or use current time
|
||||
cached_timestamp = cache.get('platform_stats_timestamp')
|
||||
if cached_timestamp and cached_timestamp != 'just_now':
|
||||
cached_timestamp = cache.get("platform_stats_timestamp")
|
||||
if cached_timestamp and cached_timestamp != "just_now":
|
||||
# Use cached timestamp for consistency
|
||||
last_updated_iso = cached_timestamp
|
||||
else:
|
||||
# Set new timestamp in cache
|
||||
cache.set('platform_stats_timestamp', last_updated_iso, 300)
|
||||
cache.set("platform_stats_timestamp", last_updated_iso, 300)
|
||||
|
||||
# Calculate relative time
|
||||
relative_last_updated = self._get_relative_time(last_updated_iso)
|
||||
@@ -293,34 +302,29 @@ class StatsAPIView(APIView):
|
||||
# Combine all stats
|
||||
stats = {
|
||||
# Core entity counts
|
||||
'total_parks': total_parks,
|
||||
'total_rides': total_rides,
|
||||
'total_manufacturers': total_manufacturers,
|
||||
'total_operators': total_operators,
|
||||
'total_designers': total_designers,
|
||||
'total_property_owners': total_property_owners,
|
||||
'total_roller_coasters': total_roller_coasters,
|
||||
|
||||
"total_parks": total_parks,
|
||||
"total_rides": total_rides,
|
||||
"total_manufacturers": total_manufacturers,
|
||||
"total_operators": total_operators,
|
||||
"total_designers": total_designers,
|
||||
"total_property_owners": total_property_owners,
|
||||
"total_roller_coasters": total_roller_coasters,
|
||||
# Photo counts
|
||||
'total_photos': total_photos,
|
||||
'total_park_photos': total_park_photos,
|
||||
'total_ride_photos': total_ride_photos,
|
||||
|
||||
"total_photos": total_photos,
|
||||
"total_park_photos": total_park_photos,
|
||||
"total_ride_photos": total_ride_photos,
|
||||
# Review counts
|
||||
'total_reviews': total_reviews,
|
||||
'total_park_reviews': total_park_reviews,
|
||||
'total_ride_reviews': total_ride_reviews,
|
||||
|
||||
"total_reviews": total_reviews,
|
||||
"total_park_reviews": total_park_reviews,
|
||||
"total_ride_reviews": total_ride_reviews,
|
||||
# Category breakdowns
|
||||
**category_stats,
|
||||
|
||||
# Status breakdowns
|
||||
**park_status_stats,
|
||||
**ride_status_stats,
|
||||
|
||||
# Metadata
|
||||
'last_updated': last_updated_iso,
|
||||
'relative_last_updated': relative_last_updated
|
||||
"last_updated": last_updated_iso,
|
||||
"relative_last_updated": relative_last_updated,
|
||||
}
|
||||
|
||||
return stats
|
||||
@@ -351,8 +355,11 @@ class StatsRecalculateAPIView(APIView):
|
||||
cache.set("platform_stats", fresh_stats, 300)
|
||||
|
||||
# Return success response with the fresh stats
|
||||
return Response({
|
||||
"message": "Platform statistics have been successfully recalculated",
|
||||
"stats": fresh_stats,
|
||||
"recalculated_at": timezone.now().isoformat()
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{
|
||||
"message": "Platform statistics have been successfully recalculated",
|
||||
"stats": fresh_stats,
|
||||
"recalculated_at": timezone.now().isoformat(),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -125,17 +125,24 @@ class TriggerTrendingCalculationAPIView(APIView):
|
||||
try:
|
||||
# Run trending calculation command
|
||||
with redirect_stdout(trending_output), redirect_stderr(trending_output):
|
||||
call_command('calculate_trending',
|
||||
'--content-type=all', '--limit=50')
|
||||
call_command(
|
||||
"calculate_trending", "--content-type=all", "--limit=50"
|
||||
)
|
||||
trending_completed = True
|
||||
except Exception as e:
|
||||
trending_output.write(f"Error: {str(e)}")
|
||||
|
||||
try:
|
||||
# Run new content calculation command
|
||||
with redirect_stdout(new_content_output), redirect_stderr(new_content_output):
|
||||
call_command('calculate_new_content',
|
||||
'--content-type=all', '--days-back=30', '--limit=50')
|
||||
with redirect_stdout(new_content_output), redirect_stderr(
|
||||
new_content_output
|
||||
):
|
||||
call_command(
|
||||
"calculate_new_content",
|
||||
"--content-type=all",
|
||||
"--days-back=30",
|
||||
"--limit=50",
|
||||
)
|
||||
new_content_completed = True
|
||||
except Exception as e:
|
||||
new_content_output.write(f"Error: {str(e)}")
|
||||
|
||||
@@ -157,7 +157,9 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
|
||||
ranking = self.get_object()
|
||||
history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:90] # Last 3 months
|
||||
)[
|
||||
:90
|
||||
] # Last 3 months
|
||||
|
||||
serializer = self.get_serializer(history, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -166,7 +166,9 @@ def custom_exception_handler(
|
||||
request=request,
|
||||
)
|
||||
|
||||
response = Response(custom_response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
response = Response(
|
||||
custom_response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -20,39 +20,37 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Calculate new content and cache results'
|
||||
help = "Calculate new content and cache results"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--content-type',
|
||||
"--content-type",
|
||||
type=str,
|
||||
default='all',
|
||||
choices=['all', 'parks', 'rides'],
|
||||
help='Type of content to calculate (default: all)'
|
||||
default="all",
|
||||
choices=["all", "parks", "rides"],
|
||||
help="Type of content to calculate (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--days-back',
|
||||
"--days-back",
|
||||
type=int,
|
||||
default=30,
|
||||
help='Number of days to look back for new content (default: 30)'
|
||||
help="Number of days to look back for new content (default: 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit',
|
||||
"--limit",
|
||||
type=int,
|
||||
default=50,
|
||||
help='Maximum number of results to calculate (default: 50)'
|
||||
help="Maximum number of results to calculate (default: 50)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Enable verbose output'
|
||||
"--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
content_type = options['content_type']
|
||||
days_back = options['days_back']
|
||||
limit = options['limit']
|
||||
verbose = options['verbose']
|
||||
content_type = options["content_type"]
|
||||
days_back = options["days_back"]
|
||||
limit = options["limit"]
|
||||
verbose = options["verbose"]
|
||||
|
||||
if verbose:
|
||||
self.stdout.write(f"Starting new content calculation for {content_type}")
|
||||
@@ -63,14 +61,16 @@ class Command(BaseCommand):
|
||||
|
||||
if content_type in ["all", "parks"]:
|
||||
parks = self._get_new_parks(
|
||||
cutoff_date, limit if content_type == "parks" else limit * 2)
|
||||
cutoff_date, limit if content_type == "parks" else limit * 2
|
||||
)
|
||||
new_items.extend(parks)
|
||||
if verbose:
|
||||
self.stdout.write(f"Found {len(parks)} new parks")
|
||||
|
||||
if content_type in ["all", "rides"]:
|
||||
rides = self._get_new_rides(
|
||||
cutoff_date, limit if content_type == "rides" else limit * 2)
|
||||
cutoff_date, limit if content_type == "rides" else limit * 2
|
||||
)
|
||||
new_items.extend(rides)
|
||||
if verbose:
|
||||
self.stdout.write(f"Found {len(rides)} new rides")
|
||||
@@ -95,7 +95,8 @@ class Command(BaseCommand):
|
||||
if verbose:
|
||||
for item in formatted_results[:5]: # Show first 5 items
|
||||
self.stdout.write(
|
||||
f" {item['name']} ({item['park']}) - opened: {item['date_opened']}")
|
||||
f" {item['name']} ({item['park']}) - opened: {item['date_opened']}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating new content: {e}", exc_info=True)
|
||||
@@ -105,8 +106,8 @@ class Command(BaseCommand):
|
||||
"""Get recently added parks using real data."""
|
||||
new_parks = (
|
||||
Park.objects.filter(
|
||||
Q(created_at__gte=cutoff_date) | Q(
|
||||
opening_date__gte=cutoff_date.date()),
|
||||
Q(created_at__gte=cutoff_date)
|
||||
| Q(opening_date__gte=cutoff_date.date()),
|
||||
status="OPERATING",
|
||||
)
|
||||
.select_related("location", "operator")
|
||||
@@ -124,18 +125,20 @@ class Command(BaseCommand):
|
||||
if opening_date and isinstance(opening_date, datetime):
|
||||
opening_date = opening_date.date()
|
||||
|
||||
results.append({
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"id": park.pk,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"url": park.url,
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"id": park.pk,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"url": park.url,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -143,8 +146,8 @@ class Command(BaseCommand):
|
||||
"""Get recently added rides using real data."""
|
||||
new_rides = (
|
||||
Ride.objects.filter(
|
||||
Q(created_at__gte=cutoff_date) | Q(
|
||||
opening_date__gte=cutoff_date.date()),
|
||||
Q(created_at__gte=cutoff_date)
|
||||
| Q(opening_date__gte=cutoff_date.date()),
|
||||
status="OPERATING",
|
||||
)
|
||||
.select_related("park", "park__location")
|
||||
@@ -154,7 +157,8 @@ class Command(BaseCommand):
|
||||
results = []
|
||||
for ride in new_rides:
|
||||
date_added = getattr(ride, "opening_date", None) or getattr(
|
||||
ride, "created_at", None)
|
||||
ride, "created_at", None
|
||||
)
|
||||
if date_added:
|
||||
if isinstance(date_added, datetime):
|
||||
date_added = date_added.date()
|
||||
@@ -163,23 +167,27 @@ class Command(BaseCommand):
|
||||
if opening_date and isinstance(opening_date, datetime):
|
||||
opening_date = opening_date.date()
|
||||
|
||||
results.append({
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _format_new_content_results(self, new_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _format_new_content_results(
|
||||
self, new_items: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format new content results for frontend consumption."""
|
||||
formatted_results = []
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@ Run with: python manage.py calculate_trending
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from django.core.cache import cache
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.core.analytics import PageView
|
||||
from apps.parks.models import Park
|
||||
@@ -22,32 +20,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Calculate trending content and cache results'
|
||||
help = "Calculate trending content and cache results"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--content-type',
|
||||
"--content-type",
|
||||
type=str,
|
||||
default='all',
|
||||
choices=['all', 'parks', 'rides'],
|
||||
help='Type of content to calculate (default: all)'
|
||||
default="all",
|
||||
choices=["all", "parks", "rides"],
|
||||
help="Type of content to calculate (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit',
|
||||
"--limit",
|
||||
type=int,
|
||||
default=50,
|
||||
help='Maximum number of results to calculate (default: 50)'
|
||||
help="Maximum number of results to calculate (default: 50)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Enable verbose output'
|
||||
"--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
content_type = options['content_type']
|
||||
limit = options['limit']
|
||||
verbose = options['verbose']
|
||||
content_type = options["content_type"]
|
||||
limit = options["limit"]
|
||||
verbose = options["verbose"]
|
||||
|
||||
if verbose:
|
||||
self.stdout.write(f"Starting trending calculation for {content_type}")
|
||||
@@ -64,7 +60,7 @@ class Command(BaseCommand):
|
||||
park_items = self._calculate_trending_parks(
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
limit if content_type == "parks" else limit * 2
|
||||
limit if content_type == "parks" else limit * 2,
|
||||
)
|
||||
trending_items.extend(park_items)
|
||||
if verbose:
|
||||
@@ -74,7 +70,7 @@ class Command(BaseCommand):
|
||||
ride_items = self._calculate_trending_rides(
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
limit if content_type == "rides" else limit * 2
|
||||
limit if content_type == "rides" else limit * 2,
|
||||
)
|
||||
trending_items.extend(ride_items)
|
||||
if verbose:
|
||||
@@ -86,7 +82,8 @@ class Command(BaseCommand):
|
||||
|
||||
# Format results for API consumption
|
||||
formatted_results = self._format_trending_results(
|
||||
trending_items, current_period_hours, previous_period_hours)
|
||||
trending_items, current_period_hours, previous_period_hours
|
||||
)
|
||||
|
||||
# Cache results
|
||||
cache_key = f"trending:calculated:{content_type}:{limit}"
|
||||
@@ -101,74 +98,109 @@ class Command(BaseCommand):
|
||||
if verbose:
|
||||
for item in formatted_results[:5]: # Show first 5 items
|
||||
self.stdout.write(
|
||||
f" {item['name']} (score: {item.get('views_change', 'N/A')})")
|
||||
f" {item['name']} (score: {item.get('views_change', 'N/A')})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating trending content: {e}", exc_info=True)
|
||||
raise CommandError(f"Failed to calculate trending content: {e}")
|
||||
|
||||
def _calculate_trending_parks(self, current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
|
||||
def _calculate_trending_parks(
|
||||
self, current_period_hours: int, previous_period_hours: int, limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Calculate trending scores for parks using real data."""
|
||||
parks = Park.objects.filter(
|
||||
status="OPERATING").select_related("location", "operator")
|
||||
parks = Park.objects.filter(status="OPERATING").select_related(
|
||||
"location", "operator"
|
||||
)
|
||||
|
||||
trending_parks = []
|
||||
|
||||
for park in parks:
|
||||
try:
|
||||
score = self._calculate_content_score(
|
||||
park, "park", current_period_hours, previous_period_hours)
|
||||
park, "park", current_period_hours, previous_period_hours
|
||||
)
|
||||
if score > 0: # Only include items with positive trending scores
|
||||
trending_parks.append({
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"trending_score": score,
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"rating": float(park.average_rating) if park.average_rating else 0.0,
|
||||
"date_opened": park.opening_date.isoformat() if park.opening_date else "",
|
||||
"url": park.url,
|
||||
})
|
||||
trending_parks.append(
|
||||
{
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"trending_score": score,
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"rating": (
|
||||
float(park.average_rating)
|
||||
if park.average_rating
|
||||
else 0.0
|
||||
),
|
||||
"date_opened": (
|
||||
park.opening_date.isoformat()
|
||||
if park.opening_date
|
||||
else ""
|
||||
),
|
||||
"url": park.url,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating score for park {park.id}: {e}")
|
||||
|
||||
return trending_parks
|
||||
|
||||
def _calculate_trending_rides(self, current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
|
||||
def _calculate_trending_rides(
|
||||
self, current_period_hours: int, previous_period_hours: int, limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Calculate trending scores for rides using real data."""
|
||||
rides = Ride.objects.filter(status="OPERATING").select_related(
|
||||
"park", "park__location")
|
||||
"park", "park__location"
|
||||
)
|
||||
|
||||
trending_rides = []
|
||||
|
||||
for ride in rides:
|
||||
try:
|
||||
score = self._calculate_content_score(
|
||||
ride, "ride", current_period_hours, previous_period_hours)
|
||||
ride, "ride", current_period_hours, previous_period_hours
|
||||
)
|
||||
if score > 0: # Only include items with positive trending scores
|
||||
trending_rides.append({
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"trending_score": score,
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"rating": float(ride.average_rating) if ride.average_rating else 0.0,
|
||||
"date_opened": ride.opening_date.isoformat() if ride.opening_date else "",
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
})
|
||||
trending_rides.append(
|
||||
{
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"trending_score": score,
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"rating": (
|
||||
float(ride.average_rating)
|
||||
if ride.average_rating
|
||||
else 0.0
|
||||
),
|
||||
"date_opened": (
|
||||
ride.opening_date.isoformat()
|
||||
if ride.opening_date
|
||||
else ""
|
||||
),
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
|
||||
|
||||
return trending_rides
|
||||
|
||||
def _calculate_content_score(self, content_obj: Any, content_type: str, current_period_hours: int, previous_period_hours: int) -> float:
|
||||
def _calculate_content_score(
|
||||
self,
|
||||
content_obj: Any,
|
||||
content_type: str,
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> float:
|
||||
"""Calculate weighted trending score for content object using real analytics data."""
|
||||
try:
|
||||
# Get content type for PageView queries
|
||||
@@ -176,7 +208,8 @@ class Command(BaseCommand):
|
||||
|
||||
# 1. View Growth Score (40% weight)
|
||||
view_growth_score = self._calculate_view_growth_score(
|
||||
ct, content_obj.id, current_period_hours, previous_period_hours)
|
||||
ct, content_obj.id, current_period_hours, previous_period_hours
|
||||
)
|
||||
|
||||
# 2. Rating Score (30% weight)
|
||||
rating_score = self._calculate_rating_score(content_obj)
|
||||
@@ -186,31 +219,41 @@ class Command(BaseCommand):
|
||||
|
||||
# 4. Popularity Score (10% weight)
|
||||
popularity_score = self._calculate_popularity_score(
|
||||
ct, content_obj.id, current_period_hours)
|
||||
ct, content_obj.id, current_period_hours
|
||||
)
|
||||
|
||||
# Calculate weighted final score
|
||||
final_score = (
|
||||
view_growth_score * 0.4 +
|
||||
rating_score * 0.3 +
|
||||
recency_score * 0.2 +
|
||||
popularity_score * 0.1
|
||||
view_growth_score * 0.4
|
||||
+ rating_score * 0.3
|
||||
+ recency_score * 0.2
|
||||
+ popularity_score * 0.1
|
||||
)
|
||||
|
||||
return final_score
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error calculating score for {content_type} {content_obj.id}: {e}")
|
||||
f"Error calculating score for {content_type} {content_obj.id}: {e}"
|
||||
)
|
||||
return 0.0
|
||||
|
||||
def _calculate_view_growth_score(self, content_type: ContentType, object_id: int, current_period_hours: int, previous_period_hours: int) -> float:
|
||||
def _calculate_view_growth_score(
|
||||
self,
|
||||
content_type: ContentType,
|
||||
object_id: int,
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> float:
|
||||
"""Calculate normalized view growth score using real PageView data."""
|
||||
try:
|
||||
current_views, previous_views, growth_percentage = PageView.get_views_growth(
|
||||
content_type,
|
||||
object_id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
current_views, previous_views, growth_percentage = (
|
||||
PageView.get_views_growth(
|
||||
content_type,
|
||||
object_id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
)
|
||||
)
|
||||
|
||||
if previous_views == 0:
|
||||
@@ -218,8 +261,9 @@ class Command(BaseCommand):
|
||||
return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0
|
||||
|
||||
# Normalize growth percentage to 0-1 scale
|
||||
normalized_growth = min(growth_percentage / 500.0,
|
||||
1.0) if growth_percentage > 0 else 0.0
|
||||
normalized_growth = (
|
||||
min(growth_percentage / 500.0, 1.0) if growth_percentage > 0 else 0.0
|
||||
)
|
||||
return max(normalized_growth, 0.0)
|
||||
|
||||
except Exception as e:
|
||||
@@ -272,11 +316,14 @@ class Command(BaseCommand):
|
||||
logger.warning(f"Error calculating recency score: {e}")
|
||||
return 0.5
|
||||
|
||||
def _calculate_popularity_score(self, content_type: ContentType, object_id: int, hours: int) -> float:
|
||||
def _calculate_popularity_score(
|
||||
self, content_type: ContentType, object_id: int, hours: int
|
||||
) -> float:
|
||||
"""Calculate popularity score based on total view count."""
|
||||
try:
|
||||
total_views = PageView.get_total_views_count(
|
||||
content_type, object_id, hours=hours)
|
||||
content_type, object_id, hours=hours
|
||||
)
|
||||
|
||||
# Normalize views to 0-1 scale
|
||||
if total_views == 0:
|
||||
@@ -290,7 +337,12 @@ class Command(BaseCommand):
|
||||
logger.warning(f"Error calculating popularity score: {e}")
|
||||
return 0.0
|
||||
|
||||
def _format_trending_results(self, trending_items: List[Dict[str, Any]], current_period_hours: int, previous_period_hours: int) -> List[Dict[str, Any]]:
|
||||
def _format_trending_results(
|
||||
self,
|
||||
trending_items: List[Dict[str, Any]],
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format trending results for frontend consumption."""
|
||||
formatted_results = []
|
||||
|
||||
@@ -299,11 +351,13 @@ class Command(BaseCommand):
|
||||
# Get view change for display
|
||||
content_obj = item["content_object"]
|
||||
ct = ContentType.objects.get_for_model(content_obj)
|
||||
current_views, previous_views, growth_percentage = PageView.get_views_growth(
|
||||
ct,
|
||||
content_obj.id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
current_views, previous_views, growth_percentage = (
|
||||
PageView.get_views_growth(
|
||||
ct,
|
||||
content_obj.id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
)
|
||||
)
|
||||
|
||||
# Format exactly as frontend expects
|
||||
|
||||
@@ -305,7 +305,7 @@ class CacheMonitor:
|
||||
stats["cache_backend"] = cache_backend
|
||||
stats["message"] = f"Cache statistics not available for {cache_backend}"
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Don't log as error since this is expected for non-Redis backends
|
||||
cache_backend = self.cache_service.default_cache.__class__.__name__
|
||||
stats["cache_backend"] = cache_backend
|
||||
|
||||
@@ -48,7 +48,11 @@ class ParkLocationAdapter(BaseLocationAdapter):
|
||||
self, location_obj: ParkLocation
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Convert ParkLocation to UnifiedLocation."""
|
||||
if not location_obj.point or location_obj.latitude is None or location_obj.longitude is None:
|
||||
if (
|
||||
not location_obj.point
|
||||
or location_obj.latitude is None
|
||||
or location_obj.longitude is None
|
||||
):
|
||||
return None
|
||||
|
||||
park = location_obj.park
|
||||
@@ -175,7 +179,11 @@ class RideLocationAdapter(BaseLocationAdapter):
|
||||
self, location_obj: RideLocation
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Convert RideLocation to UnifiedLocation."""
|
||||
if not location_obj.point or location_obj.latitude is None or location_obj.longitude is None:
|
||||
if (
|
||||
not location_obj.point
|
||||
or location_obj.latitude is None
|
||||
or location_obj.longitude is None
|
||||
):
|
||||
return None
|
||||
|
||||
ride = location_obj.ride
|
||||
|
||||
@@ -86,12 +86,14 @@ class TrendingService:
|
||||
|
||||
if content_type in ["all", "parks"]:
|
||||
park_items = self._calculate_trending_parks(
|
||||
limit * 2 if content_type == "all" else limit)
|
||||
limit * 2 if content_type == "all" else limit
|
||||
)
|
||||
trending_items.extend(park_items)
|
||||
|
||||
if content_type in ["all", "rides"]:
|
||||
ride_items = self._calculate_trending_rides(
|
||||
limit * 2 if content_type == "all" else limit)
|
||||
limit * 2 if content_type == "all" else limit
|
||||
)
|
||||
trending_items.extend(ride_items)
|
||||
|
||||
# Sort by trending score and apply limit
|
||||
@@ -105,7 +107,8 @@ class TrendingService:
|
||||
cache.set(cache_key, formatted_results, self.CACHE_TTL)
|
||||
|
||||
self.logger.info(
|
||||
f"Calculated {len(formatted_results)} trending items for {content_type}")
|
||||
f"Calculated {len(formatted_results)} trending items for {content_type}"
|
||||
)
|
||||
return formatted_results
|
||||
|
||||
except Exception as e:
|
||||
@@ -150,12 +153,14 @@ class TrendingService:
|
||||
|
||||
if content_type in ["all", "parks"]:
|
||||
parks = self._get_new_parks(
|
||||
cutoff_date, limit * 2 if content_type == "all" else limit)
|
||||
cutoff_date, limit * 2 if content_type == "all" else limit
|
||||
)
|
||||
new_items.extend(parks)
|
||||
|
||||
if content_type in ["all", "rides"]:
|
||||
rides = self._get_new_rides(
|
||||
cutoff_date, limit * 2 if content_type == "all" else limit)
|
||||
cutoff_date, limit * 2 if content_type == "all" else limit
|
||||
)
|
||||
new_items.extend(rides)
|
||||
|
||||
# Sort by date added (most recent first) and apply limit
|
||||
@@ -169,7 +174,8 @@ class TrendingService:
|
||||
cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes
|
||||
|
||||
self.logger.info(
|
||||
f"Calculated {len(formatted_results)} new items for {content_type}")
|
||||
f"Calculated {len(formatted_results)} new items for {content_type}"
|
||||
)
|
||||
return formatted_results
|
||||
|
||||
except Exception as e:
|
||||
@@ -198,18 +204,20 @@ class TrendingService:
|
||||
state = ""
|
||||
country = ""
|
||||
try:
|
||||
location = getattr(park, 'location', None)
|
||||
location = getattr(park, "location", None)
|
||||
if location:
|
||||
city = getattr(location, 'city', '') or ""
|
||||
state = getattr(location, 'state', '') or ""
|
||||
country = getattr(location, 'country', '') or ""
|
||||
city = getattr(location, "city", "") or ""
|
||||
state = getattr(location, "state", "") or ""
|
||||
country = getattr(location, "country", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get card image URL
|
||||
card_image_url = ""
|
||||
if park.card_image and hasattr(park.card_image, 'image'):
|
||||
card_image_url = park.card_image.image.url if park.card_image.image else ""
|
||||
if park.card_image and hasattr(park.card_image, "image"):
|
||||
card_image_url = (
|
||||
park.card_image.image.url if park.card_image.image else ""
|
||||
)
|
||||
|
||||
# Get primary company (operator)
|
||||
primary_company = park.operator.name if park.operator else ""
|
||||
@@ -229,7 +237,9 @@ class TrendingService:
|
||||
if park.average_rating
|
||||
else 0.0
|
||||
),
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"date_opened": (
|
||||
opening_date.isoformat() if opening_date else ""
|
||||
),
|
||||
"url": park.url,
|
||||
"card_image": card_image_url,
|
||||
"city": city,
|
||||
@@ -262,8 +272,10 @@ class TrendingService:
|
||||
|
||||
# Get card image URL
|
||||
card_image_url = ""
|
||||
if ride.card_image and hasattr(ride.card_image, 'image'):
|
||||
card_image_url = ride.card_image.image.url if ride.card_image.image else ""
|
||||
if ride.card_image and hasattr(ride.card_image, "image"):
|
||||
card_image_url = (
|
||||
ride.card_image.image.url if ride.card_image.image else ""
|
||||
)
|
||||
|
||||
trending_rides.append(
|
||||
{
|
||||
@@ -280,7 +292,9 @@ class TrendingService:
|
||||
if ride.average_rating
|
||||
else 0.0
|
||||
),
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"date_opened": (
|
||||
opening_date.isoformat() if opening_date else ""
|
||||
),
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
"card_image": card_image_url,
|
||||
@@ -474,18 +488,20 @@ class TrendingService:
|
||||
state = ""
|
||||
country = ""
|
||||
try:
|
||||
location = getattr(park, 'location', None)
|
||||
location = getattr(park, "location", None)
|
||||
if location:
|
||||
city = getattr(location, 'city', '') or ""
|
||||
state = getattr(location, 'state', '') or ""
|
||||
country = getattr(location, 'country', '') or ""
|
||||
city = getattr(location, "city", "") or ""
|
||||
state = getattr(location, "state", "") or ""
|
||||
country = getattr(location, "country", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get card image URL
|
||||
card_image_url = ""
|
||||
if park.card_image and hasattr(park.card_image, 'image'):
|
||||
card_image_url = park.card_image.image.url if park.card_image.image else ""
|
||||
if park.card_image and hasattr(park.card_image, "image"):
|
||||
card_image_url = (
|
||||
park.card_image.image.url if park.card_image.image else ""
|
||||
)
|
||||
|
||||
# Get primary company (operator)
|
||||
primary_company = park.operator.name if park.operator else ""
|
||||
@@ -543,8 +559,10 @@ class TrendingService:
|
||||
|
||||
# Get card image URL
|
||||
card_image_url = ""
|
||||
if ride.card_image and hasattr(ride.card_image, 'image'):
|
||||
card_image_url = ride.card_image.image.url if ride.card_image.image else ""
|
||||
if ride.card_image and hasattr(ride.card_image, "image"):
|
||||
card_image_url = (
|
||||
ride.card_image.image.url if ride.card_image.image else ""
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
|
||||
@@ -7,13 +7,12 @@ All tasks run asynchronously to avoid blocking the main application.
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
from typing import Dict, List, Any
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from django.core.cache import cache
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q, Count, Avg, F
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.core.analytics import PageView
|
||||
from apps.parks.models import Park
|
||||
@@ -23,7 +22,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def calculate_trending_content(self, content_type: str = "all", limit: int = 50) -> Dict[str, Any]:
|
||||
def calculate_trending_content(
|
||||
self, content_type: str = "all", limit: int = 50
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate trending content using real analytics data.
|
||||
|
||||
@@ -53,7 +54,7 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
park_items = _calculate_trending_parks(
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
limit if content_type == "parks" else limit * 2
|
||||
limit if content_type == "parks" else limit * 2,
|
||||
)
|
||||
trending_items.extend(park_items)
|
||||
|
||||
@@ -61,7 +62,7 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
ride_items = _calculate_trending_rides(
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
limit if content_type == "rides" else limit * 2
|
||||
limit if content_type == "rides" else limit * 2,
|
||||
)
|
||||
trending_items.extend(ride_items)
|
||||
|
||||
@@ -71,14 +72,16 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
|
||||
# Format results for API consumption
|
||||
formatted_results = _format_trending_results(
|
||||
trending_items, current_period_hours, previous_period_hours)
|
||||
trending_items, current_period_hours, previous_period_hours
|
||||
)
|
||||
|
||||
# Cache results
|
||||
cache_key = f"trending:calculated:{content_type}:{limit}"
|
||||
cache.set(cache_key, formatted_results, 3600) # Cache for 1 hour
|
||||
|
||||
logger.info(
|
||||
f"Calculated {len(formatted_results)} trending items for {content_type}")
|
||||
f"Calculated {len(formatted_results)} trending items for {content_type}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -95,7 +98,9 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||||
def calculate_new_content(self, content_type: str = "all", days_back: int = 30, limit: int = 50) -> Dict[str, Any]:
|
||||
def calculate_new_content(
|
||||
self, content_type: str = "all", days_back: int = 30, limit: int = 50
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate new content based on opening dates and creation dates.
|
||||
|
||||
@@ -115,12 +120,14 @@ def calculate_new_content(self, content_type: str = "all", days_back: int = 30,
|
||||
|
||||
if content_type in ["all", "parks"]:
|
||||
parks = _get_new_parks(
|
||||
cutoff_date, limit if content_type == "parks" else limit * 2)
|
||||
cutoff_date, limit if content_type == "parks" else limit * 2
|
||||
)
|
||||
new_items.extend(parks)
|
||||
|
||||
if content_type in ["all", "rides"]:
|
||||
rides = _get_new_rides(
|
||||
cutoff_date, limit if content_type == "rides" else limit * 2)
|
||||
cutoff_date, limit if content_type == "rides" else limit * 2
|
||||
)
|
||||
new_items.extend(rides)
|
||||
|
||||
# Sort by date added (most recent first) and apply limit
|
||||
@@ -177,7 +184,9 @@ def warm_trending_cache(self) -> Dict[str, Any]:
|
||||
calculate_new_content.delay(**query)
|
||||
|
||||
results[f"trending_{query['content_type']}_{query['limit']}"] = "scheduled"
|
||||
results[f"new_content_{query['content_type']}_{query['limit']}"] = "scheduled"
|
||||
results[f"new_content_{query['content_type']}_{query['limit']}"] = (
|
||||
"scheduled"
|
||||
)
|
||||
|
||||
logger.info("Trending cache warming completed")
|
||||
|
||||
@@ -197,70 +206,93 @@ def warm_trending_cache(self) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _calculate_trending_parks(current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
|
||||
def _calculate_trending_parks(
|
||||
current_period_hours: int, previous_period_hours: int, limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Calculate trending scores for parks using real data."""
|
||||
parks = Park.objects.filter(
|
||||
status="OPERATING").select_related("location", "operator")
|
||||
parks = Park.objects.filter(status="OPERATING").select_related(
|
||||
"location", "operator"
|
||||
)
|
||||
|
||||
trending_parks = []
|
||||
|
||||
for park in parks:
|
||||
try:
|
||||
score = _calculate_content_score(
|
||||
park, "park", current_period_hours, previous_period_hours)
|
||||
park, "park", current_period_hours, previous_period_hours
|
||||
)
|
||||
if score > 0: # Only include items with positive trending scores
|
||||
trending_parks.append({
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"trending_score": score,
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"location": park.formatted_location if hasattr(park, "location") else "",
|
||||
"category": "park",
|
||||
"rating": float(park.average_rating) if park.average_rating else 0.0,
|
||||
})
|
||||
trending_parks.append(
|
||||
{
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"trending_score": score,
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"location": (
|
||||
park.formatted_location if hasattr(park, "location") else ""
|
||||
),
|
||||
"category": "park",
|
||||
"rating": (
|
||||
float(park.average_rating) if park.average_rating else 0.0
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating score for park {park.id}: {e}")
|
||||
|
||||
return trending_parks
|
||||
|
||||
|
||||
def _calculate_trending_rides(current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
|
||||
def _calculate_trending_rides(
|
||||
current_period_hours: int, previous_period_hours: int, limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Calculate trending scores for rides using real data."""
|
||||
rides = Ride.objects.filter(status="OPERATING").select_related(
|
||||
"park", "park__location")
|
||||
"park", "park__location"
|
||||
)
|
||||
|
||||
trending_rides = []
|
||||
|
||||
for ride in rides:
|
||||
try:
|
||||
score = _calculate_content_score(
|
||||
ride, "ride", current_period_hours, previous_period_hours)
|
||||
ride, "ride", current_period_hours, previous_period_hours
|
||||
)
|
||||
if score > 0: # Only include items with positive trending scores
|
||||
# Get location from park
|
||||
location = ""
|
||||
if ride.park and hasattr(ride.park, "location") and ride.park.location:
|
||||
location = ride.park.formatted_location
|
||||
|
||||
trending_rides.append({
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"trending_score": score,
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"location": location,
|
||||
"category": "ride",
|
||||
"rating": float(ride.average_rating) if ride.average_rating else 0.0,
|
||||
})
|
||||
trending_rides.append(
|
||||
{
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"trending_score": score,
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"location": location,
|
||||
"category": "ride",
|
||||
"rating": (
|
||||
float(ride.average_rating) if ride.average_rating else 0.0
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
|
||||
|
||||
return trending_rides
|
||||
|
||||
|
||||
def _calculate_content_score(content_obj: Any, content_type: str, current_period_hours: int, previous_period_hours: int) -> float:
|
||||
def _calculate_content_score(
|
||||
content_obj: Any,
|
||||
content_type: str,
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate weighted trending score for content object using real analytics data.
|
||||
|
||||
@@ -279,7 +311,8 @@ def _calculate_content_score(content_obj: Any, content_type: str, current_period
|
||||
|
||||
# 1. View Growth Score (40% weight)
|
||||
view_growth_score = _calculate_view_growth_score(
|
||||
ct, content_obj.id, current_period_hours, previous_period_hours)
|
||||
ct, content_obj.id, current_period_hours, previous_period_hours
|
||||
)
|
||||
|
||||
# 2. Rating Score (30% weight)
|
||||
rating_score = _calculate_rating_score(content_obj)
|
||||
@@ -289,14 +322,15 @@ def _calculate_content_score(content_obj: Any, content_type: str, current_period
|
||||
|
||||
# 4. Popularity Score (10% weight)
|
||||
popularity_score = _calculate_popularity_score(
|
||||
ct, content_obj.id, current_period_hours)
|
||||
ct, content_obj.id, current_period_hours
|
||||
)
|
||||
|
||||
# Calculate weighted final score
|
||||
final_score = (
|
||||
view_growth_score * 0.4 +
|
||||
rating_score * 0.3 +
|
||||
recency_score * 0.2 +
|
||||
popularity_score * 0.1
|
||||
view_growth_score * 0.4
|
||||
+ rating_score * 0.3
|
||||
+ recency_score * 0.2
|
||||
+ popularity_score * 0.1
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
@@ -310,11 +344,17 @@ def _calculate_content_score(content_obj: Any, content_type: str, current_period
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error calculating score for {content_type} {content_obj.id}: {e}")
|
||||
f"Error calculating score for {content_type} {content_obj.id}: {e}"
|
||||
)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _calculate_view_growth_score(content_type: ContentType, object_id: int, current_period_hours: int, previous_period_hours: int) -> float:
|
||||
def _calculate_view_growth_score(
|
||||
content_type: ContentType,
|
||||
object_id: int,
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> float:
|
||||
"""Calculate normalized view growth score using real PageView data."""
|
||||
try:
|
||||
current_views, previous_views, growth_percentage = PageView.get_views_growth(
|
||||
@@ -330,8 +370,9 @@ def _calculate_view_growth_score(content_type: ContentType, object_id: int, curr
|
||||
|
||||
# Normalize growth percentage to 0-1 scale
|
||||
# 100% growth = 0.5, 500% growth = 1.0
|
||||
normalized_growth = min(growth_percentage / 500.0,
|
||||
1.0) if growth_percentage > 0 else 0.0
|
||||
normalized_growth = (
|
||||
min(growth_percentage / 500.0, 1.0) if growth_percentage > 0 else 0.0
|
||||
)
|
||||
return max(normalized_growth, 0.0)
|
||||
|
||||
except Exception as e:
|
||||
@@ -389,11 +430,14 @@ def _calculate_recency_score(content_obj: Any) -> float:
|
||||
return 0.5
|
||||
|
||||
|
||||
def _calculate_popularity_score(content_type: ContentType, object_id: int, hours: int) -> float:
|
||||
def _calculate_popularity_score(
|
||||
content_type: ContentType, object_id: int, hours: int
|
||||
) -> float:
|
||||
"""Calculate popularity score based on total view count."""
|
||||
try:
|
||||
total_views = PageView.get_total_views_count(
|
||||
content_type, object_id, hours=hours)
|
||||
content_type, object_id, hours=hours
|
||||
)
|
||||
|
||||
# Normalize views to 0-1 scale
|
||||
# 0 views = 0.0, 100 views = 0.5, 1000+ views = 1.0
|
||||
@@ -431,17 +475,19 @@ def _get_new_parks(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
|
||||
if opening_date and isinstance(opening_date, datetime):
|
||||
opening_date = opening_date.date()
|
||||
|
||||
results.append({
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"id": park.pk,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"id": park.pk,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -460,7 +506,8 @@ def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
|
||||
results = []
|
||||
for ride in new_rides:
|
||||
date_added = getattr(ride, "opening_date", None) or getattr(
|
||||
ride, "created_at", None)
|
||||
ride, "created_at", None
|
||||
)
|
||||
if date_added:
|
||||
if isinstance(date_added, datetime):
|
||||
date_added = date_added.date()
|
||||
@@ -469,22 +516,28 @@ def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
|
||||
if opening_date and isinstance(opening_date, datetime):
|
||||
opening_date = opening_date.date()
|
||||
|
||||
results.append({
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _format_trending_results(trending_items: List[Dict[str, Any]], current_period_hours: int, previous_period_hours: int) -> List[Dict[str, Any]]:
|
||||
def _format_trending_results(
|
||||
trending_items: List[Dict[str, Any]],
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format trending results for frontend consumption."""
|
||||
formatted_results = []
|
||||
|
||||
@@ -493,11 +546,13 @@ def _format_trending_results(trending_items: List[Dict[str, Any]], current_perio
|
||||
# Get view change for display
|
||||
content_obj = item["content_object"]
|
||||
ct = ContentType.objects.get_for_model(content_obj)
|
||||
current_views, previous_views, growth_percentage = PageView.get_views_growth(
|
||||
ct,
|
||||
content_obj.id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
current_views, previous_views, growth_percentage = (
|
||||
PageView.get_views_growth(
|
||||
ct,
|
||||
content_obj.id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
)
|
||||
)
|
||||
|
||||
# Format exactly as frontend expects
|
||||
@@ -525,7 +580,9 @@ def _format_trending_results(trending_items: List[Dict[str, Any]], current_perio
|
||||
return formatted_results
|
||||
|
||||
|
||||
def _format_new_content_results(new_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _format_new_content_results(
|
||||
new_items: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format new content results for frontend consumption."""
|
||||
formatted_results = []
|
||||
|
||||
|
||||
429
backend/apps/moderation/filters.py
Normal file
429
backend/apps/moderation/filters.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Moderation Filters
|
||||
|
||||
This module contains Django filter classes for the moderation system,
|
||||
providing comprehensive filtering capabilities for all moderation models.
|
||||
"""
|
||||
|
||||
import django_filters
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from .models import (
|
||||
ModerationReport,
|
||||
ModerationQueue,
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ModerationReportFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationReport model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.STATUS_CHOICES, help_text="Filter by report status"
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.PRIORITY_CHOICES, help_text="Filter by report priority"
|
||||
)
|
||||
|
||||
# Report type filters
|
||||
report_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.REPORT_TYPE_CHOICES, help_text="Filter by report type"
|
||||
)
|
||||
|
||||
# User filters
|
||||
reported_by = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.all(), help_text="Filter by user who made the report"
|
||||
)
|
||||
|
||||
assigned_moderator = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by assigned moderator",
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter reports created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter reports created before this date",
|
||||
)
|
||||
|
||||
resolved_after = django_filters.DateTimeFilter(
|
||||
field_name="resolved_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter reports resolved after this date",
|
||||
)
|
||||
|
||||
resolved_before = django_filters.DateTimeFilter(
|
||||
field_name="resolved_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter reports resolved before this date",
|
||||
)
|
||||
|
||||
# Content type filters
|
||||
content_type = django_filters.CharFilter(
|
||||
field_name="content_type__model",
|
||||
help_text="Filter by content type (e.g., 'park', 'ride', 'review')",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
unassigned = django_filters.BooleanFilter(
|
||||
method="filter_unassigned", help_text="Filter for unassigned reports"
|
||||
)
|
||||
|
||||
overdue = django_filters.BooleanFilter(
|
||||
method="filter_overdue", help_text="Filter for overdue reports based on SLA"
|
||||
)
|
||||
|
||||
has_resolution = django_filters.BooleanFilter(
|
||||
method="filter_has_resolution",
|
||||
help_text="Filter reports with/without resolution",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"status",
|
||||
"priority",
|
||||
"report_type",
|
||||
"reported_by",
|
||||
"assigned_moderator",
|
||||
"content_type",
|
||||
"unassigned",
|
||||
"overdue",
|
||||
"has_resolution",
|
||||
]
|
||||
|
||||
def filter_unassigned(self, queryset, name, value):
|
||||
"""Filter for unassigned reports."""
|
||||
if value:
|
||||
return queryset.filter(assigned_moderator__isnull=True)
|
||||
return queryset.filter(assigned_moderator__isnull=False)
|
||||
|
||||
def filter_overdue(self, queryset, name, value):
|
||||
"""Filter for overdue reports based on SLA."""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
now = timezone.now()
|
||||
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
||||
|
||||
overdue_ids = []
|
||||
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
|
||||
hours_since_created = (now - report.created_at).total_seconds() / 3600
|
||||
if hours_since_created > sla_hours.get(report.priority, 24):
|
||||
overdue_ids.append(report.id)
|
||||
|
||||
return queryset.filter(id__in=overdue_ids)
|
||||
|
||||
def filter_has_resolution(self, queryset, name, value):
|
||||
"""Filter reports with/without resolution."""
|
||||
if value:
|
||||
return queryset.exclude(
|
||||
resolution_action__isnull=True, resolution_action=""
|
||||
)
|
||||
return queryset.filter(
|
||||
Q(resolution_action__isnull=True) | Q(resolution_action="")
|
||||
)
|
||||
|
||||
|
||||
class ModerationQueueFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationQueue model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.STATUS_CHOICES, help_text="Filter by queue item status"
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.PRIORITY_CHOICES,
|
||||
help_text="Filter by queue item priority",
|
||||
)
|
||||
|
||||
# Item type filters
|
||||
item_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.ITEM_TYPE_CHOICES, help_text="Filter by queue item type"
|
||||
)
|
||||
|
||||
# Assignment filters
|
||||
assigned_to = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by assigned moderator",
|
||||
)
|
||||
|
||||
unassigned = django_filters.BooleanFilter(
|
||||
method="filter_unassigned", help_text="Filter for unassigned queue items"
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter items created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter items created before this date",
|
||||
)
|
||||
|
||||
assigned_after = django_filters.DateTimeFilter(
|
||||
field_name="assigned_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter items assigned after this date",
|
||||
)
|
||||
|
||||
assigned_before = django_filters.DateTimeFilter(
|
||||
field_name="assigned_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter items assigned before this date",
|
||||
)
|
||||
|
||||
# Content type filters
|
||||
content_type = django_filters.CharFilter(
|
||||
field_name="content_type__model", help_text="Filter by content type"
|
||||
)
|
||||
|
||||
# Related report filters
|
||||
has_related_report = django_filters.BooleanFilter(
|
||||
method="filter_has_related_report",
|
||||
help_text="Filter items with/without related reports",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationQueue
|
||||
fields = [
|
||||
"status",
|
||||
"priority",
|
||||
"item_type",
|
||||
"assigned_to",
|
||||
"unassigned",
|
||||
"content_type",
|
||||
"has_related_report",
|
||||
]
|
||||
|
||||
def filter_unassigned(self, queryset, name, value):
|
||||
"""Filter for unassigned queue items."""
|
||||
if value:
|
||||
return queryset.filter(assigned_to__isnull=True)
|
||||
return queryset.filter(assigned_to__isnull=False)
|
||||
|
||||
def filter_has_related_report(self, queryset, name, value):
|
||||
"""Filter items with/without related reports."""
|
||||
if value:
|
||||
return queryset.filter(related_report__isnull=False)
|
||||
return queryset.filter(related_report__isnull=True)
|
||||
|
||||
|
||||
class ModerationActionFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationAction model."""
|
||||
|
||||
# Action type filters
|
||||
action_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationAction.ACTION_TYPE_CHOICES, help_text="Filter by action type"
|
||||
)
|
||||
|
||||
# User filters
|
||||
moderator = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by moderator who took the action",
|
||||
)
|
||||
|
||||
target_user = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.all(), help_text="Filter by target user"
|
||||
)
|
||||
|
||||
# Status filters
|
||||
is_active = django_filters.BooleanFilter(help_text="Filter by active status")
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter actions created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter actions created before this date",
|
||||
)
|
||||
|
||||
expires_after = django_filters.DateTimeFilter(
|
||||
field_name="expires_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter actions expiring after this date",
|
||||
)
|
||||
|
||||
expires_before = django_filters.DateTimeFilter(
|
||||
field_name="expires_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter actions expiring before this date",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
expired = django_filters.BooleanFilter(
|
||||
method="filter_expired", help_text="Filter for expired actions"
|
||||
)
|
||||
|
||||
expiring_soon = django_filters.BooleanFilter(
|
||||
method="filter_expiring_soon",
|
||||
help_text="Filter for actions expiring within 24 hours",
|
||||
)
|
||||
|
||||
has_related_report = django_filters.BooleanFilter(
|
||||
method="filter_has_related_report",
|
||||
help_text="Filter actions with/without related reports",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationAction
|
||||
fields = [
|
||||
"action_type",
|
||||
"moderator",
|
||||
"target_user",
|
||||
"is_active",
|
||||
"expired",
|
||||
"expiring_soon",
|
||||
"has_related_report",
|
||||
]
|
||||
|
||||
def filter_expired(self, queryset, name, value):
|
||||
"""Filter for expired actions."""
|
||||
now = timezone.now()
|
||||
if value:
|
||||
return queryset.filter(expires_at__lte=now)
|
||||
return queryset.filter(Q(expires_at__gt=now) | Q(expires_at__isnull=True))
|
||||
|
||||
def filter_expiring_soon(self, queryset, name, value):
|
||||
"""Filter for actions expiring within 24 hours."""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
now = timezone.now()
|
||||
soon = now + timedelta(hours=24)
|
||||
return queryset.filter(expires_at__gt=now, expires_at__lte=soon, is_active=True)
|
||||
|
||||
def filter_has_related_report(self, queryset, name, value):
|
||||
"""Filter actions with/without related reports."""
|
||||
if value:
|
||||
return queryset.filter(related_report__isnull=False)
|
||||
return queryset.filter(related_report__isnull=True)
|
||||
|
||||
|
||||
class BulkOperationFilter(django_filters.FilterSet):
|
||||
"""Filter for BulkOperation model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.STATUS_CHOICES, help_text="Filter by operation status"
|
||||
)
|
||||
|
||||
# Operation type filters
|
||||
operation_type = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.OPERATION_TYPE_CHOICES,
|
||||
help_text="Filter by operation type",
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.PRIORITY_CHOICES, help_text="Filter by operation priority"
|
||||
)
|
||||
|
||||
# User filters
|
||||
created_by = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by user who created the operation",
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations created before this date",
|
||||
)
|
||||
|
||||
started_after = django_filters.DateTimeFilter(
|
||||
field_name="started_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations started after this date",
|
||||
)
|
||||
|
||||
started_before = django_filters.DateTimeFilter(
|
||||
field_name="started_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations started before this date",
|
||||
)
|
||||
|
||||
completed_after = django_filters.DateTimeFilter(
|
||||
field_name="completed_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations completed after this date",
|
||||
)
|
||||
|
||||
completed_before = django_filters.DateTimeFilter(
|
||||
field_name="completed_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations completed before this date",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
can_cancel = django_filters.BooleanFilter(
|
||||
help_text="Filter by cancellation capability"
|
||||
)
|
||||
|
||||
has_failures = django_filters.BooleanFilter(
|
||||
method="filter_has_failures",
|
||||
help_text="Filter operations with/without failures",
|
||||
)
|
||||
|
||||
in_progress = django_filters.BooleanFilter(
|
||||
method="filter_in_progress",
|
||||
help_text="Filter for operations currently in progress",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = BulkOperation
|
||||
fields = [
|
||||
"status",
|
||||
"operation_type",
|
||||
"priority",
|
||||
"created_by",
|
||||
"can_cancel",
|
||||
"has_failures",
|
||||
"in_progress",
|
||||
]
|
||||
|
||||
def filter_has_failures(self, queryset, name, value):
|
||||
"""Filter operations with/without failures."""
|
||||
if value:
|
||||
return queryset.filter(failed_items__gt=0)
|
||||
return queryset.filter(failed_items=0)
|
||||
|
||||
def filter_in_progress(self, queryset, name, value):
|
||||
"""Filter for operations currently in progress."""
|
||||
if value:
|
||||
return queryset.filter(status__in=["PENDING", "RUNNING"])
|
||||
return queryset.exclude(status__in=["PENDING", "RUNNING"])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,782 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 19:16
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
(
|
||||
"moderation",
|
||||
"0003_bulkoperation_bulkoperationevent_moderationaction_and_more",
|
||||
),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="moderationqueue",
|
||||
options={"ordering": ["priority", "created_at"]},
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="moderationqueue",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="moderationqueue",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="bulkoperation",
|
||||
name="moderation__operati_bc84d9_idx",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="moderationaction",
|
||||
name="moderation__action__7d7882_idx",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="moderationqueue",
|
||||
name="moderation__entity__7c66ff_idx",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="moderationqueue",
|
||||
name="moderation__flagged_169834_idx",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="moderationreport",
|
||||
name="moderation__reporte_04923f_idx",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="can_cancel",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Whether this operation can be cancelled"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="description",
|
||||
field=models.TextField(help_text="Description of what this operation does"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="estimated_duration_minutes",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, help_text="Estimated duration in minutes", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="failed_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items that failed"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="operation_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("UPDATE_PARKS", "Update Parks"),
|
||||
("UPDATE_RIDES", "Update Rides"),
|
||||
("IMPORT_DATA", "Import Data"),
|
||||
("EXPORT_DATA", "Export Data"),
|
||||
("MODERATE_CONTENT", "Moderate Content"),
|
||||
("USER_ACTIONS", "User Actions"),
|
||||
("CLEANUP", "Cleanup"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="parameters",
|
||||
field=models.JSONField(
|
||||
default=dict, help_text="Parameters for the operation"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="priority",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="processed_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items processed"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="results",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Results and output from the operation",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="schedule_for",
|
||||
field=models.DateTimeField(
|
||||
blank=True, help_text="When to run this operation", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="total_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of items to process"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="can_cancel",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Whether this operation can be cancelled"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="description",
|
||||
field=models.TextField(help_text="Description of what this operation does"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="estimated_duration_minutes",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, help_text="Estimated duration in minutes", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="failed_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items that failed"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="id",
|
||||
field=models.BigIntegerField(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="operation_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("UPDATE_PARKS", "Update Parks"),
|
||||
("UPDATE_RIDES", "Update Rides"),
|
||||
("IMPORT_DATA", "Import Data"),
|
||||
("EXPORT_DATA", "Export Data"),
|
||||
("MODERATE_CONTENT", "Moderate Content"),
|
||||
("USER_ACTIONS", "User Actions"),
|
||||
("CLEANUP", "Cleanup"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="parameters",
|
||||
field=models.JSONField(
|
||||
default=dict, help_text="Parameters for the operation"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="priority",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="processed_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items processed"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="results",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Results and output from the operation",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="schedule_for",
|
||||
field=models.DateTimeField(
|
||||
blank=True, help_text="When to run this operation", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="total_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of items to process"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="action_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("WARNING", "Warning"),
|
||||
("USER_SUSPENSION", "User Suspension"),
|
||||
("USER_BAN", "User Ban"),
|
||||
("CONTENT_REMOVAL", "Content Removal"),
|
||||
("CONTENT_EDIT", "Content Edit"),
|
||||
("CONTENT_RESTRICTION", "Content Restriction"),
|
||||
("ACCOUNT_RESTRICTION", "Account Restriction"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="details",
|
||||
field=models.TextField(help_text="Detailed explanation of the action"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="duration_hours",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Duration in hours for temporary actions",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="expires_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True, help_text="When this action expires", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="is_active",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Whether this action is currently active"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="reason",
|
||||
field=models.CharField(
|
||||
help_text="Brief reason for the action", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="action_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("WARNING", "Warning"),
|
||||
("USER_SUSPENSION", "User Suspension"),
|
||||
("USER_BAN", "User Ban"),
|
||||
("CONTENT_REMOVAL", "Content Removal"),
|
||||
("CONTENT_EDIT", "Content Edit"),
|
||||
("CONTENT_RESTRICTION", "Content Restriction"),
|
||||
("ACCOUNT_RESTRICTION", "Account Restriction"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="details",
|
||||
field=models.TextField(help_text="Detailed explanation of the action"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="duration_hours",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Duration in hours for temporary actions",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="expires_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True, help_text="When this action expires", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="is_active",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Whether this action is currently active"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="reason",
|
||||
field=models.CharField(
|
||||
help_text="Brief reason for the action", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="description",
|
||||
field=models.TextField(
|
||||
help_text="Detailed description of what needs to be done"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="entity_id",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, help_text="ID of the related entity", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="entity_preview",
|
||||
field=models.JSONField(
|
||||
blank=True, default=dict, help_text="Preview data for the entity"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="entity_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Type of entity (park, ride, user, etc.)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="estimated_review_time",
|
||||
field=models.PositiveIntegerField(
|
||||
default=30, help_text="Estimated time in minutes"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="flagged_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="flagged_queue_items",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="item_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("CONTENT_REVIEW", "Content Review"),
|
||||
("USER_REVIEW", "User Review"),
|
||||
("BULK_ACTION", "Bulk Action"),
|
||||
("POLICY_VIOLATION", "Policy Violation"),
|
||||
("APPEAL", "Appeal"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("IN_PROGRESS", "In Progress"),
|
||||
("COMPLETED", "Completed"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="tags",
|
||||
field=models.JSONField(
|
||||
blank=True, default=list, help_text="Tags for categorization"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="title",
|
||||
field=models.CharField(
|
||||
help_text="Brief title for the queue item", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="description",
|
||||
field=models.TextField(
|
||||
help_text="Detailed description of what needs to be done"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="entity_id",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, help_text="ID of the related entity", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="entity_preview",
|
||||
field=models.JSONField(
|
||||
blank=True, default=dict, help_text="Preview data for the entity"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="entity_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Type of entity (park, ride, user, etc.)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="estimated_review_time",
|
||||
field=models.PositiveIntegerField(
|
||||
default=30, help_text="Estimated time in minutes"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="flagged_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="item_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("CONTENT_REVIEW", "Content Review"),
|
||||
("USER_REVIEW", "User Review"),
|
||||
("BULK_ACTION", "Bulk Action"),
|
||||
("POLICY_VIOLATION", "Policy Violation"),
|
||||
("APPEAL", "Appeal"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("IN_PROGRESS", "In Progress"),
|
||||
("COMPLETED", "Completed"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="tags",
|
||||
field=models.JSONField(
|
||||
blank=True, default=list, help_text="Tags for categorization"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="title",
|
||||
field=models.CharField(
|
||||
help_text="Brief title for the queue item", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="description",
|
||||
field=models.TextField(help_text="Detailed description of the issue"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="evidence_urls",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="URLs to evidence (screenshots, etc.)",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="reason",
|
||||
field=models.CharField(
|
||||
help_text="Brief reason for the report", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="report_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("SPAM", "Spam"),
|
||||
("HARASSMENT", "Harassment"),
|
||||
("INAPPROPRIATE_CONTENT", "Inappropriate Content"),
|
||||
("MISINFORMATION", "Misinformation"),
|
||||
("COPYRIGHT", "Copyright Violation"),
|
||||
("PRIVACY", "Privacy Violation"),
|
||||
("HATE_SPEECH", "Hate Speech"),
|
||||
("VIOLENCE", "Violence or Threats"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="reported_entity_id",
|
||||
field=models.PositiveIntegerField(
|
||||
help_text="ID of the entity being reported"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="reported_entity_type",
|
||||
field=models.CharField(
|
||||
help_text="Type of entity being reported (park, ride, user, etc.)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="resolution_action",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default=django.utils.timezone.now,
|
||||
help_text="Action taken to resolve",
|
||||
max_length=100,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="resolution_notes",
|
||||
field=models.TextField(blank=True, help_text="Notes about the resolution"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending Review"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("RESOLVED", "Resolved"),
|
||||
("DISMISSED", "Dismissed"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="description",
|
||||
field=models.TextField(help_text="Detailed description of the issue"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="evidence_urls",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="URLs to evidence (screenshots, etc.)",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="reason",
|
||||
field=models.CharField(
|
||||
help_text="Brief reason for the report", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="report_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("SPAM", "Spam"),
|
||||
("HARASSMENT", "Harassment"),
|
||||
("INAPPROPRIATE_CONTENT", "Inappropriate Content"),
|
||||
("MISINFORMATION", "Misinformation"),
|
||||
("COPYRIGHT", "Copyright Violation"),
|
||||
("PRIVACY", "Privacy Violation"),
|
||||
("HATE_SPEECH", "Hate Speech"),
|
||||
("VIOLENCE", "Violence or Threats"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="reported_entity_id",
|
||||
field=models.PositiveIntegerField(
|
||||
help_text="ID of the entity being reported"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="reported_entity_type",
|
||||
field=models.CharField(
|
||||
help_text="Type of entity being reported (park, ride, user, etc.)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="resolution_action",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default=django.utils.timezone.now,
|
||||
help_text="Action taken to resolve",
|
||||
max_length=100,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="resolution_notes",
|
||||
field=models.TextField(blank=True, help_text="Notes about the resolution"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending Review"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("RESOLVED", "Resolved"),
|
||||
("DISMISSED", "Dismissed"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bulkoperation",
|
||||
index=models.Index(
|
||||
fields=["schedule_for"], name="moderation__schedul_350704_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bulkoperation",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="moderation__created_b705f4_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationaction",
|
||||
index=models.Index(
|
||||
fields=["moderator"], name="moderation__moderat_1c19b0_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationaction",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="moderation__created_6378e6_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationqueue",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="moderation__created_fe6dd0_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationreport",
|
||||
index=models.Index(
|
||||
fields=["reported_by"], name="moderation__reporte_81af56_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationreport",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="moderation__created_ae337c_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="moderationqueue",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "moderation_moderationqueueevent" ("assigned_at", "assigned_to_id", "content_type_id", "created_at", "description", "entity_id", "entity_preview", "entity_type", "estimated_review_time", "flagged_by_id", "id", "item_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "related_report_id", "status", "tags", "title", "updated_at") VALUES (NEW."assigned_at", NEW."assigned_to_id", NEW."content_type_id", NEW."created_at", NEW."description", NEW."entity_id", NEW."entity_preview", NEW."entity_type", NEW."estimated_review_time", NEW."flagged_by_id", NEW."id", NEW."item_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."priority", NEW."related_report_id", NEW."status", NEW."tags", NEW."title", NEW."updated_at"); RETURN NULL;',
|
||||
hash="55993d8cb4981feed7b3febde9e87989481a8a34",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_cf9cb",
|
||||
table="moderation_moderationqueue",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="moderationqueue",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "moderation_moderationqueueevent" ("assigned_at", "assigned_to_id", "content_type_id", "created_at", "description", "entity_id", "entity_preview", "entity_type", "estimated_review_time", "flagged_by_id", "id", "item_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "related_report_id", "status", "tags", "title", "updated_at") VALUES (NEW."assigned_at", NEW."assigned_to_id", NEW."content_type_id", NEW."created_at", NEW."description", NEW."entity_id", NEW."entity_preview", NEW."entity_type", NEW."estimated_review_time", NEW."flagged_by_id", NEW."id", NEW."item_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."priority", NEW."related_report_id", NEW."status", NEW."tags", NEW."title", NEW."updated_at"); RETURN NULL;',
|
||||
hash="8da070419fd1efd43bfb272a431392b6244a7739",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_3b3aa",
|
||||
table="moderation_moderationqueue",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,17 @@
|
||||
from typing import Any, Dict, Optional, Type, Union
|
||||
"""
|
||||
Moderation Models
|
||||
|
||||
This module contains models for the ThrillWiki moderation system, including:
|
||||
- EditSubmission: Original content submission and approval workflow
|
||||
- ModerationReport: User reports for content moderation
|
||||
- ModerationQueue: Workflow management for moderation tasks
|
||||
- ModerationAction: Actions taken against users/content
|
||||
- BulkOperation: Administrative bulk operations
|
||||
|
||||
All models use pghistory for change tracking and TrackedModel base class.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -7,12 +20,17 @@ from django.utils import timezone
|
||||
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from datetime import timedelta
|
||||
import pghistory
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
UserType = Union[AbstractBaseUser, AnonymousUser]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Original EditSubmission Model (Preserved)
|
||||
# ============================================================================
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class EditSubmission(TrackedModel):
|
||||
STATUS_CHOICES = [
|
||||
@@ -79,7 +97,7 @@ class EditSubmission(TrackedModel):
|
||||
blank=True, help_text="Notes from the moderator about this submission"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
@@ -103,128 +121,508 @@ 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 related_model := field.related_model:
|
||||
resolved_data[field_name] = related_model.objects.get(pk=value)
|
||||
except (FieldDoesNotExist, ObjectDoesNotExist):
|
||||
field = model_class._meta.get_field(field_name)
|
||||
if isinstance(field, models.ForeignKey) and value is not None:
|
||||
try:
|
||||
related_obj = field.related_model.objects.get(pk=value)
|
||||
resolved_data[field_name] = related_obj
|
||||
except ObjectDoesNotExist:
|
||||
raise ValueError(
|
||||
f"Related object {field.related_model.__name__} with pk={value} does not exist"
|
||||
)
|
||||
except FieldDoesNotExist:
|
||||
# Field doesn't exist on model, skip it
|
||||
continue
|
||||
|
||||
return resolved_data
|
||||
|
||||
def _prepare_model_data(
|
||||
self, data: Dict[str, Any], model_class: Type[models.Model]
|
||||
) -> Dict[str, Any]:
|
||||
"""Prepare data for model creation/update by filtering out auto-generated fields"""
|
||||
prepared_data = data.copy()
|
||||
def _get_final_changes(self) -> Dict[str, Any]:
|
||||
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
|
||||
return self.moderator_changes or self.changes
|
||||
|
||||
# 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)
|
||||
def approve(self, moderator: UserType) -> Optional[models.Model]:
|
||||
"""
|
||||
Approve this submission and apply the changes.
|
||||
|
||||
# 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()
|
||||
Args:
|
||||
moderator: The user approving the submission
|
||||
|
||||
return prepared_data
|
||||
Returns:
|
||||
The created or updated model instance
|
||||
|
||||
def _check_duplicate_name(
|
||||
self, model_class: Type[models.Model], name: str
|
||||
) -> Optional[models.Model]:
|
||||
"""Check if an object with the same name already exists"""
|
||||
try:
|
||||
return model_class.objects.filter(name=name).first()
|
||||
except BaseException as e:
|
||||
print(f"Error checking for duplicate name '{name}': {e}")
|
||||
raise e
|
||||
return None
|
||||
Raises:
|
||||
ValueError: If submission cannot be approved
|
||||
ValidationError: If the data is invalid
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot approve submission with status {self.status}")
|
||||
|
||||
def approve(self, user: UserType) -> Optional[models.Model]:
|
||||
"""Approve the submission and apply the changes"""
|
||||
if not (model_class := self.content_type.model_class()):
|
||||
model_class = self.content_type.model_class()
|
||||
if not model_class:
|
||||
raise ValueError("Could not resolve model class")
|
||||
|
||||
final_changes = self._get_final_changes()
|
||||
resolved_changes = self._resolve_foreign_keys(final_changes)
|
||||
|
||||
try:
|
||||
# Use moderator_changes if available, otherwise use original
|
||||
# changes
|
||||
changes_to_apply = (
|
||||
self.moderator_changes
|
||||
if self.moderator_changes is not None
|
||||
else self.changes
|
||||
)
|
||||
|
||||
resolved_data = self._resolve_foreign_keys(changes_to_apply)
|
||||
prepared_data = self._prepare_model_data(resolved_data, model_class)
|
||||
|
||||
# For CREATE submissions, check for duplicates by name
|
||||
if self.submission_type == "CREATE" and "name" in prepared_data:
|
||||
if existing_obj := self._check_duplicate_name(
|
||||
model_class, prepared_data["name"]
|
||||
):
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"A {model_class.__name__} with the name '{
|
||||
prepared_data['name']
|
||||
}' already exists (ID: {existing_obj.pk})"
|
||||
self.save()
|
||||
raise ValueError(self.notes)
|
||||
|
||||
self.status = "APPROVED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
|
||||
if self.submission_type == "CREATE":
|
||||
# Create new object
|
||||
obj = model_class(**prepared_data)
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
obj = model_class(**resolved_changes)
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
# Update object_id after creation
|
||||
self.object_id = getattr(obj, "id", None)
|
||||
else:
|
||||
# Apply changes to existing object
|
||||
if not (obj := self.content_object):
|
||||
raise ValueError("Content object not found")
|
||||
for field, value in prepared_data.items():
|
||||
setattr(obj, field, value)
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
# Update existing object
|
||||
if not self.content_object:
|
||||
raise ValueError("Cannot update: content object not found")
|
||||
|
||||
obj = self.content_object
|
||||
for field_name, value in resolved_changes.items():
|
||||
if hasattr(obj, field_name):
|
||||
setattr(obj, field_name, value)
|
||||
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
self.full_clean()
|
||||
# Mark submission as approved
|
||||
self.status = "APPROVED"
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
return obj
|
||||
|
||||
except Exception as e:
|
||||
if (
|
||||
self.status != "REJECTED"
|
||||
): # Don't override if already rejected due to duplicate
|
||||
self.status = "PENDING" # Reset status if approval failed
|
||||
self.save()
|
||||
raise ValueError(f"Error approving submission: {str(e)}") from e
|
||||
# Mark as rejected on any error
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Approval failed: {str(e)}"
|
||||
self.save()
|
||||
raise
|
||||
|
||||
def reject(self, moderator: UserType, reason: str) -> None:
|
||||
"""
|
||||
Reject this submission.
|
||||
|
||||
Args:
|
||||
moderator: The user rejecting the submission
|
||||
reason: Reason for rejection
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot reject submission with status {self.status}")
|
||||
|
||||
def reject(self, user: UserType) -> None:
|
||||
"""Reject the submission"""
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Rejected: {reason}"
|
||||
self.save()
|
||||
|
||||
def escalate(self, user: UserType) -> None:
|
||||
"""Escalate the submission to admin"""
|
||||
def escalate(self, moderator: UserType, reason: str) -> None:
|
||||
"""
|
||||
Escalate this submission for higher-level review.
|
||||
|
||||
Args:
|
||||
moderator: The user escalating the submission
|
||||
reason: Reason for escalation
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot escalate submission with status {self.status}")
|
||||
|
||||
self.status = "ESCALATED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Escalated: {reason}"
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def submitted_by(self):
|
||||
"""Alias for user field to maintain compatibility"""
|
||||
return self.user
|
||||
|
||||
@property
|
||||
def submitted_at(self):
|
||||
"""Alias for created_at field to maintain compatibility"""
|
||||
return self.created_at
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# New Moderation System Models
|
||||
# ============================================================================
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationReport(TrackedModel):
|
||||
"""
|
||||
Model for tracking user reports about content, users, or behavior.
|
||||
|
||||
This handles the initial reporting phase where users flag content
|
||||
or behavior that needs moderator attention.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending Review'),
|
||||
('UNDER_REVIEW', 'Under Review'),
|
||||
('RESOLVED', 'Resolved'),
|
||||
('DISMISSED', 'Dismissed'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
REPORT_TYPE_CHOICES = [
|
||||
('SPAM', 'Spam'),
|
||||
('HARASSMENT', 'Harassment'),
|
||||
('INAPPROPRIATE_CONTENT', 'Inappropriate Content'),
|
||||
('MISINFORMATION', 'Misinformation'),
|
||||
('COPYRIGHT', 'Copyright Violation'),
|
||||
('PRIVACY', 'Privacy Violation'),
|
||||
('HATE_SPEECH', 'Hate Speech'),
|
||||
('VIOLENCE', 'Violence or Threats'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Report details
|
||||
report_type = models.CharField(max_length=50, choices=REPORT_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
|
||||
# What is being reported
|
||||
reported_entity_type = models.CharField(
|
||||
max_length=50, help_text="Type of entity being reported (park, ride, user, etc.)")
|
||||
reported_entity_id = models.PositiveIntegerField(
|
||||
help_text="ID of the entity being reported")
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
# Report content
|
||||
reason = models.CharField(max_length=200, help_text="Brief reason for the report")
|
||||
description = models.TextField(help_text="Detailed description of the issue")
|
||||
evidence_urls = models.JSONField(
|
||||
default=list, blank=True, help_text="URLs to evidence (screenshots, etc.)")
|
||||
|
||||
# Users involved
|
||||
reported_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_reports_made'
|
||||
)
|
||||
assigned_moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='assigned_moderation_reports'
|
||||
)
|
||||
|
||||
# Resolution
|
||||
resolution_action = models.CharField(
|
||||
max_length=100, blank=True, help_text="Action taken to resolve")
|
||||
resolution_notes = models.TextField(
|
||||
blank=True, help_text="Notes about the resolution")
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['reported_by']),
|
||||
models.Index(fields=['assigned_moderator']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_report_type_display()} report by {self.reported_by.username}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationQueue(TrackedModel):
|
||||
"""
|
||||
Model for managing moderation workflow and task assignment.
|
||||
|
||||
This represents items in the moderation queue that need attention,
|
||||
separate from the initial reports.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('IN_PROGRESS', 'In Progress'),
|
||||
('COMPLETED', 'Completed'),
|
||||
('CANCELLED', 'Cancelled'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
ITEM_TYPE_CHOICES = [
|
||||
('CONTENT_REVIEW', 'Content Review'),
|
||||
('USER_REVIEW', 'User Review'),
|
||||
('BULK_ACTION', 'Bulk Action'),
|
||||
('POLICY_VIOLATION', 'Policy Violation'),
|
||||
('APPEAL', 'Appeal'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Queue item details
|
||||
item_type = models.CharField(max_length=50, choices=ITEM_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
|
||||
title = models.CharField(max_length=200, help_text="Brief title for the queue item")
|
||||
description = models.TextField(
|
||||
help_text="Detailed description of what needs to be done")
|
||||
|
||||
# What entity this relates to
|
||||
entity_type = models.CharField(
|
||||
max_length=50, blank=True, help_text="Type of entity (park, ride, user, etc.)")
|
||||
entity_id = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="ID of the related entity")
|
||||
entity_preview = models.JSONField(
|
||||
default=dict, blank=True, help_text="Preview data for the entity")
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
# Assignment and timing
|
||||
assigned_to = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='assigned_queue_items'
|
||||
)
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
estimated_review_time = models.PositiveIntegerField(
|
||||
default=30, help_text="Estimated time in minutes")
|
||||
|
||||
# Metadata
|
||||
flagged_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='flagged_queue_items'
|
||||
)
|
||||
tags = models.JSONField(default=list, blank=True,
|
||||
help_text="Tags for categorization")
|
||||
|
||||
# Related objects
|
||||
related_report = models.ForeignKey(
|
||||
ModerationReport,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='queue_items'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['priority', 'created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['assigned_to']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_item_type_display()}: {self.title}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationAction(TrackedModel):
|
||||
"""
|
||||
Model for tracking actions taken against users or content.
|
||||
|
||||
This records what actions moderators have taken, including
|
||||
warnings, suspensions, content removal, etc.
|
||||
"""
|
||||
|
||||
ACTION_TYPE_CHOICES = [
|
||||
('WARNING', 'Warning'),
|
||||
('USER_SUSPENSION', 'User Suspension'),
|
||||
('USER_BAN', 'User Ban'),
|
||||
('CONTENT_REMOVAL', 'Content Removal'),
|
||||
('CONTENT_EDIT', 'Content Edit'),
|
||||
('CONTENT_RESTRICTION', 'Content Restriction'),
|
||||
('ACCOUNT_RESTRICTION', 'Account Restriction'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Action details
|
||||
action_type = models.CharField(max_length=50, choices=ACTION_TYPE_CHOICES)
|
||||
reason = models.CharField(max_length=200, help_text="Brief reason for the action")
|
||||
details = models.TextField(help_text="Detailed explanation of the action")
|
||||
|
||||
# Duration (for temporary actions)
|
||||
duration_hours = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Duration in hours for temporary actions"
|
||||
)
|
||||
expires_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When this action expires")
|
||||
is_active = models.BooleanField(
|
||||
default=True, help_text="Whether this action is currently active")
|
||||
|
||||
# Users involved
|
||||
moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_actions_taken'
|
||||
)
|
||||
target_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_actions_received'
|
||||
)
|
||||
|
||||
# Related objects
|
||||
related_report = models.ForeignKey(
|
||||
ModerationReport,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='actions_taken'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['target_user', 'is_active']),
|
||||
models.Index(fields=['moderator']),
|
||||
models.Index(fields=['expires_at']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Set expiration time if duration is provided
|
||||
if self.duration_hours and not self.expires_at:
|
||||
self.expires_at = timezone.now() + timedelta(hours=self.duration_hours)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class BulkOperation(TrackedModel):
|
||||
"""
|
||||
Model for tracking bulk administrative operations.
|
||||
|
||||
This handles large-scale operations like bulk updates,
|
||||
imports, exports, or mass moderation actions.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('RUNNING', 'Running'),
|
||||
('COMPLETED', 'Completed'),
|
||||
('FAILED', 'Failed'),
|
||||
('CANCELLED', 'Cancelled'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
OPERATION_TYPE_CHOICES = [
|
||||
('UPDATE_PARKS', 'Update Parks'),
|
||||
('UPDATE_RIDES', 'Update Rides'),
|
||||
('IMPORT_DATA', 'Import Data'),
|
||||
('EXPORT_DATA', 'Export Data'),
|
||||
('MODERATE_CONTENT', 'Moderate Content'),
|
||||
('USER_ACTIONS', 'User Actions'),
|
||||
('CLEANUP', 'Cleanup'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Operation details
|
||||
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
description = models.TextField(help_text="Description of what this operation does")
|
||||
|
||||
# Operation parameters and results
|
||||
parameters = models.JSONField(
|
||||
default=dict, help_text="Parameters for the operation")
|
||||
results = models.JSONField(default=dict, blank=True,
|
||||
help_text="Results and output from the operation")
|
||||
|
||||
# Progress tracking
|
||||
total_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of items to process")
|
||||
processed_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items processed")
|
||||
failed_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items that failed")
|
||||
|
||||
# Timing
|
||||
estimated_duration_minutes = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Estimated duration in minutes"
|
||||
)
|
||||
schedule_for = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When to run this operation")
|
||||
|
||||
# Control
|
||||
can_cancel = models.BooleanField(
|
||||
default=True, help_text="Whether this operation can be cancelled")
|
||||
|
||||
# User who created the operation
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bulk_operations_created'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['created_by']),
|
||||
models.Index(fields=['schedule_for']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_operation_type_display()}: {self.description[:50]}"
|
||||
|
||||
@property
|
||||
def progress_percentage(self):
|
||||
"""Calculate progress percentage."""
|
||||
if self.total_items == 0:
|
||||
return 0.0
|
||||
return round((self.processed_items / self.total_items) * 100, 2)
|
||||
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class PhotoSubmission(TrackedModel):
|
||||
@@ -270,7 +668,7 @@ class PhotoSubmission(TrackedModel):
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
@@ -319,7 +717,7 @@ class PhotoSubmission(TrackedModel):
|
||||
self.save()
|
||||
|
||||
def auto_approve(self) -> None:
|
||||
"""Auto-approve submissions from moderators"""
|
||||
"""Auto - approve submissions from moderators"""
|
||||
# Get user role safely
|
||||
user_role = getattr(self.user, "role", None)
|
||||
|
||||
|
||||
318
backend/apps/moderation/permissions.py
Normal file
318
backend/apps/moderation/permissions.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Moderation Permissions
|
||||
|
||||
This module contains custom permission classes for the moderation system,
|
||||
providing role-based access control for moderation operations.
|
||||
"""
|
||||
|
||||
from rest_framework import permissions
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class IsModerator(permissions.BasePermission):
|
||||
"""
|
||||
Permission that only allows moderators to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderator role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role == "MODERATOR"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for moderators."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class IsModeratorOrAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows moderators, admins, and superusers to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderator, admin, or superuser role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for moderators and admins."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class IsAdminOrSuperuser(permissions.BasePermission):
|
||||
"""
|
||||
Permission that only allows admins and superusers to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has admin or superuser role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for admins and superusers."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class CanViewModerationData(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to view moderation data based on their role.
|
||||
|
||||
- Regular users can only view their own reports
|
||||
- Moderators and above can view all moderation data
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated."""
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for viewing moderation data."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Moderators and above can view all data
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
return True
|
||||
|
||||
# Regular users can only view their own reports
|
||||
if hasattr(obj, "reported_by"):
|
||||
return obj.reported_by == request.user
|
||||
|
||||
# For other objects, deny access to regular users
|
||||
return False
|
||||
|
||||
|
||||
class CanModerateContent(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to moderate content based on their role.
|
||||
|
||||
- Only moderators and above can moderate content
|
||||
- Includes additional checks for specific moderation actions
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderation privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for content moderation."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can do everything
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can moderate most content but may have some restrictions
|
||||
if user_role == "ADMIN":
|
||||
# Add any admin-specific restrictions here if needed
|
||||
return True
|
||||
|
||||
# Moderators have basic moderation permissions
|
||||
if user_role == "MODERATOR":
|
||||
# Add any moderator-specific restrictions here if needed
|
||||
# For example, moderators might not be able to moderate admin actions
|
||||
if hasattr(obj, "moderator") and obj.moderator:
|
||||
moderator_role = getattr(obj.moderator, "role", "USER")
|
||||
if moderator_role in ["ADMIN", "SUPERUSER"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanAssignModerationTasks(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to assign moderation tasks to others.
|
||||
|
||||
- Moderators can assign tasks to themselves
|
||||
- Admins can assign tasks to moderators and themselves
|
||||
- Superusers can assign tasks to anyone
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has assignment privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for task assignment."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can assign to anyone
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can assign to moderators and themselves
|
||||
if user_role == "ADMIN":
|
||||
return True
|
||||
|
||||
# Moderators can only assign to themselves
|
||||
if user_role == "MODERATOR":
|
||||
# Check if they're trying to assign to themselves
|
||||
assignee_id = request.data.get("moderator_id") or request.data.get(
|
||||
"assigned_to"
|
||||
)
|
||||
if assignee_id:
|
||||
return str(assignee_id) == str(request.user.id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanPerformBulkOperations(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to perform bulk operations.
|
||||
|
||||
- Only admins and superusers can perform bulk operations
|
||||
- Includes additional safety checks for destructive operations
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has bulk operation privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for bulk operations."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can perform all bulk operations
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can perform most bulk operations
|
||||
if user_role == "ADMIN":
|
||||
# Add any admin-specific restrictions for bulk operations here
|
||||
# For example, admins might not be able to perform certain destructive operations
|
||||
operation_type = getattr(obj, "operation_type", None)
|
||||
if operation_type in ["DELETE_USERS", "PURGE_DATA"]:
|
||||
return False # Only superusers can perform these operations
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsOwnerOrModerator(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows object owners or moderators to access the view.
|
||||
|
||||
- Users can access their own objects
|
||||
- Moderators and above can access any object
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated."""
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for owners or moderators."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Moderators and above can access any object
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
return True
|
||||
|
||||
# Check if user is the owner of the object
|
||||
if hasattr(obj, "reported_by"):
|
||||
return obj.reported_by == request.user
|
||||
elif hasattr(obj, "created_by"):
|
||||
return obj.created_by == request.user
|
||||
elif hasattr(obj, "user"):
|
||||
return obj.user == request.user
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanManageUserRestrictions(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to manage user restrictions and moderation actions.
|
||||
|
||||
- Moderators can create basic restrictions (warnings, temporary suspensions)
|
||||
- Admins can create more severe restrictions (longer suspensions, content removal)
|
||||
- Superusers can create any restriction including permanent bans
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has restriction management privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for managing user restrictions."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can manage any restriction
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Get the action type from request data or object
|
||||
action_type = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
action_type = request.data.get("action_type")
|
||||
elif hasattr(obj, "action_type"):
|
||||
action_type = obj.action_type
|
||||
|
||||
# Admins can manage most restrictions
|
||||
if user_role == "ADMIN":
|
||||
# Admins cannot create permanent bans
|
||||
if action_type == "USER_BAN" and request.data.get("duration_hours") is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Moderators can only manage basic restrictions
|
||||
if user_role == "MODERATOR":
|
||||
allowed_actions = ["WARNING", "CONTENT_REMOVAL", "USER_SUSPENSION"]
|
||||
if action_type not in allowed_actions:
|
||||
return False
|
||||
|
||||
# Moderators can only create temporary suspensions (max 7 days)
|
||||
if action_type == "USER_SUSPENSION":
|
||||
duration_hours = request.data.get("duration_hours", 0)
|
||||
if duration_hours > 168: # 7 days = 168 hours
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -27,14 +27,14 @@ def pending_submissions_for_review(
|
||||
"""
|
||||
queryset = (
|
||||
EditSubmission.objects.filter(status="PENDING")
|
||||
.select_related("submitted_by", "content_type")
|
||||
.select_related("user", "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("created_at")[:limit]
|
||||
|
||||
|
||||
def submissions_by_user(
|
||||
@@ -50,14 +50,14 @@ def submissions_by_user(
|
||||
Returns:
|
||||
QuerySet of user's submissions
|
||||
"""
|
||||
queryset = EditSubmission.objects.filter(submitted_by_id=user_id).select_related(
|
||||
queryset = EditSubmission.objects.filter(user_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("-created_at")
|
||||
|
||||
|
||||
def submissions_handled_by_moderator(
|
||||
@@ -79,7 +79,7 @@ def submissions_handled_by_moderator(
|
||||
EditSubmission.objects.filter(
|
||||
handled_by_id=moderator_id, handled_at__gte=cutoff_date
|
||||
)
|
||||
.select_related("submitted_by", "content_type")
|
||||
.select_related("user", "content_type")
|
||||
.order_by("-handled_at")
|
||||
)
|
||||
|
||||
@@ -97,9 +97,9 @@ def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]:
|
||||
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")
|
||||
EditSubmission.objects.filter(created_at__gte=cutoff_date)
|
||||
.select_related("user", "content_type", "handled_by")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
|
||||
@@ -118,12 +118,12 @@ def submissions_by_content_type(
|
||||
"""
|
||||
queryset = EditSubmission.objects.filter(
|
||||
content_type__model=content_type.lower()
|
||||
).select_related("submitted_by", "handled_by")
|
||||
).select_related("user", "handled_by")
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset.order_by("-submitted_at")
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
|
||||
def moderation_queue_summary() -> Dict[str, Any]:
|
||||
@@ -172,7 +172,7 @@ def moderation_statistics_summary(
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
base_queryset = EditSubmission.objects.filter(submitted_at__gte=cutoff_date)
|
||||
base_queryset = EditSubmission.objects.filter(created_at__gte=cutoff_date)
|
||||
|
||||
if moderator:
|
||||
handled_queryset = base_queryset.filter(handled_by=moderator)
|
||||
@@ -189,7 +189,7 @@ def moderation_statistics_summary(
|
||||
handled_queryset.exclude(handled_at__isnull=True)
|
||||
.extra(
|
||||
select={
|
||||
"response_hours": "EXTRACT(EPOCH FROM (handled_at - submitted_at)) / 3600"
|
||||
"response_hours": "EXTRACT(EPOCH FROM (handled_at - created_at)) / 3600"
|
||||
}
|
||||
)
|
||||
.values_list("response_hours", flat=True)
|
||||
@@ -228,9 +228,9 @@ def submissions_needing_attention(*, hours: int = 24) -> QuerySet[EditSubmission
|
||||
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")
|
||||
EditSubmission.objects.filter(status="PENDING", created_at__lte=cutoff_time)
|
||||
.select_related("user", "content_type")
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ def top_contributors(*, days: int = 30, limit: int = 10) -> QuerySet[User]:
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
User.objects.filter(edit_submissions__submitted_at__gte=cutoff_date)
|
||||
User.objects.filter(edit_submissions__created_at__gte=cutoff_date)
|
||||
.annotate(submission_count=Count("edit_submissions"))
|
||||
.filter(submission_count__gt=0)
|
||||
.order_by("-submission_count")[:limit]
|
||||
|
||||
735
backend/apps/moderation/serializers.py
Normal file
735
backend/apps/moderation/serializers.py
Normal file
@@ -0,0 +1,735 @@
|
||||
"""
|
||||
Moderation API Serializers
|
||||
|
||||
This module contains DRF serializers for the moderation system, including:
|
||||
- ModerationReport serializers for content reporting
|
||||
- ModerationQueue serializers for moderation workflow
|
||||
- ModerationAction serializers for tracking moderation actions
|
||||
- BulkOperation serializers for administrative bulk operations
|
||||
|
||||
All serializers include comprehensive validation and nested relationships.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from .models import (
|
||||
ModerationReport,
|
||||
ModerationQueue,
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Base Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
"""Basic user information for moderation contexts."""
|
||||
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "display_name", "email", "role"]
|
||||
read_only_fields = ["id", "username", "display_name", "email", "role"]
|
||||
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
class ContentTypeSerializer(serializers.ModelSerializer):
|
||||
"""Content type information for generic foreign keys."""
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ["id", "app_label", "model"]
|
||||
read_only_fields = ["id", "app_label", "model"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Report Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationReportSerializer(serializers.ModelSerializer):
|
||||
"""Full moderation report serializer with all details."""
|
||||
|
||||
reported_by = UserBasicSerializer(read_only=True)
|
||||
assigned_moderator = UserBasicSerializer(read_only=True)
|
||||
content_type = ContentTypeSerializer(read_only=True)
|
||||
|
||||
# Computed fields
|
||||
is_overdue = serializers.SerializerMethodField()
|
||||
time_since_created = serializers.SerializerMethodField()
|
||||
priority_display = serializers.CharField(
|
||||
source="get_priority_display", read_only=True
|
||||
)
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
report_type_display = serializers.CharField(
|
||||
source="get_report_type_display", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"id",
|
||||
"report_type",
|
||||
"report_type_display",
|
||||
"status",
|
||||
"status_display",
|
||||
"priority",
|
||||
"priority_display",
|
||||
"reported_entity_type",
|
||||
"reported_entity_id",
|
||||
"reason",
|
||||
"description",
|
||||
"evidence_urls",
|
||||
"resolved_at",
|
||||
"resolution_notes",
|
||||
"resolution_action",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reported_by",
|
||||
"assigned_moderator",
|
||||
"content_type",
|
||||
"is_overdue",
|
||||
"time_since_created",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reported_by",
|
||||
"content_type",
|
||||
"is_overdue",
|
||||
"time_since_created",
|
||||
"report_type_display",
|
||||
"status_display",
|
||||
"priority_display",
|
||||
]
|
||||
|
||||
def get_is_overdue(self, obj) -> bool:
|
||||
"""Check if report is overdue based on priority."""
|
||||
if obj.status in ["RESOLVED", "DISMISSED"]:
|
||||
return False
|
||||
|
||||
now = timezone.now()
|
||||
hours_since_created = (now - obj.created_at).total_seconds() / 3600
|
||||
|
||||
# Define SLA hours by priority
|
||||
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
||||
|
||||
return hours_since_created > sla_hours.get(obj.priority, 24)
|
||||
|
||||
def get_time_since_created(self, obj) -> str:
|
||||
"""Human-readable time since creation."""
|
||||
now = timezone.now()
|
||||
diff = now - obj.created_at
|
||||
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} days ago"
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hours ago"
|
||||
else:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes ago"
|
||||
|
||||
|
||||
class CreateModerationReportSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating new moderation reports."""
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"report_type",
|
||||
"reported_entity_type",
|
||||
"reported_entity_id",
|
||||
"reason",
|
||||
"description",
|
||||
"evidence_urls",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate the report data."""
|
||||
# Validate entity type
|
||||
valid_entity_types = ["park", "ride", "review", "photo", "user", "comment"]
|
||||
if attrs["reported_entity_type"] not in valid_entity_types:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"reported_entity_type": f'Must be one of: {", ".join(valid_entity_types)}'
|
||||
}
|
||||
)
|
||||
|
||||
# Validate evidence URLs
|
||||
evidence_urls = attrs.get("evidence_urls", [])
|
||||
if not isinstance(evidence_urls, list):
|
||||
raise serializers.ValidationError(
|
||||
{"evidence_urls": "Must be a list of URLs"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new moderation report."""
|
||||
validated_data["reported_by"] = self.context["request"].user
|
||||
validated_data["status"] = "PENDING"
|
||||
validated_data["priority"] = "MEDIUM" # Default priority
|
||||
|
||||
# Set content type based on entity type
|
||||
entity_type = validated_data["reported_entity_type"]
|
||||
app_label_map = {
|
||||
"park": "parks",
|
||||
"ride": "rides",
|
||||
"review": "rides", # Assuming ride reviews
|
||||
"photo": "media",
|
||||
"user": "accounts",
|
||||
"comment": "core",
|
||||
}
|
||||
|
||||
if entity_type in app_label_map:
|
||||
try:
|
||||
content_type = ContentType.objects.get(
|
||||
app_label=app_label_map[entity_type], model=entity_type
|
||||
)
|
||||
validated_data["content_type"] = content_type
|
||||
except ContentType.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class UpdateModerationReportSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating moderation reports."""
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"status",
|
||||
"priority",
|
||||
"assigned_moderator",
|
||||
"resolution_notes",
|
||||
"resolution_action",
|
||||
]
|
||||
|
||||
def validate_status(self, value):
|
||||
"""Validate status transitions."""
|
||||
if self.instance and self.instance.status == "RESOLVED":
|
||||
if value != "RESOLVED":
|
||||
raise serializers.ValidationError(
|
||||
"Cannot change status of resolved report"
|
||||
)
|
||||
return value
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Update moderation report with automatic timestamps."""
|
||||
if "status" in validated_data and validated_data["status"] == "RESOLVED":
|
||||
validated_data["resolved_at"] = timezone.now()
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Queue Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationQueueSerializer(serializers.ModelSerializer):
|
||||
"""Full moderation queue item serializer."""
|
||||
|
||||
assigned_to = UserBasicSerializer(read_only=True)
|
||||
related_report = ModerationReportSerializer(read_only=True)
|
||||
content_type = ContentTypeSerializer(read_only=True)
|
||||
|
||||
# Computed fields
|
||||
is_overdue = serializers.SerializerMethodField()
|
||||
time_in_queue = serializers.SerializerMethodField()
|
||||
estimated_completion = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ModerationQueue
|
||||
fields = [
|
||||
"id",
|
||||
"item_type",
|
||||
"status",
|
||||
"priority",
|
||||
"title",
|
||||
"description",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"entity_preview",
|
||||
"flagged_by",
|
||||
"assigned_at",
|
||||
"estimated_review_time",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"tags",
|
||||
"assigned_to",
|
||||
"related_report",
|
||||
"content_type",
|
||||
"is_overdue",
|
||||
"time_in_queue",
|
||||
"estimated_completion",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"content_type",
|
||||
"is_overdue",
|
||||
"time_in_queue",
|
||||
"estimated_completion",
|
||||
]
|
||||
|
||||
def get_is_overdue(self, obj) -> bool:
|
||||
"""Check if queue item is overdue."""
|
||||
if obj.status == "COMPLETED":
|
||||
return False
|
||||
|
||||
if obj.assigned_at:
|
||||
time_assigned = (timezone.now() - obj.assigned_at).total_seconds() / 60
|
||||
return time_assigned > obj.estimated_review_time
|
||||
|
||||
# If not assigned, check time in queue
|
||||
time_in_queue = (timezone.now() - obj.created_at).total_seconds() / 60
|
||||
return time_in_queue > (obj.estimated_review_time * 2)
|
||||
|
||||
def get_time_in_queue(self, obj) -> int:
|
||||
"""Minutes since item was created."""
|
||||
return int((timezone.now() - obj.created_at).total_seconds() / 60)
|
||||
|
||||
def get_estimated_completion(self, obj) -> str:
|
||||
"""Estimated completion time."""
|
||||
if obj.assigned_at:
|
||||
completion_time = obj.assigned_at + timedelta(
|
||||
minutes=obj.estimated_review_time
|
||||
)
|
||||
else:
|
||||
completion_time = timezone.now() + timedelta(
|
||||
minutes=obj.estimated_review_time
|
||||
)
|
||||
|
||||
return completion_time.isoformat()
|
||||
|
||||
|
||||
class AssignQueueItemSerializer(serializers.Serializer):
|
||||
"""Serializer for assigning queue items to moderators."""
|
||||
|
||||
moderator_id = serializers.IntegerField()
|
||||
|
||||
def validate_moderator_id(self, value):
|
||||
"""Validate that the moderator exists and has appropriate permissions."""
|
||||
try:
|
||||
user = User.objects.get(id=value)
|
||||
user_role = getattr(user, "role", "USER")
|
||||
if user_role not in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
raise serializers.ValidationError(
|
||||
"User must be a moderator, admin, or superuser"
|
||||
)
|
||||
return value
|
||||
except User.DoesNotExist:
|
||||
raise serializers.ValidationError("Moderator not found")
|
||||
|
||||
|
||||
class CompleteQueueItemSerializer(serializers.Serializer):
|
||||
"""Serializer for completing queue items."""
|
||||
|
||||
action = serializers.ChoiceField(
|
||||
choices=[
|
||||
"NO_ACTION",
|
||||
"CONTENT_REMOVED",
|
||||
"CONTENT_EDITED",
|
||||
"USER_WARNING",
|
||||
"USER_SUSPENDED",
|
||||
"USER_BANNED",
|
||||
]
|
||||
)
|
||||
notes = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate completion data."""
|
||||
action = attrs["action"]
|
||||
notes = attrs.get("notes", "")
|
||||
|
||||
# Require notes for certain actions
|
||||
if action in ["USER_WARNING", "USER_SUSPENDED", "USER_BANNED"] and not notes:
|
||||
raise serializers.ValidationError(
|
||||
{"notes": f"Notes are required for action: {action}"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Action Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationActionSerializer(serializers.ModelSerializer):
|
||||
"""Full moderation action serializer."""
|
||||
|
||||
moderator = UserBasicSerializer(read_only=True)
|
||||
target_user = UserBasicSerializer(read_only=True)
|
||||
related_report = ModerationReportSerializer(read_only=True)
|
||||
|
||||
# Computed fields
|
||||
is_expired = serializers.SerializerMethodField()
|
||||
time_remaining = serializers.SerializerMethodField()
|
||||
action_type_display = serializers.CharField(
|
||||
source="get_action_type_display", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationAction
|
||||
fields = [
|
||||
"id",
|
||||
"action_type",
|
||||
"action_type_display",
|
||||
"reason",
|
||||
"details",
|
||||
"duration_hours",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"is_active",
|
||||
"moderator",
|
||||
"target_user",
|
||||
"related_report",
|
||||
"updated_at",
|
||||
"is_expired",
|
||||
"time_remaining",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"moderator",
|
||||
"target_user",
|
||||
"related_report",
|
||||
"is_expired",
|
||||
"time_remaining",
|
||||
"action_type_display",
|
||||
]
|
||||
|
||||
def get_is_expired(self, obj) -> bool:
|
||||
"""Check if action has expired."""
|
||||
if not obj.expires_at:
|
||||
return False
|
||||
return timezone.now() > obj.expires_at
|
||||
|
||||
def get_time_remaining(self, obj) -> str | None:
|
||||
"""Time remaining until expiration."""
|
||||
if not obj.expires_at or not obj.is_active:
|
||||
return None
|
||||
|
||||
now = timezone.now()
|
||||
if now >= obj.expires_at:
|
||||
return "Expired"
|
||||
|
||||
diff = obj.expires_at - now
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} days"
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hours"
|
||||
else:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes"
|
||||
|
||||
|
||||
class CreateModerationActionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating moderation actions."""
|
||||
|
||||
target_user_id = serializers.IntegerField()
|
||||
related_report_id = serializers.IntegerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = ModerationAction
|
||||
fields = [
|
||||
"action_type",
|
||||
"reason",
|
||||
"details",
|
||||
"duration_hours",
|
||||
"target_user_id",
|
||||
"related_report_id",
|
||||
]
|
||||
|
||||
def validate_target_user_id(self, value):
|
||||
"""Validate target user exists."""
|
||||
try:
|
||||
User.objects.get(id=value)
|
||||
return value
|
||||
except User.DoesNotExist:
|
||||
raise serializers.ValidationError("Target user not found")
|
||||
|
||||
def validate_related_report_id(self, value):
|
||||
"""Validate related report exists."""
|
||||
if value:
|
||||
try:
|
||||
ModerationReport.objects.get(id=value)
|
||||
return value
|
||||
except ModerationReport.DoesNotExist:
|
||||
raise serializers.ValidationError("Related report not found")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate action data."""
|
||||
action_type = attrs["action_type"]
|
||||
duration_hours = attrs.get("duration_hours")
|
||||
|
||||
# Validate duration for temporary actions
|
||||
temporary_actions = ["USER_SUSPENSION", "CONTENT_RESTRICTION"]
|
||||
if action_type in temporary_actions and not duration_hours:
|
||||
raise serializers.ValidationError(
|
||||
{"duration_hours": f"Duration is required for {action_type}"}
|
||||
)
|
||||
|
||||
# Validate duration range
|
||||
if duration_hours and (
|
||||
duration_hours < 1 or duration_hours > 8760
|
||||
): # 1 hour to 1 year
|
||||
raise serializers.ValidationError(
|
||||
{"duration_hours": "Duration must be between 1 and 8760 hours (1 year)"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create moderation action with automatic fields."""
|
||||
target_user_id = validated_data.pop("target_user_id")
|
||||
related_report_id = validated_data.pop("related_report_id", None)
|
||||
|
||||
validated_data["moderator"] = self.context["request"].user
|
||||
validated_data["target_user_id"] = target_user_id
|
||||
validated_data["is_active"] = True
|
||||
|
||||
if related_report_id:
|
||||
validated_data["related_report_id"] = related_report_id
|
||||
|
||||
# Set expiration time for temporary actions
|
||||
if validated_data.get("duration_hours"):
|
||||
validated_data["expires_at"] = timezone.now() + timedelta(
|
||||
hours=validated_data["duration_hours"]
|
||||
)
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bulk Operation Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BulkOperationSerializer(serializers.ModelSerializer):
|
||||
"""Full bulk operation serializer."""
|
||||
|
||||
created_by = UserBasicSerializer(read_only=True)
|
||||
|
||||
# Computed fields
|
||||
progress_percentage = serializers.SerializerMethodField()
|
||||
estimated_completion = serializers.SerializerMethodField()
|
||||
operation_type_display = serializers.CharField(
|
||||
source="get_operation_type_display", read_only=True
|
||||
)
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BulkOperation
|
||||
fields = [
|
||||
"id",
|
||||
"operation_type",
|
||||
"operation_type_display",
|
||||
"status",
|
||||
"status_display",
|
||||
"priority",
|
||||
"parameters",
|
||||
"results",
|
||||
"total_items",
|
||||
"processed_items",
|
||||
"failed_items",
|
||||
"created_at",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"estimated_duration_minutes",
|
||||
"can_cancel",
|
||||
"description",
|
||||
"schedule_for",
|
||||
"created_by",
|
||||
"updated_at",
|
||||
"progress_percentage",
|
||||
"estimated_completion",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"progress_percentage",
|
||||
"estimated_completion",
|
||||
"operation_type_display",
|
||||
"status_display",
|
||||
]
|
||||
|
||||
def get_progress_percentage(self, obj) -> float:
|
||||
"""Calculate progress percentage."""
|
||||
if obj.total_items == 0:
|
||||
return 0.0
|
||||
return round((obj.processed_items / obj.total_items) * 100, 2)
|
||||
|
||||
def get_estimated_completion(self, obj) -> str | None:
|
||||
"""Estimate completion time."""
|
||||
if obj.status == "COMPLETED":
|
||||
return obj.completed_at.isoformat() if obj.completed_at else None
|
||||
|
||||
if obj.status == "RUNNING" and obj.started_at:
|
||||
# Calculate based on current progress
|
||||
if obj.processed_items > 0:
|
||||
elapsed_minutes = (timezone.now() - obj.started_at).total_seconds() / 60
|
||||
rate = obj.processed_items / elapsed_minutes
|
||||
remaining_items = obj.total_items - obj.processed_items
|
||||
remaining_minutes = (
|
||||
remaining_items / rate
|
||||
if rate > 0
|
||||
else obj.estimated_duration_minutes
|
||||
)
|
||||
completion_time = timezone.now() + timedelta(minutes=remaining_minutes)
|
||||
return completion_time.isoformat()
|
||||
|
||||
# Use scheduled time or estimated duration
|
||||
if obj.schedule_for:
|
||||
return obj.schedule_for.isoformat()
|
||||
elif obj.estimated_duration_minutes:
|
||||
completion_time = timezone.now() + timedelta(
|
||||
minutes=obj.estimated_duration_minutes
|
||||
)
|
||||
return completion_time.isoformat()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class CreateBulkOperationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating bulk operations."""
|
||||
|
||||
class Meta:
|
||||
model = BulkOperation
|
||||
fields = [
|
||||
"operation_type",
|
||||
"priority",
|
||||
"parameters",
|
||||
"description",
|
||||
"schedule_for",
|
||||
"estimated_duration_minutes",
|
||||
]
|
||||
|
||||
def validate_parameters(self, value):
|
||||
"""Validate operation parameters."""
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Parameters must be a JSON object")
|
||||
|
||||
operation_type = getattr(self, "initial_data", {}).get("operation_type")
|
||||
|
||||
# Validate required parameters by operation type
|
||||
required_params = {
|
||||
"UPDATE_PARKS": ["park_ids", "updates"],
|
||||
"UPDATE_RIDES": ["ride_ids", "updates"],
|
||||
"IMPORT_DATA": ["data_type", "source"],
|
||||
"EXPORT_DATA": ["data_type", "format"],
|
||||
"MODERATE_CONTENT": ["content_type", "action"],
|
||||
"USER_ACTIONS": ["user_ids", "action"],
|
||||
}
|
||||
|
||||
if operation_type in required_params:
|
||||
for param in required_params[operation_type]:
|
||||
if param not in value:
|
||||
raise serializers.ValidationError(
|
||||
f'Parameter "{param}" is required for {operation_type}'
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create bulk operation with automatic fields."""
|
||||
validated_data["created_by"] = self.context["request"].user
|
||||
validated_data["status"] = "PENDING"
|
||||
validated_data["total_items"] = 0
|
||||
validated_data["processed_items"] = 0
|
||||
validated_data["failed_items"] = 0
|
||||
validated_data["can_cancel"] = True
|
||||
|
||||
# Generate unique ID
|
||||
import uuid
|
||||
|
||||
validated_data["id"] = str(uuid.uuid4())[:50]
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Statistics and Summary Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationStatsSerializer(serializers.Serializer):
|
||||
"""Serializer for moderation statistics."""
|
||||
|
||||
# Report stats
|
||||
total_reports = serializers.IntegerField()
|
||||
pending_reports = serializers.IntegerField()
|
||||
resolved_reports = serializers.IntegerField()
|
||||
overdue_reports = serializers.IntegerField()
|
||||
|
||||
# Queue stats
|
||||
queue_size = serializers.IntegerField()
|
||||
assigned_items = serializers.IntegerField()
|
||||
unassigned_items = serializers.IntegerField()
|
||||
|
||||
# Action stats
|
||||
total_actions = serializers.IntegerField()
|
||||
active_actions = serializers.IntegerField()
|
||||
expired_actions = serializers.IntegerField()
|
||||
|
||||
# Bulk operation stats
|
||||
running_operations = serializers.IntegerField()
|
||||
completed_operations = serializers.IntegerField()
|
||||
failed_operations = serializers.IntegerField()
|
||||
|
||||
# Performance metrics
|
||||
average_resolution_time_hours = serializers.FloatField()
|
||||
reports_by_priority = serializers.DictField()
|
||||
reports_by_type = serializers.DictField()
|
||||
|
||||
|
||||
class UserModerationProfileSerializer(serializers.Serializer):
|
||||
"""Serializer for user moderation profile."""
|
||||
|
||||
user = UserBasicSerializer()
|
||||
|
||||
# Report history
|
||||
reports_made = serializers.IntegerField()
|
||||
reports_against = serializers.IntegerField()
|
||||
|
||||
# Action history
|
||||
warnings_received = serializers.IntegerField()
|
||||
suspensions_received = serializers.IntegerField()
|
||||
active_restrictions = serializers.IntegerField()
|
||||
|
||||
# Risk assessment
|
||||
risk_level = serializers.ChoiceField(choices=["LOW", "MEDIUM", "HIGH", "CRITICAL"])
|
||||
risk_factors = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
# Recent activity
|
||||
recent_reports = ModerationReportSerializer(many=True)
|
||||
recent_actions = ModerationActionSerializer(many=True)
|
||||
|
||||
# Account status
|
||||
account_status = serializers.CharField()
|
||||
last_violation_date = serializers.DateTimeField(allow_null=True)
|
||||
next_review_date = serializers.DateTimeField(allow_null=True)
|
||||
@@ -6,10 +6,11 @@ Following Django styleguide pattern for business logic encapsulation.
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import QuerySet
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from .models import EditSubmission
|
||||
from apps.accounts.models import User
|
||||
from .models import EditSubmission, PhotoSubmission, ModerationQueue
|
||||
|
||||
|
||||
class ModerationService:
|
||||
@@ -133,9 +134,9 @@ class ModerationService:
|
||||
submission = EditSubmission(
|
||||
content_object=content_object,
|
||||
changes=changes,
|
||||
submitted_by=submitter,
|
||||
user=submitter,
|
||||
submission_type=submission_type,
|
||||
notes=notes or "",
|
||||
reason=notes or "",
|
||||
)
|
||||
|
||||
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
|
||||
@@ -228,3 +229,415 @@ class ModerationService:
|
||||
from .selectors import moderation_statistics_summary
|
||||
|
||||
return moderation_statistics_summary(days=days, moderator=moderator)
|
||||
|
||||
@staticmethod
|
||||
def _is_moderator_or_above(user: User) -> bool:
|
||||
"""
|
||||
Check if user has moderator privileges or above.
|
||||
|
||||
Args:
|
||||
user: User to check
|
||||
|
||||
Returns:
|
||||
True if user is MODERATOR, ADMIN, or SUPERUSER
|
||||
"""
|
||||
return user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||
|
||||
@staticmethod
|
||||
def create_edit_submission_with_queue(
|
||||
*,
|
||||
content_object: Optional[object],
|
||||
changes: Dict[str, Any],
|
||||
submitter: User,
|
||||
submission_type: str = "EDIT",
|
||||
reason: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an edit submission with automatic queue routing.
|
||||
|
||||
For moderators and above: Creates submission and auto-approves
|
||||
For regular users: Creates submission and adds to moderation queue
|
||||
|
||||
Args:
|
||||
content_object: The object being edited (None for CREATE)
|
||||
changes: Dictionary of field changes
|
||||
submitter: User submitting the changes
|
||||
submission_type: Type of submission ("CREATE" or "EDIT")
|
||||
reason: Reason for the submission
|
||||
source: Source of information
|
||||
|
||||
Returns:
|
||||
Dictionary with submission info and queue status
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create the submission
|
||||
submission = EditSubmission(
|
||||
content_object=content_object,
|
||||
changes=changes,
|
||||
user=submitter,
|
||||
submission_type=submission_type,
|
||||
reason=reason or "",
|
||||
source=source or "",
|
||||
)
|
||||
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
# Check if user is moderator or above
|
||||
if ModerationService._is_moderator_or_above(submitter):
|
||||
# Auto-approve for moderators
|
||||
try:
|
||||
created_object = submission.approve(submitter)
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'auto_approved',
|
||||
'created_object': created_object,
|
||||
'queue_item': None,
|
||||
'message': 'Submission auto-approved for moderator'
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'queue_item': None,
|
||||
'message': f'Auto-approval failed: {str(e)}'
|
||||
}
|
||||
else:
|
||||
# Create queue item for regular users
|
||||
queue_item = ModerationService._create_queue_item_for_submission(
|
||||
submission=submission,
|
||||
submitter=submitter
|
||||
)
|
||||
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'queued',
|
||||
'created_object': None,
|
||||
'queue_item': queue_item,
|
||||
'message': 'Submission added to moderation queue'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_photo_submission_with_queue(
|
||||
*,
|
||||
content_object: object,
|
||||
photo,
|
||||
caption: str = "",
|
||||
date_taken=None,
|
||||
submitter: User,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a photo submission with automatic queue routing.
|
||||
|
||||
For moderators and above: Creates submission and auto-approves
|
||||
For regular users: Creates submission and adds to moderation queue
|
||||
|
||||
Args:
|
||||
content_object: The object the photo is for
|
||||
photo: The photo file
|
||||
caption: Photo caption
|
||||
date_taken: Date the photo was taken
|
||||
submitter: User submitting the photo
|
||||
|
||||
Returns:
|
||||
Dictionary with submission info and queue status
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create the photo submission
|
||||
submission = PhotoSubmission(
|
||||
content_object=content_object,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
date_taken=date_taken,
|
||||
user=submitter,
|
||||
)
|
||||
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
# Check if user is moderator or above
|
||||
if ModerationService._is_moderator_or_above(submitter):
|
||||
# Auto-approve for moderators
|
||||
try:
|
||||
submission.auto_approve()
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'auto_approved',
|
||||
'queue_item': None,
|
||||
'message': 'Photo submission auto-approved for moderator'
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'failed',
|
||||
'queue_item': None,
|
||||
'message': f'Auto-approval failed: {str(e)}'
|
||||
}
|
||||
else:
|
||||
# Create queue item for regular users
|
||||
queue_item = ModerationService._create_queue_item_for_photo_submission(
|
||||
submission=submission,
|
||||
submitter=submitter
|
||||
)
|
||||
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'queued',
|
||||
'queue_item': queue_item,
|
||||
'message': 'Photo submission added to moderation queue'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_submission(
|
||||
*, submission: EditSubmission, submitter: User
|
||||
) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for an edit submission.
|
||||
|
||||
Args:
|
||||
submission: The edit submission
|
||||
submitter: User who made the submission
|
||||
|
||||
Returns:
|
||||
Created ModerationQueue item
|
||||
"""
|
||||
# Determine content type and entity info
|
||||
content_type = submission.content_type
|
||||
entity_type = content_type.model if content_type else "unknown"
|
||||
entity_id = submission.object_id
|
||||
|
||||
# Create preview data
|
||||
entity_preview = {
|
||||
'submission_type': submission.submission_type,
|
||||
'changes_count': len(submission.changes) if submission.changes else 0,
|
||||
'reason': submission.reason[:100] if submission.reason else "",
|
||||
}
|
||||
|
||||
if submission.content_object:
|
||||
entity_preview['object_name'] = str(submission.content_object)
|
||||
|
||||
# Determine title and description
|
||||
action = "creation" if submission.submission_type == "CREATE" else "edit"
|
||||
title = f"{entity_type.title()} {action} by {submitter.username}"
|
||||
|
||||
description = f"Review {action} submission for {entity_type}"
|
||||
if submission.reason:
|
||||
description += f". Reason: {submission.reason}"
|
||||
|
||||
# Create queue item
|
||||
queue_item = ModerationQueue(
|
||||
item_type='CONTENT_REVIEW',
|
||||
title=title,
|
||||
description=description,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_preview=entity_preview,
|
||||
content_type=content_type,
|
||||
flagged_by=submitter,
|
||||
priority='MEDIUM',
|
||||
estimated_review_time=15, # 15 minutes default
|
||||
tags=['edit_submission', submission.submission_type.lower()],
|
||||
)
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_photo_submission(
|
||||
*, submission: PhotoSubmission, submitter: User
|
||||
) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for a photo submission.
|
||||
|
||||
Args:
|
||||
submission: The photo submission
|
||||
submitter: User who made the submission
|
||||
|
||||
Returns:
|
||||
Created ModerationQueue item
|
||||
"""
|
||||
# Determine content type and entity info
|
||||
content_type = submission.content_type
|
||||
entity_type = content_type.model if content_type else "unknown"
|
||||
entity_id = submission.object_id
|
||||
|
||||
# Create preview data
|
||||
entity_preview = {
|
||||
'caption': submission.caption,
|
||||
'date_taken': submission.date_taken.isoformat() if submission.date_taken else None,
|
||||
'photo_url': submission.photo.url if submission.photo else None,
|
||||
}
|
||||
|
||||
if submission.content_object:
|
||||
entity_preview['object_name'] = str(submission.content_object)
|
||||
|
||||
# Create title and description
|
||||
title = f"Photo submission for {entity_type} by {submitter.username}"
|
||||
description = f"Review photo submission for {entity_type}"
|
||||
if submission.caption:
|
||||
description += f". Caption: {submission.caption}"
|
||||
|
||||
# Create queue item
|
||||
queue_item = ModerationQueue(
|
||||
item_type='CONTENT_REVIEW',
|
||||
title=title,
|
||||
description=description,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_preview=entity_preview,
|
||||
content_type=content_type,
|
||||
flagged_by=submitter,
|
||||
priority='LOW', # Photos typically lower priority
|
||||
estimated_review_time=5, # 5 minutes default for photos
|
||||
tags=['photo_submission'],
|
||||
)
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def process_queue_item(
|
||||
*, queue_item_id: int, moderator: User, action: str, notes: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a moderation queue item (approve, reject, etc.).
|
||||
|
||||
Args:
|
||||
queue_item_id: ID of the queue item to process
|
||||
moderator: User processing the item
|
||||
action: Action to take ('approve', 'reject', 'escalate')
|
||||
notes: Optional notes about the action
|
||||
|
||||
Returns:
|
||||
Dictionary with processing results
|
||||
"""
|
||||
with transaction.atomic():
|
||||
queue_item = ModerationQueue.objects.select_for_update().get(
|
||||
id=queue_item_id
|
||||
)
|
||||
|
||||
if queue_item.status != 'PENDING':
|
||||
raise ValueError(f"Queue item {queue_item_id} is not pending")
|
||||
|
||||
# Find related submission
|
||||
if 'edit_submission' in queue_item.tags:
|
||||
# Find EditSubmission
|
||||
submissions = EditSubmission.objects.filter(
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
status='PENDING'
|
||||
).order_by('-created_at')
|
||||
|
||||
if not submissions.exists():
|
||||
raise ValueError(
|
||||
"No pending edit submission found for this queue item")
|
||||
|
||||
submission = submissions.first()
|
||||
|
||||
if action == 'approve':
|
||||
try:
|
||||
created_object = submission.approve(moderator)
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'approved',
|
||||
'created_object': created_object,
|
||||
'message': 'Submission approved successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'message': f'Approval failed: {str(e)}'
|
||||
}
|
||||
elif action == 'reject':
|
||||
submission.reject(moderator, notes or "Rejected by moderator")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'rejected',
|
||||
'created_object': None,
|
||||
'message': 'Submission rejected'
|
||||
}
|
||||
elif action == 'escalate':
|
||||
submission.escalate(moderator, notes or "Escalated for review")
|
||||
queue_item.priority = 'HIGH'
|
||||
queue_item.status = 'PENDING' # Keep in queue but escalated
|
||||
result = {
|
||||
'status': 'escalated',
|
||||
'created_object': None,
|
||||
'message': 'Submission escalated'
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
elif 'photo_submission' in queue_item.tags:
|
||||
# Find PhotoSubmission
|
||||
submissions = PhotoSubmission.objects.filter(
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
status='PENDING'
|
||||
).order_by('-created_at')
|
||||
|
||||
if not submissions.exists():
|
||||
raise ValueError(
|
||||
"No pending photo submission found for this queue item")
|
||||
|
||||
submission = submissions.first()
|
||||
|
||||
if action == 'approve':
|
||||
try:
|
||||
submission.approve(moderator, notes or "")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'approved',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission approved successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'message': f'Photo approval failed: {str(e)}'
|
||||
}
|
||||
elif action == 'reject':
|
||||
submission.reject(moderator, notes or "Rejected by moderator")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'rejected',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission rejected'
|
||||
}
|
||||
elif action == 'escalate':
|
||||
submission.escalate(moderator, notes or "Escalated for review")
|
||||
queue_item.priority = 'HIGH'
|
||||
queue_item.status = 'PENDING' # Keep in queue but escalated
|
||||
result = {
|
||||
'status': 'escalated',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission escalated'
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
else:
|
||||
raise ValueError("Unknown queue item type")
|
||||
|
||||
# Update queue item
|
||||
queue_item.assigned_to = moderator
|
||||
queue_item.assigned_at = timezone.now()
|
||||
if notes:
|
||||
queue_item.description += f"\n\nModerator notes: {notes}"
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
result['queue_item'] = queue_item
|
||||
return result
|
||||
|
||||
@@ -1,58 +1,87 @@
|
||||
from django.urls import path
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse_lazy
|
||||
from . import views
|
||||
"""
|
||||
Moderation URLs
|
||||
|
||||
This module defines URL patterns for the moderation API endpoints.
|
||||
All endpoints are nested under /api/moderation/ and provide comprehensive
|
||||
moderation functionality including reports, queue management, actions, and bulk operations.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
ModerationReportViewSet,
|
||||
ModerationQueueViewSet,
|
||||
ModerationActionViewSet,
|
||||
BulkOperationViewSet,
|
||||
UserModerationViewSet,
|
||||
)
|
||||
|
||||
# Create router and register viewsets
|
||||
router = DefaultRouter()
|
||||
router.register(r"reports", ModerationReportViewSet, basename="moderation-reports")
|
||||
router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
|
||||
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
|
||||
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
|
||||
router.register(r"users", UserModerationViewSet, basename="user-moderation")
|
||||
|
||||
app_name = "moderation"
|
||||
|
||||
|
||||
def redirect_to_dashboard(request):
|
||||
return redirect(reverse_lazy("moderation:dashboard"))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# Root URL redirects to dashboard
|
||||
path("", redirect_to_dashboard),
|
||||
# Dashboard and Submissions
|
||||
path("dashboard/", views.DashboardView.as_view(), name="dashboard"),
|
||||
path("submissions/", views.submission_list, name="submission_list"),
|
||||
# Search endpoints
|
||||
path("search/parks/", views.search_parks, name="search_parks"),
|
||||
path(
|
||||
"search/ride-models/",
|
||||
views.search_ride_models,
|
||||
name="search_ride_models",
|
||||
),
|
||||
# Submission Actions
|
||||
path(
|
||||
"submissions/<int:submission_id>/edit/",
|
||||
views.edit_submission,
|
||||
name="edit_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/approve/",
|
||||
views.approve_submission,
|
||||
name="approve_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/reject/",
|
||||
views.reject_submission,
|
||||
name="reject_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/escalate/",
|
||||
views.escalate_submission,
|
||||
name="escalate_submission",
|
||||
),
|
||||
# Photo Submissions
|
||||
path(
|
||||
"photos/<int:submission_id>/approve/",
|
||||
views.approve_photo,
|
||||
name="approve_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:submission_id>/reject/",
|
||||
views.reject_photo,
|
||||
name="reject_photo",
|
||||
),
|
||||
# Include all router URLs
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
# URL patterns generated by the router:
|
||||
#
|
||||
# Moderation Reports:
|
||||
# GET /api/moderation/reports/ - List all reports
|
||||
# POST /api/moderation/reports/ - Create new report
|
||||
# GET /api/moderation/reports/{id}/ - Get specific report
|
||||
# PUT /api/moderation/reports/{id}/ - Update report
|
||||
# PATCH /api/moderation/reports/{id}/ - Partial update report
|
||||
# DELETE /api/moderation/reports/{id}/ - Delete report
|
||||
# POST /api/moderation/reports/{id}/assign/ - Assign report to moderator
|
||||
# POST /api/moderation/reports/{id}/resolve/ - Resolve report
|
||||
# GET /api/moderation/reports/stats/ - Get report statistics
|
||||
#
|
||||
# Moderation Queue:
|
||||
# GET /api/moderation/queue/ - List queue items
|
||||
# POST /api/moderation/queue/ - Create queue item
|
||||
# GET /api/moderation/queue/{id}/ - Get specific queue item
|
||||
# PUT /api/moderation/queue/{id}/ - Update queue item
|
||||
# PATCH /api/moderation/queue/{id}/ - Partial update queue item
|
||||
# DELETE /api/moderation/queue/{id}/ - Delete queue item
|
||||
# POST /api/moderation/queue/{id}/assign/ - Assign queue item
|
||||
# POST /api/moderation/queue/{id}/unassign/ - Unassign queue item
|
||||
# POST /api/moderation/queue/{id}/complete/ - Complete queue item
|
||||
# GET /api/moderation/queue/my_queue/ - Get current user's queue items
|
||||
#
|
||||
# Moderation Actions:
|
||||
# GET /api/moderation/actions/ - List all actions
|
||||
# POST /api/moderation/actions/ - Create new action
|
||||
# GET /api/moderation/actions/{id}/ - Get specific action
|
||||
# PUT /api/moderation/actions/{id}/ - Update action
|
||||
# PATCH /api/moderation/actions/{id}/ - Partial update action
|
||||
# DELETE /api/moderation/actions/{id}/ - Delete action
|
||||
# POST /api/moderation/actions/{id}/deactivate/ - Deactivate action
|
||||
# GET /api/moderation/actions/active/ - Get active actions
|
||||
# GET /api/moderation/actions/expired/ - Get expired actions
|
||||
#
|
||||
# Bulk Operations:
|
||||
# GET /api/moderation/bulk-operations/ - List bulk operations
|
||||
# POST /api/moderation/bulk-operations/ - Create bulk operation
|
||||
# GET /api/moderation/bulk-operations/{id}/ - Get specific operation
|
||||
# PUT /api/moderation/bulk-operations/{id}/ - Update operation
|
||||
# PATCH /api/moderation/bulk-operations/{id}/ - Partial update operation
|
||||
# DELETE /api/moderation/bulk-operations/{id}/ - Delete operation
|
||||
# POST /api/moderation/bulk-operations/{id}/cancel/ - Cancel operation
|
||||
# POST /api/moderation/bulk-operations/{id}/retry/ - Retry failed operation
|
||||
# GET /api/moderation/bulk-operations/{id}/logs/ - Get operation logs
|
||||
# GET /api/moderation/bulk-operations/running/ - Get running operations
|
||||
#
|
||||
# User Moderation:
|
||||
# GET /api/moderation/users/{id}/ - Get user moderation profile
|
||||
# POST /api/moderation/users/{id}/moderate/ - Take action against user
|
||||
# GET /api/moderation/users/search/ - Search users for moderation
|
||||
# GET /api/moderation/users/stats/ - Get user moderation statistics
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
from django import forms
|
||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||
from autocomplete import AutocompleteWidget
|
||||
from autocomplete.core import register
|
||||
from autocomplete.shortcuts import ModelAutocomplete
|
||||
from autocomplete.widgets import AutocompleteWidget
|
||||
from .models import Park
|
||||
from .models.location import ParkLocation
|
||||
from .querysets import get_base_park_queryset
|
||||
|
||||
|
||||
class ParkAutocomplete(forms.Form):
|
||||
@register
|
||||
class ParkAutocomplete(ModelAutocomplete):
|
||||
"""Autocomplete for searching parks.
|
||||
|
||||
Features:
|
||||
@@ -19,25 +21,6 @@ class ParkAutocomplete(forms.Form):
|
||||
model = Park
|
||||
search_attrs = ["name"] # We'll match on park names
|
||||
|
||||
def get_search_results(self, search):
|
||||
"""Return search results with related data."""
|
||||
return (
|
||||
get_base_park_queryset()
|
||||
.filter(name__icontains=search)
|
||||
.select_related("operator", "property_owner")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def format_result(self, park):
|
||||
"""Format each park result with status and location."""
|
||||
location = park.formatted_location
|
||||
location_text = f" • {location}" if location else ""
|
||||
return {
|
||||
"key": str(park.pk),
|
||||
"label": park.name,
|
||||
"extra": f"{park.get_status_display()}{location_text}",
|
||||
}
|
||||
|
||||
|
||||
class ParkSearchForm(forms.Form):
|
||||
"""Form for searching parks with autocomplete."""
|
||||
|
||||
@@ -35,8 +35,7 @@ class ParkPhoto(TrackedModel):
|
||||
)
|
||||
|
||||
image = CloudflareImagesField(
|
||||
variant="public",
|
||||
help_text="Park photo stored on Cloudflare Images"
|
||||
variant="public", help_text="Park photo stored on Cloudflare Images"
|
||||
)
|
||||
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
@@ -93,7 +92,9 @@ class ParkPhoto(TrackedModel):
|
||||
ParkPhoto.objects.filter(
|
||||
park=self.park,
|
||||
is_primary=True,
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
).exclude(
|
||||
pk=self.pk
|
||||
).update(is_primary=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class Park(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parks_using_as_banner",
|
||||
help_text="Photo to use as banner image for this park"
|
||||
help_text="Photo to use as banner image for this park",
|
||||
)
|
||||
card_image = models.ForeignKey(
|
||||
"ParkPhoto",
|
||||
@@ -71,7 +71,7 @@ class Park(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parks_using_as_card",
|
||||
help_text="Photo to use as card image for this park"
|
||||
help_text="Photo to use as card image for this park",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
@@ -173,7 +173,7 @@ class Park(TrackedModel):
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Generate frontend URL
|
||||
frontend_domain = getattr(settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
frontend_domain = getattr(settings, "FRONTEND_DOMAIN", "https://thrillwiki.com")
|
||||
self.url = f"{frontend_domain}/parks/{self.slug}/"
|
||||
|
||||
# Save the model
|
||||
|
||||
@@ -4,434 +4,45 @@
|
||||
{% block title %}Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block list_actions %}
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
{# Enhanced View Mode Toggle with Modern Design #}
|
||||
<fieldset class="flex items-center space-x-1 bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-xl p-1 shadow-inner">
|
||||
<legend class="sr-only">View mode selection</legend>
|
||||
|
||||
{# Grid View Button #}
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'view_mode' and key != 'search' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#view-mode-indicator"
|
||||
class="group relative p-3 rounded-lg transition-all duration-300 {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-800 shadow-lg text-blue-600 dark:text-blue-400 scale-105{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50{% endif %}"
|
||||
aria-label="Grid view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5 transition-transform duration-200 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}
|
||||
<div class="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20 animate-pulse"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
{# List View Button #}
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'view_mode' and key != 'search' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#view-mode-indicator"
|
||||
class="group relative p-3 rounded-lg transition-all duration-300 {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-800 shadow-lg text-blue-600 dark:text-blue-400 scale-105{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50{% endif %}"
|
||||
aria-label="List view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'list' %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5 transition-transform duration-200 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
{% if request.GET.view_mode == 'list' %}
|
||||
<div class="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20 animate-pulse"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
{# View Mode Loading Indicator #}
|
||||
<div id="view-mode-indicator" class="htmx-indicator">
|
||||
<div class="flex items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">Switching view...</span>
|
||||
</div>
|
||||
</div>
|
||||
{# Simple View Toggle #}
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded-md text-sm font-medium transition-all {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-600 shadow-sm text-blue-600 dark:text-blue-400{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded-md text-sm font-medium transition-all {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-600 shadow-sm text-blue-600 dark:text-blue-400{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Enhanced Add Park Button #}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}"
|
||||
class="group inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl shadow-lg text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-300 transform hover:scale-105 hover:shadow-xl"
|
||||
data-testid="add-park-button">
|
||||
<svg class="w-5 h-5 mr-2 transition-transform duration-200 group-hover:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
Add Park
|
||||
<div class="absolute inset-0 rounded-xl bg-gradient-to-r from-white/20 to-white/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block filter_section %}
|
||||
<!-- DEBUG: park_list.html filter_section block is being rendered - timestamp: 2025-08-21 -->
|
||||
<div class="mb-6" x-data="parkListManager()" x-init="init()">
|
||||
{# Enhanced Search Section #}
|
||||
<div class="relative mb-8">
|
||||
<div class="w-full relative"
|
||||
x-data="{ query: '', selectedId: null }"
|
||||
@search-selected.window="
|
||||
query = $event.detail;
|
||||
selectedId = $event.target.value;
|
||||
$refs.filterForm.querySelector('input[name=search]').value = query;
|
||||
$refs.filterForm.submit();
|
||||
query = '';
|
||||
">
|
||||
<form hx-get="{% url 'parks:suggest_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-indicator="#search-indicator"
|
||||
x-ref="searchForm">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search"
|
||||
name="search"
|
||||
placeholder="Search parks by name, location, or description..."
|
||||
class="w-full pl-10 pr-12 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 focus:ring-blue-500 transition-colors duration-200"
|
||||
aria-label="Search parks"
|
||||
aria-controls="search-results"
|
||||
:aria-expanded="query !== ''"
|
||||
x-model="query"
|
||||
@keydown.escape="query = ''"
|
||||
@focus="$event.target.select()">
|
||||
|
||||
<!-- Clear search button -->
|
||||
<button type="button"
|
||||
x-show="query"
|
||||
@click="query = ''; $refs.searchForm.querySelector('input').value = ''; $refs.filterForm.submit();"
|
||||
class="absolute inset-y-0 right-8 flex items-center pr-1 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="search-indicator"
|
||||
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
|
||||
role="status"
|
||||
aria-label="Loading search results">
|
||||
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Searching...</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="search-results"
|
||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-600"
|
||||
role="listbox"
|
||||
x-show="query"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95">
|
||||
<!-- Search suggestions will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Filter Chips Section #}
|
||||
<div id="active-filters-section"
|
||||
x-show="hasActiveFilters"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Active Filters</h3>
|
||||
<button type="button"
|
||||
@click="clearAllFilters()"
|
||||
class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium focus:outline-none focus:underline">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2" id="filter-chips-container">
|
||||
<!-- Filter chips will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter Panel #}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Filters</h3>
|
||||
<button type="button"
|
||||
x-data="{ collapsed: false }"
|
||||
@click="collapsed = !collapsed; toggleFilterCollapse()"
|
||||
class="lg:hidden text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium focus:outline-none">
|
||||
<span x-text="collapsed ? 'Show Filters' : 'Hide Filters'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="filter-form"
|
||||
x-ref="filterForm"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change, submit"
|
||||
hx-indicator="#main-loading-indicator"
|
||||
class="mt-4"
|
||||
@htmx:beforeRequest="onFilterRequest()"
|
||||
@htmx:afterRequest="onFilterResponse($event)">
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% include "core/search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Main Loading Indicator #}
|
||||
<div id="main-loading-indicator" class="htmx-indicator fixed top-4 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white px-6 py-3 rounded-lg shadow-lg flex items-center backdrop-blur-sm">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Updating results...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function parkListManager() {
|
||||
return {
|
||||
hasActiveFilters: false,
|
||||
filterCollapsed: false,
|
||||
lastResultCount: 0,
|
||||
|
||||
init() {
|
||||
this.updateActiveFilters();
|
||||
this.setupFilterChips();
|
||||
|
||||
// Listen for form changes to update filter chips
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.closest('#filter-form')) {
|
||||
setTimeout(() => this.updateActiveFilters(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for HTMX responses to update result counts
|
||||
document.addEventListener('htmx:afterSwap', (e) => {
|
||||
if (e.detail.target.id === 'results-container') {
|
||||
this.updateResultInfo();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateActiveFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const activeFilters = [];
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (this.isFilterActive(input)) {
|
||||
activeFilters.push(this.createFilterChip(input));
|
||||
}
|
||||
});
|
||||
|
||||
this.hasActiveFilters = activeFilters.length > 0;
|
||||
this.renderFilterChips(activeFilters);
|
||||
},
|
||||
|
||||
isFilterActive(input) {
|
||||
if (!input.name || input.type === 'hidden') return false;
|
||||
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
return input.checked;
|
||||
}
|
||||
|
||||
return input.value && input.value !== '' && input.value !== 'all' && input.value !== '0';
|
||||
},
|
||||
|
||||
createFilterChip(input) {
|
||||
let label = input.name;
|
||||
let value = input.value;
|
||||
|
||||
// Get human readable label from associated label element
|
||||
const labelElement = document.querySelector(`label[for="${input.id}"]`);
|
||||
if (labelElement) {
|
||||
label = labelElement.textContent.trim();
|
||||
}
|
||||
|
||||
// Format value for display
|
||||
if (input.type === 'checkbox') {
|
||||
value = 'Yes';
|
||||
} else if (input.tagName === 'SELECT') {
|
||||
const selectedOption = input.querySelector(`option[value="${input.value}"]`);
|
||||
if (selectedOption) {
|
||||
value = selectedOption.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: input.name,
|
||||
label: label,
|
||||
value: value,
|
||||
displayText: `${label}: ${value}`
|
||||
};
|
||||
},
|
||||
|
||||
renderFilterChips(chips) {
|
||||
const container = document.getElementById('filter-chips-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = chips.map(chip => `
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
|
||||
${chip.displayText}
|
||||
<button type="button"
|
||||
onclick="removeFilter('${chip.name}')"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100 focus:outline-none"
|
||||
aria-label="Remove ${chip.label} filter">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
this.hasActiveFilters = false;
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
toggleFilterCollapse() {
|
||||
this.filterCollapsed = !this.filterCollapsed;
|
||||
},
|
||||
|
||||
onFilterRequest() {
|
||||
// Add loading state to results
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.style.opacity = '0.6';
|
||||
resultsContainer.style.pointerEvents = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
onFilterResponse(event) {
|
||||
// Remove loading state
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.style.opacity = '1';
|
||||
resultsContainer.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
// Update active filters after response
|
||||
setTimeout(() => this.updateActiveFilters(), 100);
|
||||
},
|
||||
|
||||
updateResultInfo() {
|
||||
// This would update any result count information
|
||||
// Implementation depends on how results are structured
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function to remove individual filters
|
||||
function removeFilter(filterName) {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const input = form.querySelector(`[name="${filterName}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
htmx.trigger(form, 'submit');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block results_list %}
|
||||
<div id="park-results"
|
||||
class="overflow-hidden transition-all duration-300"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
|
||||
{# Enhanced Results Header with Modern Design #}
|
||||
<div class="bg-gradient-to-r from-gray-50 to-white dark:from-gray-800 dark:to-gray-700 border-b border-gray-200/50 dark:border-gray-600/50 px-6 py-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="text-xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
|
||||
Parks
|
||||
{% if parks %}
|
||||
<span class="text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
({{ parks|length }}{% if parks.has_other_pages %} of {{ parks.paginator.count }}{% endif %} found)
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{# Enhanced Results Status Indicator #}
|
||||
{% if request.GET.search or request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-blue-100 to-purple-100 text-blue-800 dark:from-blue-900/30 dark:to-purple-900/30 dark:text-blue-300 border border-blue-200 dark:border-blue-700/50">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z"/>
|
||||
</svg>
|
||||
Filtered Results
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Enhanced Sort Options #}
|
||||
<div class="flex items-center space-x-3">
|
||||
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
|
||||
<div class="relative">
|
||||
<select id="sort-select"
|
||||
name="ordering"
|
||||
form="filter-form"
|
||||
class="appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 pl-3 pr-10 py-2 transition-colors duration-200"
|
||||
onchange="document.getElementById('filter-form').submit()">
|
||||
<option value="">Default</option>
|
||||
<option value="name" {% if request.GET.ordering == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
||||
<option value="-name" {% if request.GET.ordering == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
||||
<option value="-average_rating" {% if request.GET.ordering == '-average_rating' %}selected{% endif %}>Highest Rated</option>
|
||||
<option value="-coaster_count" {% if request.GET.ordering == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
|
||||
<option value="-ride_count" {% if request.GET.ordering == '-ride_count' %}selected{% endif %}>Most Rides</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Results Content with Adaptive Grid #}
|
||||
{# Results Content with Adaptive Grid #}
|
||||
<div class="p-6">
|
||||
{% if parks %}
|
||||
{# Enhanced Responsive Grid Container #}
|
||||
@@ -550,4 +161,4 @@ document.addEventListener('htmx:afterSwap', function(event) {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@ from apps.core.mixins import HTMXFilterableMixin
|
||||
from .models.location import ParkLocation
|
||||
from .models.media import ParkPhoto
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.moderation.services import ModerationService
|
||||
from apps.moderation.mixins import (
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
@@ -501,88 +502,85 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
self.normalize_coordinates(form)
|
||||
changes = self.prepare_changes_data(form.cleaned_data)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
submission_type="CREATE",
|
||||
# Use the new queue routing service
|
||||
result = ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None, # None for CREATE
|
||||
changes=changes,
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=self.request.POST.get("reason", ""),
|
||||
source=self.request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
if (
|
||||
hasattr(self.request.user, "role")
|
||||
and getattr(self.request.user, "role", None) in ALLOWED_ROLES
|
||||
):
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.object_id = self.object.id
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
if result['status'] == 'auto_approved':
|
||||
# Moderator submission was auto-approved
|
||||
self.object = result['created_object']
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
park=self.object,
|
||||
defaults={
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", "USA"),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
},
|
||||
)
|
||||
park_location.set_coordinates(
|
||||
form.cleaned_data["latitude"],
|
||||
form.cleaned_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get(
|
||||
"longitude"
|
||||
):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
park=self.object,
|
||||
defaults={
|
||||
"street_address": form.cleaned_data.get(
|
||||
"street_address", ""
|
||||
),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", "USA"),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
},
|
||||
)
|
||||
park_location.set_coordinates(
|
||||
form.cleaned_data["latitude"],
|
||||
form.cleaned_data["longitude"],
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
park=self.object,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully created {self.object.name}. "
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error creating park: {
|
||||
str(e)
|
||||
}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully created {self.object.name}. "
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
elif result['status'] == 'queued':
|
||||
# Regular user submission was queued
|
||||
messages.success(
|
||||
self.request,
|
||||
"Your park submission has been sent for review. "
|
||||
"You will be notified when it is approved.",
|
||||
)
|
||||
# Redirect to parks list since we don't have an object yet
|
||||
return HttpResponseRedirect(reverse("parks:park_list"))
|
||||
|
||||
elif result['status'] == 'failed':
|
||||
# Auto-approval failed
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error creating park: {result['message']}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Fallback error case
|
||||
messages.error(
|
||||
self.request,
|
||||
"Your park submission has been sent for review. "
|
||||
"You will be notified when it is approved.",
|
||||
"An unexpected error occurred. Please try again.",
|
||||
)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
messages.error(self.request, f"{field}: {error}")
|
||||
return super().form_invalid(form)
|
||||
return self.form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
@@ -633,125 +631,129 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
self.normalize_coordinates(form)
|
||||
changes = self.prepare_changes_data(form.cleaned_data)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
submission_type="EDIT",
|
||||
# Use the new queue routing service
|
||||
result = ModerationService.create_edit_submission_with_queue(
|
||||
content_object=self.object,
|
||||
changes=changes,
|
||||
submitter=self.request.user,
|
||||
submission_type="EDIT",
|
||||
reason=self.request.POST.get("reason", ""),
|
||||
source=self.request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
if (
|
||||
hasattr(self.request.user, "role")
|
||||
and getattr(self.request.user, "role", None) in ALLOWED_ROLES
|
||||
):
|
||||
if result['status'] == 'auto_approved':
|
||||
# Moderator submission was auto-approved
|
||||
# The object was already updated by the service
|
||||
self.object = result['created_object']
|
||||
|
||||
location_data = {
|
||||
"name": self.object.name,
|
||||
"location_type": "park",
|
||||
"latitude": form.cleaned_data.get("latitude"),
|
||||
"longitude": form.cleaned_data.get("longitude"),
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", ""),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
}
|
||||
|
||||
# Create or update ParkLocation
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
park_location = self.object.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
if key in ["latitude", "longitude"] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
|
||||
location_data = {
|
||||
"name": self.object.name,
|
||||
"location_type": "park",
|
||||
"latitude": form.cleaned_data.get("latitude"),
|
||||
"longitude": form.cleaned_data.get("longitude"),
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", ""),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
# Handle coordinates if provided
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
park_location.set_coordinates(
|
||||
float(location_data["latitude"]),
|
||||
float(location_data["longitude"]),
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
coordinates_data = {
|
||||
"latitude": float(location_data["latitude"]),
|
||||
"longitude": float(location_data["longitude"]),
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {
|
||||
k: v
|
||||
for k, v in location_data.items()
|
||||
if k not in ["latitude", "longitude"]
|
||||
}
|
||||
creation_data.setdefault("country", "USA")
|
||||
|
||||
# Create or update ParkLocation
|
||||
try:
|
||||
park_location = self.object.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
if key in ["latitude", "longitude"] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=self.object, **creation_data
|
||||
)
|
||||
|
||||
# Handle coordinates if provided
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
park_location.set_coordinates(
|
||||
float(location_data["latitude"]),
|
||||
float(location_data["longitude"]),
|
||||
)
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data["latitude"],
|
||||
coordinates_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
coordinates_data = {
|
||||
"latitude": float(location_data["latitude"]),
|
||||
"longitude": float(location_data["longitude"]),
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {
|
||||
k: v
|
||||
for k, v in location_data.items()
|
||||
if k not in ["latitude", "longitude"]
|
||||
}
|
||||
creation_data.setdefault("country", "USA")
|
||||
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=self.object, **creation_data
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data["latitude"],
|
||||
coordinates_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully updated {self.object.name}. "
|
||||
f"Added {uploaded_count} new photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error updating park: {
|
||||
str(e)
|
||||
}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully updated {self.object.name}. "
|
||||
f"Added {uploaded_count} new photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
elif result['status'] == 'queued':
|
||||
# Regular user submission was queued
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Your changes to {self.object.name} have been sent for review. "
|
||||
"You will be notified when they are approved.",
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
)
|
||||
|
||||
elif result['status'] == 'failed':
|
||||
# Auto-approval failed
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error updating park: {result['message']}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Fallback error case
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Your changes to {self.object.name} have been sent for review. "
|
||||
"You will be notified when they are approved.",
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
"An unexpected error occurred. Please try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||
messages.error(self.request, REQUIRED_FIELDS_ERROR)
|
||||
|
||||
@@ -346,9 +346,9 @@ class RideForm(forms.ModelForm):
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields[
|
||||
"manufacturer_search"
|
||||
].initial = self.instance.manufacturer.name
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
|
||||
@@ -346,9 +346,9 @@ class RideForm(forms.ModelForm):
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields[
|
||||
"manufacturer_search"
|
||||
].initial = self.instance.manufacturer.name
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
|
||||
@@ -6,8 +6,8 @@ from django.utils.text import slugify
|
||||
|
||||
def populate_ride_model_slugs(apps, schema_editor):
|
||||
"""Populate unique slugs for existing RideModel records."""
|
||||
RideModel = apps.get_model('rides', 'RideModel')
|
||||
Company = apps.get_model('rides', 'Company')
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
Company = apps.get_model("rides", "Company")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate base slug from manufacturer name + model name
|
||||
@@ -25,13 +25,13 @@ def populate_ride_model_slugs(apps, schema_editor):
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = slug
|
||||
ride_model.save(update_fields=['slug'])
|
||||
ride_model.save(update_fields=["slug"])
|
||||
|
||||
|
||||
def reverse_populate_ride_model_slugs(apps, schema_editor):
|
||||
"""Reverse operation - clear slugs (not really needed but for completeness)."""
|
||||
RideModel = apps.get_model('rides', 'RideModel')
|
||||
RideModel.objects.all().update(slug='')
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
RideModel.objects.all().update(slug="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.utils.text import slugify
|
||||
|
||||
def update_ride_model_slugs(apps, schema_editor):
|
||||
"""Update RideModel slugs to be just the model name, not manufacturer + name."""
|
||||
RideModel = apps.get_model('rides', 'RideModel')
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate new slug from just the name
|
||||
@@ -15,22 +15,25 @@ def update_ride_model_slugs(apps, schema_editor):
|
||||
# Ensure uniqueness within the same manufacturer
|
||||
counter = 1
|
||||
base_slug = new_slug
|
||||
while RideModel.objects.filter(
|
||||
manufacturer=ride_model.manufacturer,
|
||||
slug=new_slug
|
||||
).exclude(pk=ride_model.pk).exists():
|
||||
while (
|
||||
RideModel.objects.filter(
|
||||
manufacturer=ride_model.manufacturer, slug=new_slug
|
||||
)
|
||||
.exclude(pk=ride_model.pk)
|
||||
.exists()
|
||||
):
|
||||
new_slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = new_slug
|
||||
ride_model.save(update_fields=['slug'])
|
||||
ride_model.save(update_fields=["slug"])
|
||||
print(f"Updated {ride_model.name}: {ride_model.slug}")
|
||||
|
||||
|
||||
def reverse_ride_model_slugs(apps, schema_editor):
|
||||
"""Reverse the slug update by regenerating the old format."""
|
||||
RideModel = apps.get_model('rides', 'RideModel')
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate old-style slug with manufacturer + name
|
||||
@@ -41,19 +44,21 @@ def reverse_ride_model_slugs(apps, schema_editor):
|
||||
# Ensure uniqueness globally (old way)
|
||||
counter = 1
|
||||
base_slug = old_slug
|
||||
while RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists():
|
||||
while (
|
||||
RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists()
|
||||
):
|
||||
old_slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = old_slug
|
||||
ride_model.save(update_fields=['slug'])
|
||||
ride_model.save(update_fields=["slug"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rides', '0013_fix_ride_model_slugs'),
|
||||
("rides", "0013_fix_ride_model_slugs"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -49,12 +49,13 @@ class Company(TrackedModel):
|
||||
# OPERATOR and PROPERTY_OWNER are for parks domain and handled separately
|
||||
if self.roles:
|
||||
frontend_domain = getattr(
|
||||
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
primary_role = self.roles[0] # Use first role as primary
|
||||
|
||||
if primary_role == 'MANUFACTURER':
|
||||
if primary_role == "MANUFACTURER":
|
||||
self.url = f"{frontend_domain}/rides/manufacturers/{self.slug}/"
|
||||
elif primary_role == 'DESIGNER':
|
||||
elif primary_role == "DESIGNER":
|
||||
self.url = f"{frontend_domain}/rides/designers/{self.slug}/"
|
||||
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain, not here
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ class RidePhoto(TrackedModel):
|
||||
)
|
||||
|
||||
image = CloudflareImagesField(
|
||||
variant="public",
|
||||
help_text="Ride photo stored on Cloudflare Images"
|
||||
variant="public", help_text="Ride photo stored on Cloudflare Images"
|
||||
)
|
||||
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
@@ -111,7 +110,9 @@ class RidePhoto(TrackedModel):
|
||||
RidePhoto.objects.filter(
|
||||
ride=self.ride,
|
||||
is_primary=True,
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
).exclude(
|
||||
pk=self.pk
|
||||
).update(is_primary=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ class RideModel(TrackedModel):
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=255, help_text="Name of the ride model")
|
||||
slug = models.SlugField(max_length=255,
|
||||
help_text="URL-friendly identifier (unique within manufacturer)")
|
||||
slug = models.SlugField(
|
||||
max_length=255, help_text="URL-friendly identifier (unique within manufacturer)"
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -40,115 +41,133 @@ class RideModel(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
help_text="Primary manufacturer of this ride model"
|
||||
help_text="Primary manufacturer of this ride model",
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Detailed description of the ride model")
|
||||
blank=True, help_text="Detailed description of the ride model"
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default="",
|
||||
blank=True,
|
||||
help_text="Primary category classification"
|
||||
help_text="Primary category classification",
|
||||
)
|
||||
|
||||
# Technical specifications
|
||||
typical_height_range_min_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
help_text="Minimum typical height in feet for this model"
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical height in feet for this model",
|
||||
)
|
||||
typical_height_range_max_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
help_text="Maximum typical height in feet for this model"
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical height in feet for this model",
|
||||
)
|
||||
typical_speed_range_min_mph = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True,
|
||||
help_text="Minimum typical speed in mph for this model"
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical speed in mph for this model",
|
||||
)
|
||||
typical_speed_range_max_mph = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True,
|
||||
help_text="Maximum typical speed in mph for this model"
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical speed in mph for this model",
|
||||
)
|
||||
typical_capacity_range_min = models.PositiveIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Minimum typical hourly capacity for this model"
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical hourly capacity for this model",
|
||||
)
|
||||
typical_capacity_range_max = models.PositiveIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Maximum typical hourly capacity for this model"
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical hourly capacity for this model",
|
||||
)
|
||||
|
||||
# Design characteristics
|
||||
track_type = models.CharField(
|
||||
max_length=100, blank=True,
|
||||
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)"
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)",
|
||||
)
|
||||
support_structure = models.CharField(
|
||||
max_length=100, blank=True,
|
||||
help_text="Type of support structure (e.g., steel, wooden, hybrid)"
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of support structure (e.g., steel, wooden, hybrid)",
|
||||
)
|
||||
train_configuration = models.CharField(
|
||||
max_length=200, blank=True,
|
||||
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)"
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)",
|
||||
)
|
||||
restraint_system = models.CharField(
|
||||
max_length=100, blank=True,
|
||||
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)"
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)",
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = models.PositiveIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Year of first installation of this model"
|
||||
null=True, blank=True, help_text="Year of first installation of this model"
|
||||
)
|
||||
last_installation_year = models.PositiveIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Year of last installation of this model (if discontinued)"
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Year of last installation of this model (if discontinued)",
|
||||
)
|
||||
is_discontinued = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this model is no longer being manufactured"
|
||||
default=False, help_text="Whether this model is no longer being manufactured"
|
||||
)
|
||||
total_installations = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Total number of installations worldwide (auto-calculated)"
|
||||
default=0, help_text="Total number of installations worldwide (auto-calculated)"
|
||||
)
|
||||
|
||||
# Design features
|
||||
notable_features = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notable design features or innovations (JSON or comma-separated)"
|
||||
help_text="Notable design features or innovations (JSON or comma-separated)",
|
||||
)
|
||||
target_market = models.CharField(
|
||||
max_length=50, blank=True,
|
||||
max_length=50,
|
||||
blank=True,
|
||||
choices=[
|
||||
('FAMILY', 'Family'),
|
||||
('THRILL', 'Thrill'),
|
||||
('EXTREME', 'Extreme'),
|
||||
('KIDDIE', 'Kiddie'),
|
||||
('ALL_AGES', 'All Ages'),
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
help_text="Primary target market for this ride model"
|
||||
help_text="Primary target market for this ride model",
|
||||
)
|
||||
|
||||
# Media
|
||||
primary_image = models.ForeignKey(
|
||||
'RideModelPhoto',
|
||||
"RideModelPhoto",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='ride_models_as_primary',
|
||||
help_text="Primary promotional image for this ride model"
|
||||
related_name="ride_models_as_primary",
|
||||
help_text="Primary promotional image for this ride model",
|
||||
)
|
||||
|
||||
# SEO and metadata
|
||||
meta_title = models.CharField(
|
||||
max_length=60, blank=True,
|
||||
help_text="SEO meta title (auto-generated if blank)"
|
||||
max_length=60, blank=True, help_text="SEO meta title (auto-generated if blank)"
|
||||
)
|
||||
meta_description = models.CharField(
|
||||
max_length=160, blank=True,
|
||||
help_text="SEO meta description (auto-generated if blank)"
|
||||
max_length=160,
|
||||
blank=True,
|
||||
help_text="SEO meta description (auto-generated if blank)",
|
||||
)
|
||||
|
||||
# Frontend URL
|
||||
@@ -156,17 +175,18 @@ class RideModel(TrackedModel):
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["manufacturer__name", "name"]
|
||||
unique_together = [
|
||||
["manufacturer", "name"],
|
||||
["manufacturer", "slug"]
|
||||
]
|
||||
unique_together = [["manufacturer", "name"], ["manufacturer", "slug"]]
|
||||
constraints = [
|
||||
# Height range validation
|
||||
models.CheckConstraint(
|
||||
name="ride_model_height_range_logical",
|
||||
condition=models.Q(typical_height_range_min_ft__isnull=True)
|
||||
| models.Q(typical_height_range_max_ft__isnull=True)
|
||||
| models.Q(typical_height_range_min_ft__lte=models.F("typical_height_range_max_ft")),
|
||||
| models.Q(
|
||||
typical_height_range_min_ft__lte=models.F(
|
||||
"typical_height_range_max_ft"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum height cannot exceed maximum height",
|
||||
),
|
||||
# Speed range validation
|
||||
@@ -174,7 +194,11 @@ class RideModel(TrackedModel):
|
||||
name="ride_model_speed_range_logical",
|
||||
condition=models.Q(typical_speed_range_min_mph__isnull=True)
|
||||
| models.Q(typical_speed_range_max_mph__isnull=True)
|
||||
| models.Q(typical_speed_range_min_mph__lte=models.F("typical_speed_range_max_mph")),
|
||||
| models.Q(
|
||||
typical_speed_range_min_mph__lte=models.F(
|
||||
"typical_speed_range_max_mph"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum speed cannot exceed maximum speed",
|
||||
),
|
||||
# Capacity range validation
|
||||
@@ -182,7 +206,11 @@ class RideModel(TrackedModel):
|
||||
name="ride_model_capacity_range_logical",
|
||||
condition=models.Q(typical_capacity_range_min__isnull=True)
|
||||
| models.Q(typical_capacity_range_max__isnull=True)
|
||||
| models.Q(typical_capacity_range_min__lte=models.F("typical_capacity_range_max")),
|
||||
| models.Q(
|
||||
typical_capacity_range_min__lte=models.F(
|
||||
"typical_capacity_range_max"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum capacity cannot exceed maximum capacity",
|
||||
),
|
||||
# Installation years validation
|
||||
@@ -190,7 +218,9 @@ class RideModel(TrackedModel):
|
||||
name="ride_model_installation_years_logical",
|
||||
condition=models.Q(first_installation_year__isnull=True)
|
||||
| models.Q(last_installation_year__isnull=True)
|
||||
| models.Q(first_installation_year__lte=models.F("last_installation_year")),
|
||||
| models.Q(
|
||||
first_installation_year__lte=models.F("last_installation_year")
|
||||
),
|
||||
violation_error_message="First installation year cannot be after last installation year",
|
||||
),
|
||||
]
|
||||
@@ -205,16 +235,18 @@ class RideModel(TrackedModel):
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
from django.utils.text import slugify
|
||||
|
||||
# Only use the ride model name for the slug, not manufacturer
|
||||
base_slug = slugify(self.name)
|
||||
self.slug = base_slug
|
||||
|
||||
# Ensure uniqueness within the same manufacturer
|
||||
counter = 1
|
||||
while RideModel.objects.filter(
|
||||
manufacturer=self.manufacturer,
|
||||
slug=self.slug
|
||||
).exclude(pk=self.pk).exists():
|
||||
while (
|
||||
RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug)
|
||||
.exclude(pk=self.pk)
|
||||
.exists()
|
||||
):
|
||||
self.slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
@@ -222,14 +254,16 @@ class RideModel(TrackedModel):
|
||||
if not self.meta_title:
|
||||
self.meta_title = str(self)[:60]
|
||||
if not self.meta_description:
|
||||
desc = f"{self} - {self.description[:100]}" if self.description else str(
|
||||
self)
|
||||
desc = (
|
||||
f"{self} - {self.description[:100]}" if self.description else str(self)
|
||||
)
|
||||
self.meta_description = desc[:160]
|
||||
|
||||
# Generate frontend URL
|
||||
if self.manufacturer:
|
||||
frontend_domain = getattr(
|
||||
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
@@ -238,9 +272,10 @@ class RideModel(TrackedModel):
|
||||
"""Update the total installations count based on actual ride instances."""
|
||||
# Import here to avoid circular import
|
||||
from django.apps import apps
|
||||
Ride = apps.get_model('rides', 'Ride')
|
||||
|
||||
Ride = apps.get_model("rides", "Ride")
|
||||
self.total_installations = Ride.objects.filter(ride_model=self).count()
|
||||
self.save(update_fields=['total_installations'])
|
||||
self.save(update_fields=["total_installations"])
|
||||
|
||||
@property
|
||||
def installation_years_range(self) -> str:
|
||||
@@ -248,7 +283,11 @@ class RideModel(TrackedModel):
|
||||
if self.first_installation_year and self.last_installation_year:
|
||||
return f"{self.first_installation_year}-{self.last_installation_year}"
|
||||
elif self.first_installation_year:
|
||||
return f"{self.first_installation_year}-present" if not self.is_discontinued else f"{self.first_installation_year}+"
|
||||
return (
|
||||
f"{self.first_installation_year}-present"
|
||||
if not self.is_discontinued
|
||||
else f"{self.first_installation_year}+"
|
||||
)
|
||||
return "Unknown"
|
||||
|
||||
@property
|
||||
@@ -282,13 +321,12 @@ class RideModelVariant(TrackedModel):
|
||||
"""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="variants"
|
||||
RideModel, on_delete=models.CASCADE, related_name="variants"
|
||||
)
|
||||
name = models.CharField(max_length=255, help_text="Name of this variant")
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Description of variant differences")
|
||||
blank=True, help_text="Description of variant differences"
|
||||
)
|
||||
|
||||
# Variant-specific specifications
|
||||
min_height_ft = models.DecimalField(
|
||||
@@ -306,8 +344,7 @@ class RideModelVariant(TrackedModel):
|
||||
|
||||
# Distinguishing features
|
||||
distinguishing_features = models.TextField(
|
||||
blank=True,
|
||||
help_text="What makes this variant unique from the base model"
|
||||
blank=True, help_text="What makes this variant unique from the base model"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
@@ -323,13 +360,10 @@ class RideModelPhoto(TrackedModel):
|
||||
"""Photos associated with ride models for catalog/promotional purposes."""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="photos"
|
||||
RideModel, on_delete=models.CASCADE, related_name="photos"
|
||||
)
|
||||
image = models.ImageField(
|
||||
upload_to="ride_models/photos/",
|
||||
help_text="Photo of the ride model"
|
||||
upload_to="ride_models/photos/", help_text="Photo of the ride model"
|
||||
)
|
||||
caption = models.CharField(max_length=500, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
@@ -338,18 +372,17 @@ class RideModelPhoto(TrackedModel):
|
||||
photo_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('PROMOTIONAL', 'Promotional'),
|
||||
('TECHNICAL', 'Technical Drawing'),
|
||||
('INSTALLATION', 'Installation Example'),
|
||||
('RENDERING', '3D Rendering'),
|
||||
('CATALOG', 'Catalog Image'),
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default='PROMOTIONAL'
|
||||
default="PROMOTIONAL",
|
||||
)
|
||||
|
||||
is_primary = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this is the primary photo for the ride model"
|
||||
default=False, help_text="Whether this is the primary photo for the ride model"
|
||||
)
|
||||
|
||||
# Attribution
|
||||
@@ -367,8 +400,7 @@ class RideModelPhoto(TrackedModel):
|
||||
# Ensure only one primary photo per ride model
|
||||
if self.is_primary:
|
||||
RideModelPhoto.objects.filter(
|
||||
ride_model=self.ride_model,
|
||||
is_primary=True
|
||||
ride_model=self.ride_model, is_primary=True
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -381,32 +413,33 @@ class RideModelTechnicalSpec(TrackedModel):
|
||||
"""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="technical_specs"
|
||||
RideModel, on_delete=models.CASCADE, related_name="technical_specs"
|
||||
)
|
||||
|
||||
spec_category = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('DIMENSIONS', 'Dimensions'),
|
||||
('PERFORMANCE', 'Performance'),
|
||||
('CAPACITY', 'Capacity'),
|
||||
('SAFETY', 'Safety Features'),
|
||||
('ELECTRICAL', 'Electrical Requirements'),
|
||||
('FOUNDATION', 'Foundation Requirements'),
|
||||
('MAINTENANCE', 'Maintenance'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
)
|
||||
|
||||
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
|
||||
spec_value = models.CharField(
|
||||
max_length=255, help_text="Value of the specification")
|
||||
spec_unit = models.CharField(max_length=20, blank=True,
|
||||
help_text="Unit of measurement")
|
||||
max_length=255, help_text="Value of the specification"
|
||||
)
|
||||
spec_unit = models.CharField(
|
||||
max_length=20, blank=True, help_text="Unit of measurement"
|
||||
)
|
||||
notes = models.TextField(
|
||||
blank=True, help_text="Additional notes about this specification")
|
||||
blank=True, help_text="Additional notes about this specification"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["spec_category", "spec_name"]
|
||||
@@ -510,7 +543,7 @@ class Ride(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rides_using_as_banner",
|
||||
help_text="Photo to use as banner image for this ride"
|
||||
help_text="Photo to use as banner image for this ride",
|
||||
)
|
||||
card_image = models.ForeignKey(
|
||||
"RidePhoto",
|
||||
@@ -518,13 +551,14 @@ class Ride(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rides_using_as_card",
|
||||
help_text="Photo to use as card image for this ride"
|
||||
help_text="Photo to use as card image for this ride",
|
||||
)
|
||||
|
||||
# Frontend URL
|
||||
url = models.URLField(blank=True, help_text="Frontend URL for this ride")
|
||||
park_url = models.URLField(
|
||||
blank=True, help_text="Frontend URL for this ride's park")
|
||||
blank=True, help_text="Frontend URL for this ride's park"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["name"]
|
||||
@@ -596,7 +630,8 @@ class Ride(TrackedModel):
|
||||
# Generate frontend URLs
|
||||
if self.park:
|
||||
frontend_domain = getattr(
|
||||
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/"
|
||||
self.park_url = f"{frontend_domain}/parks/{self.park.slug}/"
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class RideRankingService:
|
||||
processed = 0
|
||||
|
||||
for i, ride_a in enumerate(rides):
|
||||
for ride_b in rides[i + 1:]:
|
||||
for ride_b in rides[i + 1 :]:
|
||||
comparison = self._calculate_pairwise_comparison(ride_a, ride_b)
|
||||
if comparison:
|
||||
# Store both directions for easy lookup
|
||||
|
||||
@@ -14,6 +14,7 @@ from .services.search import RideSearchService
|
||||
from apps.parks.models import Park
|
||||
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.moderation.services import ModerationService
|
||||
from .models.rankings import RideRanking, RankingSnapshot
|
||||
from .services.ranking_service import RideRankingService
|
||||
|
||||
@@ -102,35 +103,38 @@ class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
|
||||
# Check for new manufacturer
|
||||
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
||||
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New manufacturer suggested during ride creation: {manufacturer_name}",
|
||||
)
|
||||
|
||||
# Check for new designer
|
||||
designer_name = form.cleaned_data.get("designer_search")
|
||||
if designer_name and not form.cleaned_data.get("designer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New designer suggested during ride creation: {designer_name}",
|
||||
)
|
||||
|
||||
# Check for new ride model
|
||||
ride_model_name = form.cleaned_data.get("ride_model_search")
|
||||
manufacturer = form.cleaned_data.get("manufacturer")
|
||||
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(RideModel),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={
|
||||
"name": ride_model_name,
|
||||
"manufacturer": manufacturer.id,
|
||||
},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New ride model suggested during ride creation: {ride_model_name}",
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
@@ -180,35 +184,38 @@ class RideUpdateView(
|
||||
# Check for new manufacturer
|
||||
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
||||
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New manufacturer suggested during ride update: {manufacturer_name}",
|
||||
)
|
||||
|
||||
# Check for new designer
|
||||
designer_name = form.cleaned_data.get("designer_search")
|
||||
if designer_name and not form.cleaned_data.get("designer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New designer suggested during ride update: {designer_name}",
|
||||
)
|
||||
|
||||
# Check for new ride model
|
||||
ride_model_name = form.cleaned_data.get("ride_model_search")
|
||||
manufacturer = form.cleaned_data.get("manufacturer")
|
||||
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(RideModel),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={
|
||||
"name": ride_model_name,
|
||||
"manufacturer": manufacturer.id,
|
||||
},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New ride model suggested during ride update: {ride_model_name}",
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
@@ -12,58 +12,52 @@ import os
|
||||
from celery import Celery
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.django.local')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.local")
|
||||
|
||||
app = Celery('thrillwiki')
|
||||
app = Celery("thrillwiki")
|
||||
|
||||
# Get Redis URL from environment variable with fallback
|
||||
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/1')
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/1")
|
||||
|
||||
# Celery Configuration - set directly without loading from Django settings first
|
||||
app.conf.update(
|
||||
# Broker settings
|
||||
broker_url=REDIS_URL,
|
||||
result_backend=REDIS_URL,
|
||||
|
||||
# Task settings
|
||||
task_serializer='json',
|
||||
accept_content=['json'],
|
||||
result_serializer='json',
|
||||
timezone='America/New_York',
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="America/New_York",
|
||||
enable_utc=True,
|
||||
|
||||
# Worker settings
|
||||
worker_prefetch_multiplier=1,
|
||||
task_acks_late=True,
|
||||
worker_max_tasks_per_child=1000,
|
||||
|
||||
# Task routing
|
||||
task_routes={
|
||||
'apps.core.tasks.trending.*': {'queue': 'trending'},
|
||||
'apps.core.tasks.analytics.*': {'queue': 'analytics'},
|
||||
'apps.core.tasks.cache.*': {'queue': 'cache'},
|
||||
"apps.core.tasks.trending.*": {"queue": "trending"},
|
||||
"apps.core.tasks.analytics.*": {"queue": "analytics"},
|
||||
"apps.core.tasks.cache.*": {"queue": "cache"},
|
||||
},
|
||||
|
||||
# Beat schedule for periodic tasks
|
||||
beat_schedule={
|
||||
'calculate-trending-content': {
|
||||
'task': 'apps.core.tasks.trending.calculate_trending_content',
|
||||
'schedule': 300.0, # Every 5 minutes
|
||||
"calculate-trending-content": {
|
||||
"task": "apps.core.tasks.trending.calculate_trending_content",
|
||||
"schedule": 300.0, # Every 5 minutes
|
||||
},
|
||||
'warm-trending-cache': {
|
||||
'task': 'apps.core.tasks.trending.warm_trending_cache',
|
||||
'schedule': 900.0, # Every 15 minutes
|
||||
"warm-trending-cache": {
|
||||
"task": "apps.core.tasks.trending.warm_trending_cache",
|
||||
"schedule": 900.0, # Every 15 minutes
|
||||
},
|
||||
'cleanup-old-analytics': {
|
||||
'task': 'apps.core.tasks.analytics.cleanup_old_analytics',
|
||||
'schedule': 86400.0, # Daily
|
||||
"cleanup-old-analytics": {
|
||||
"task": "apps.core.tasks.analytics.cleanup_old_analytics",
|
||||
"schedule": 86400.0, # Daily
|
||||
},
|
||||
},
|
||||
|
||||
# Task result settings
|
||||
result_expires=3600, # 1 hour
|
||||
task_ignore_result=False,
|
||||
|
||||
# Error handling
|
||||
task_reject_on_worker_lost=True,
|
||||
task_soft_time_limit=300, # 5 minutes
|
||||
@@ -77,4 +71,4 @@ app.autodiscover_tasks()
|
||||
@app.task(bind=True)
|
||||
def debug_task(self):
|
||||
"""Debug task for testing Celery setup."""
|
||||
print(f'Request: {self.request!r}')
|
||||
print(f"Request: {self.request!r}")
|
||||
|
||||
@@ -17,7 +17,6 @@ DATABASE_URL = config("DATABASE_URL")
|
||||
CACHE_URL = config("CACHE_URL", default="locmem://")
|
||||
EMAIL_URL = config("EMAIL_URL", default="console://")
|
||||
REDIS_URL = config("REDIS_URL", default="redis://127.0.0.1:6379/1")
|
||||
CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool)
|
||||
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default=[])
|
||||
API_RATE_LIMIT_PER_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60)
|
||||
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000)
|
||||
@@ -47,8 +46,9 @@ SECRET_KEY = config("SECRET_KEY")
|
||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS")
|
||||
|
||||
# CSRF trusted origins
|
||||
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS",
|
||||
default=[]) # type: ignore[arg-type]
|
||||
CSRF_TRUSTED_ORIGINS = config(
|
||||
"CSRF_TRUSTED_ORIGINS", default=[]
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
# Application definition
|
||||
DJANGO_APPS = [
|
||||
@@ -88,6 +88,7 @@ THIRD_PARTY_APPS = [
|
||||
"health_check.contrib.redis",
|
||||
"django_celery_beat", # Celery beat scheduler
|
||||
"django_celery_results", # Celery result backend
|
||||
"django_extensions", # Django Extensions for enhanced development tools
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
@@ -269,9 +270,9 @@ AUTH_USER_MODEL = "accounts.User"
|
||||
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = False
|
||||
|
||||
# Tailwind configuration
|
||||
TAILWIND_CLI_CONFIG_FILE = BASE_DIR / "tailwind.config.js"
|
||||
TAILWIND_CLI_SRC_CSS = BASE_DIR / "static" / "css" / "src" / "input.css"
|
||||
TAILWIND_CLI_DIST_CSS = BASE_DIR / "static" / "css" / "tailwind.css"
|
||||
TAILWIND_CLI_CONFIG_FILE = "tailwind.config.js"
|
||||
TAILWIND_CLI_SRC_CSS = "static/css/src/input.css"
|
||||
TAILWIND_CLI_DIST_CSS = "css/tailwind.css"
|
||||
|
||||
# Test runner
|
||||
TEST_RUNNER = "django.test.runner.DiscoverRunner"
|
||||
@@ -323,17 +324,19 @@ REST_FRAMEWORK = {
|
||||
}
|
||||
|
||||
# CORS Settings for API
|
||||
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS",
|
||||
default=[]) # type: ignore[arg-type]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = config(
|
||||
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool) # type: ignore[arg-type]
|
||||
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
|
||||
API_RATE_LIMIT_PER_MINUTE = config(
|
||||
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int) # type: ignore[arg-type]
|
||||
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int
|
||||
) # type: ignore[arg-type]
|
||||
API_RATE_LIMIT_PER_HOUR = config(
|
||||
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int) # type: ignore[arg-type]
|
||||
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int
|
||||
) # type: ignore[arg-type]
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "ThrillWiki API",
|
||||
"DESCRIPTION": "Comprehensive theme park and ride information API",
|
||||
|
||||
@@ -4,30 +4,7 @@ Local development settings for thrillwiki project.
|
||||
|
||||
from ..settings import database
|
||||
import logging
|
||||
from .base import (
|
||||
BASE_DIR,
|
||||
INSTALLED_APPS,
|
||||
MIDDLEWARE,
|
||||
STATIC_ROOT,
|
||||
STATIC_URL,
|
||||
ROOT_URLCONF,
|
||||
AUTH_PASSWORD_VALIDATORS,
|
||||
AUTH_USER_MODEL,
|
||||
TEMPLATES,
|
||||
SECRET_KEY,
|
||||
SPECTACULAR_SETTINGS,
|
||||
REST_FRAMEWORK,
|
||||
)
|
||||
|
||||
SECRET_KEY = SECRET_KEY
|
||||
SPECTACULAR_SETTINGS = SPECTACULAR_SETTINGS
|
||||
REST_FRAMEWORK = REST_FRAMEWORK
|
||||
TEMPLATES = TEMPLATES
|
||||
ROOT_URLCONF = ROOT_URLCONF
|
||||
AUTH_PASSWORD_VALIDATORS = AUTH_PASSWORD_VALIDATORS
|
||||
AUTH_USER_MODEL = AUTH_USER_MODEL
|
||||
STATIC_ROOT = STATIC_ROOT
|
||||
STATIC_URL = STATIC_URL
|
||||
from .base import *
|
||||
|
||||
# Import database configuration
|
||||
DATABASES = database.DATABASES
|
||||
|
||||
@@ -7,449 +7,426 @@
|
||||
--font-family-sans: Poppins, sans-serif;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center px-6 py-2.5 border border-transparent rounded-full shadow-md text-sm font-medium text-white bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center px-6 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full shadow-md text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
|
||||
}
|
||||
/* [Previous styles remain unchanged until mobile menu section...] */
|
||||
|
||||
/* Mobile Menu */
|
||||
#mobileMenu {
|
||||
@apply overflow-hidden transition-all duration-300 ease-in-out opacity-0 max-h-0;
|
||||
}
|
||||
|
||||
#mobileMenu.show {
|
||||
@apply max-h-[300px] opacity-100;
|
||||
}
|
||||
|
||||
#mobileMenu .space-y-4 {
|
||||
@apply pb-6;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
@apply flex items-center justify-center px-6 py-3 text-gray-700 transition-all border border-transparent rounded-lg dark:text-gray-200 hover:bg-primary/10 dark:hover:bg-primary/20 hover:text-primary dark:hover:text-primary hover:border-primary/20 dark:hover:border-primary/30;
|
||||
}
|
||||
|
||||
.mobile-nav-link i {
|
||||
@apply text-xl transition-colors;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.mobile-nav-link i {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-nav-link.primary {
|
||||
@apply text-white bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90;
|
||||
}
|
||||
|
||||
.mobile-nav-link.primary i {
|
||||
@apply mr-3 text-white;
|
||||
}
|
||||
|
||||
.mobile-nav-link.secondary {
|
||||
@apply text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600;
|
||||
}
|
||||
|
||||
.mobile-nav-link.secondary i {
|
||||
@apply mr-3 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
#theme-toggle+.theme-toggle-btn i::before {
|
||||
content: "\f186";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
#theme-toggle:checked+.theme-toggle-btn i::before {
|
||||
content: "\f185";
|
||||
@apply text-yellow-400;
|
||||
}
|
||||
|
||||
/* Navigation Components */
|
||||
.nav-link {
|
||||
@apply flex items-center text-gray-700 transition-all dark:text-gray-200;
|
||||
}
|
||||
|
||||
/* Extra small screens (540px and below) */
|
||||
@media (max-width: 540px) {
|
||||
.nav-link {
|
||||
@apply px-2 py-2 text-sm;
|
||||
}
|
||||
.nav-link i {
|
||||
@apply mr-1 text-base;
|
||||
}
|
||||
.nav-link span {
|
||||
@apply text-sm;
|
||||
}
|
||||
.site-logo {
|
||||
@apply px-1 text-lg;
|
||||
}
|
||||
.nav-container {
|
||||
@apply px-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small screens (541px to 767px) */
|
||||
@media (min-width: 541px) and (max-width: 767px) {
|
||||
.nav-link {
|
||||
@apply px-3 py-2;
|
||||
}
|
||||
.nav-link i {
|
||||
@apply mr-2;
|
||||
}
|
||||
.site-logo {
|
||||
@apply px-2 text-xl;
|
||||
}
|
||||
.nav-container {
|
||||
@apply px-4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium screens and up */
|
||||
@media (min-width: 768px) {
|
||||
.nav-link {
|
||||
@apply px-6 py-2.5 rounded-lg font-medium border border-transparent hover:border-primary/20 dark:hover:border-primary/30;
|
||||
}
|
||||
.nav-link i {
|
||||
@apply mr-3 text-lg;
|
||||
}
|
||||
.site-logo {
|
||||
@apply px-3 text-2xl;
|
||||
}
|
||||
.nav-container {
|
||||
@apply px-6;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@apply text-primary dark:text-primary bg-primary/10 dark:bg-primary/20;
|
||||
}
|
||||
|
||||
.nav-link i {
|
||||
@apply text-gray-500 transition-colors dark:text-gray-400;
|
||||
}
|
||||
|
||||
.nav-link:hover i {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#mobileMenu {
|
||||
@apply !hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu Items */
|
||||
.menu-item {
|
||||
@apply flex items-center w-full px-4 py-3 text-sm text-gray-700 transition-all dark:text-gray-200 hover:bg-primary/10 dark:hover:bg-primary/20 hover:text-primary dark:hover:text-primary first:rounded-t-lg last:rounded-b-lg;
|
||||
}
|
||||
|
||||
.menu-item i {
|
||||
@apply mr-3 text-base text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* Form Components */
|
||||
.form-input {
|
||||
@apply w-full px-4 py-3 text-gray-900 transition-all border border-gray-200 rounded-lg shadow-xs dark:border-gray-700 bg-white/70 dark:bg-gray-800/70 backdrop-blur-xs dark:text-white focus:ring-3 focus:ring-primary/50 focus:border-primary;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
@apply mt-2 space-y-1 text-sm text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply mt-2 text-sm text-red-600 dark:text-red-400;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-3 py-1 text-sm font-medium rounded-full;
|
||||
}
|
||||
|
||||
.status-operating {
|
||||
@apply text-green-800 bg-green-100 dark:bg-green-700 dark:text-green-50;
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
@apply text-red-800 bg-red-100 dark:bg-red-700 dark:text-red-50;
|
||||
}
|
||||
|
||||
.status-construction {
|
||||
@apply text-yellow-800 bg-yellow-100 dark:bg-yellow-600 dark:text-yellow-50;
|
||||
}
|
||||
|
||||
/* Auth Components */
|
||||
.auth-card {
|
||||
@apply w-full max-w-md p-8 mx-auto border shadow-xl bg-white/90 dark:bg-gray-800/90 rounded-2xl backdrop-blur-xs border-gray-200/50 dark:border-gray-700/50;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
@apply mb-8 text-2xl font-bold text-center text-transparent bg-gradient-to-r from-primary to-secondary bg-clip-text;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
@apply relative my-6 text-center;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
@apply absolute top-1/2 w-1/3 border-t border-gray-200 dark:border-gray-700 content-[''];
|
||||
}
|
||||
|
||||
.auth-divider::before {
|
||||
@apply left-0;
|
||||
}
|
||||
|
||||
.auth-divider::after {
|
||||
@apply right-0;
|
||||
}
|
||||
|
||||
.auth-divider span {
|
||||
@apply px-4 text-sm text-gray-500 dark:text-gray-400 bg-white/90 dark:bg-gray-800/90;
|
||||
}
|
||||
|
||||
/* Social Login Buttons */
|
||||
.btn-social {
|
||||
@apply w-full flex items-center justify-center px-6 py-3 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xs text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-[1.02] transition-all mb-3;
|
||||
}
|
||||
|
||||
.btn-discord {
|
||||
@apply text-gray-700 bg-white border-gray-200 hover:bg-gray-50 shadow-gray-200/50 dark:shadow-gray-900/50;
|
||||
}
|
||||
|
||||
.btn-google {
|
||||
@apply text-gray-700 bg-white border-gray-200 hover:bg-gray-50 shadow-gray-200/50 dark:shadow-gray-900/50;
|
||||
}
|
||||
|
||||
/* Alert Components */
|
||||
.alert {
|
||||
@apply p-4 mb-4 shadow-lg rounded-xl backdrop-blur-xs;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
@apply text-green-800 border border-green-200 bg-green-100/90 dark:bg-green-800/30 dark:text-green-100 dark:border-green-700;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
@apply text-red-800 border border-red-200 bg-red-100/90 dark:bg-red-800/30 dark:text-red-100 dark:border-red-700;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
@apply text-yellow-800 border border-yellow-200 bg-yellow-100/90 dark:bg-yellow-800/30 dark:text-yellow-100 dark:border-yellow-700;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
@apply text-blue-800 border border-blue-200 bg-blue-100/90 dark:bg-blue-800/30 dark:text-blue-100 dark:border-blue-700;
|
||||
}
|
||||
|
||||
/* Layout Components */
|
||||
.card {
|
||||
@apply p-6 bg-white border rounded-lg shadow-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply transition-transform transform hover:-translate-y-1;
|
||||
}
|
||||
|
||||
.grid-cards {
|
||||
@apply grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
|
||||
}
|
||||
|
||||
/* Adaptive Grid System - White Space Solutions */
|
||||
.grid-adaptive {
|
||||
@apply grid gap-6;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.grid-adaptive-sm {
|
||||
@apply grid gap-4;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.grid-adaptive-lg {
|
||||
@apply grid gap-8;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
/* Stats Grid - Always Even Layout */
|
||||
.grid-stats {
|
||||
@apply grid gap-4;
|
||||
/* Default: Force 2+3 layout for small screens */
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-stats-wide {
|
||||
@apply grid gap-4;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
/* Enhanced Responsive Grid */
|
||||
.grid-responsive {
|
||||
@apply grid grid-cols-1 gap-6;
|
||||
@apply sm:grid-cols-2;
|
||||
@apply md:grid-cols-3;
|
||||
@apply lg:grid-cols-4;
|
||||
@apply xl:grid-cols-5;
|
||||
@apply 2xl:grid-cols-6;
|
||||
}
|
||||
|
||||
.grid-responsive-cards {
|
||||
@apply grid grid-cols-1 gap-6;
|
||||
@apply md:grid-cols-2;
|
||||
@apply lg:grid-cols-3;
|
||||
@apply xl:grid-cols-4;
|
||||
@apply 2xl:grid-cols-5;
|
||||
}
|
||||
|
||||
/* Tablet-specific optimizations for 768px breakpoint */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.grid-adaptive-sm {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
/* Force 2+3 even layout for tablets */
|
||||
.grid-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Content-aware grid adjustments */
|
||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
.grid-adaptive-lg {
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
}
|
||||
/* Force 3+2 even layout for intermediate sizes */
|
||||
.grid-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
.grid-adaptive-lg {
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
}
|
||||
/* Force 5-column even layout for large screens */
|
||||
.grid-stats {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Priority Card - Operator/Owner Full-Width Responsive Behavior */
|
||||
.card-stats-priority {
|
||||
/* Full width by default (small screens) */
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Medium screens - still full width for emphasis */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.card-stats-priority {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large screens - normal grid behavior */
|
||||
@media (min-width: 1024px) {
|
||||
.card-stats-priority {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
}
|
||||
.grid-adaptive-lg {
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.heading-1 {
|
||||
@apply mb-6 text-3xl font-bold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.heading-2 {
|
||||
@apply mb-4 text-2xl font-bold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
}
|
||||
|
||||
/* Turnstile Widget */
|
||||
.turnstile {
|
||||
@apply flex items-center justify-center my-4;
|
||||
}
|
||||
|
||||
/* Layout Optimization - Phase 1 Critical Fixes */
|
||||
/* Optimized Padding System */
|
||||
.p-compact {
|
||||
@apply p-5; /* 20px - replaces excessive p-6 (24px) */
|
||||
}
|
||||
|
||||
.p-optimized {
|
||||
@apply p-4; /* 16px - replaces p-6 (24px) for 33% reduction */
|
||||
}
|
||||
|
||||
.p-minimal {
|
||||
@apply p-3; /* 12px - replaces p-2 (8px) for consistency */
|
||||
}
|
||||
|
||||
/* Consistent Card Heights */
|
||||
.card-standard {
|
||||
@apply min-h-[120px];
|
||||
}
|
||||
|
||||
.card-large {
|
||||
@apply min-h-[200px];
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
@apply min-h-[80px];
|
||||
}
|
||||
|
||||
/* Mobile Responsive Padding Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.p-compact {
|
||||
@apply p-4; /* 16px on mobile */
|
||||
}
|
||||
.p-optimized {
|
||||
@apply p-3.5; /* 14px on mobile */
|
||||
}
|
||||
.p-minimal {
|
||||
@apply p-2.5; /* 10px on mobile */
|
||||
}
|
||||
|
||||
.card-standard {
|
||||
@apply min-h-[100px];
|
||||
}
|
||||
|
||||
.card-large {
|
||||
@apply min-h-[160px];
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
@apply min-h-[80px];
|
||||
}
|
||||
}
|
||||
/* Base Component Styles */
|
||||
.site-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-logo:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Navigation Styles */
|
||||
.nav-container {
|
||||
padding: 1rem 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-primary);
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.nav-link i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-link span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Dark mode form styles */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-input {
|
||||
background-color: #374151;
|
||||
border-color: #4b5563;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--color-primary), #3730a3);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #3730a3, #312e81);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 12px -2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Dark mode button styles */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-secondary {
|
||||
border-color: #4b5563;
|
||||
color: #e5e7eb;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu Styles */
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.menu-item i {
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Dark mode menu styles */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.menu-item {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #4b5563;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme Toggle Styles */
|
||||
.theme-toggle-btn {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.theme-toggle-btn i::before {
|
||||
content: "\f185"; /* sun icon */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.theme-toggle-btn i::before {
|
||||
content: "\f186"; /* moon icon */
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Menu Styles */
|
||||
#mobileMenu {
|
||||
display: none;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#mobileMenu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#mobileMenu {
|
||||
border-top-color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid Adaptive Styles */
|
||||
.grid-adaptive-sm {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.grid-adaptive {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Card Styles */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
background: #1f2937;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert Styles */
|
||||
.alert {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 50;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--color-primary);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.bg-gradient-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary), #3730a3);
|
||||
}
|
||||
|
||||
.bg-gradient-secondary {
|
||||
background: linear-gradient(135deg, var(--color-secondary), #be185d);
|
||||
}
|
||||
|
||||
/* Responsive Utilities */
|
||||
@media (max-width: 1023px) {
|
||||
.lg\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:flex {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.lg\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus States */
|
||||
.focus-ring {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.focus-ring:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
12
backend/static/js/alpine.min.js
vendored
12
backend/static/js/alpine.min.js
vendored
File diff suppressed because one or more lines are too long
5
backend/static/js/cdn.min.js.1
Normal file
5
backend/static/js/cdn.min.js.1
Normal file
File diff suppressed because one or more lines are too long
5
backend/static/js/cdn.min.js.2
Normal file
5
backend/static/js/cdn.min.js.2
Normal file
File diff suppressed because one or more lines are too long
@@ -1,909 +0,0 @@
|
||||
{# Enhanced filter form with modern UI/UX design - timestamp: 2025-08-21 #}
|
||||
{% load static %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<!-- Mobile Filter Toggle Button -->
|
||||
<button class="mobile-nav-toggle md:hidden fixed bottom-6 right-6 z-50 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-4 focus:ring-blue-500/50"
|
||||
onclick="toggleMobileFilters()"
|
||||
aria-label="Toggle filters"
|
||||
data-tooltip="Show/Hide Filters">
|
||||
<svg class="w-6 h-6 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="filter-container" x-data="filterManager()" x-init="init()">
|
||||
{# Quick Filter Presets - Enhanced Design #}
|
||||
<div class="mb-8 bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-blue-500 to-purple-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Quick Filters</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks instantly with preset filters</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Live filtering</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('disney')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('disney') ? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg ring-2 ring-blue-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Show Disney Parks">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('disney')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Disney Parks</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.disney" x-text="`${quickFilterCounts.disney} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-400/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('coasters')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('coasters') ? 'bg-gradient-to-br from-green-500 to-green-600 text-white shadow-lg ring-2 ring-green-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-green-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Parks with Roller Coasters">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-red-400 rounded-full animate-ping" x-show="isQuickFilterActive('coasters')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">With Coasters</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.coasters" x-text="`${quickFilterCounts.coasters} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-green-400/10 to-emerald-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('top_rated')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('top_rated') ? 'bg-gradient-to-br from-yellow-500 to-yellow-600 text-white shadow-lg ring-2 ring-yellow-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-yellow-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Highly Rated Parks (4+ stars)">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('top_rated')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Top Rated</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.top_rated" x-text="`${quickFilterCounts.top_rated} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-yellow-400/10 to-orange-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('major_parks')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('major_parks') ? 'bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-lg ring-2 ring-purple-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-purple-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Major Theme Parks (10+ rides)">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-purple-400 rounded-full animate-ping" x-show="isQuickFilterActive('major_parks')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Major Parks</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.major_parks" x-text="`${quickFilterCounts.major_parks} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-purple-400/10 to-pink-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Mobile Filter Toggle - Enhanced Design #}
|
||||
<div class="lg:hidden mb-6">
|
||||
<button type="button"
|
||||
@click="showMobileFilters = !showMobileFilters"
|
||||
class="w-full group relative overflow-hidden rounded-xl p-4 bg-gradient-to-r from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-blue-500/10 dark:bg-blue-400/10 rounded-lg">
|
||||
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-white">Filters</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Customize your search</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 rounded-full">
|
||||
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300" x-text="activeFilterCount + ' active'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-6 h-6 text-gray-400 transition-transform duration-300" :class="showMobileFilters ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Main Filter Form - Enhanced Design #}
|
||||
<form hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="change delay:300ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML transition:true"
|
||||
class="space-y-8"
|
||||
:class="showMobileFilters || window.innerWidth >= 1024 ? 'block' : 'hidden'"
|
||||
x-show.transition="showMobileFilters || window.innerWidth >= 1024">
|
||||
|
||||
{# Search Input Section - Enhanced Design #}
|
||||
<div class="bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-850 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-green-500 to-blue-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Search & Discover</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks by name, location, or features</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-xl blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative">
|
||||
<label for="search" class="sr-only">Search Parks</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
x-model="searchQuery"
|
||||
placeholder="Search by park name, location, or features..."
|
||||
class="w-full px-6 py-4 pl-14 pr-12 text-lg border-2 border-gray-200 dark:border-gray-600 rounded-xl bg-white/80 dark:bg-gray-800/80 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-300 ease-out backdrop-blur-sm hover:border-gray-300 dark:hover:border-gray-500 group-focus-within:shadow-xl">
|
||||
|
||||
{# Search Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<div class="p-1 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<svg class="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Clear Button #}
|
||||
<div class="absolute inset-y-0 right-0 pr-4 flex items-center" x-show="searchQuery" x-transition>
|
||||
<button type="button"
|
||||
@click="searchQuery = ''"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
title="Clear search">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Loading Indicator #}
|
||||
<div class="absolute inset-y-0 right-0 pr-4 flex items-center" x-show="isLoading" x-transition>
|
||||
<div class="animate-spin h-5 w-5 text-blue-500">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Search Suggestions/Hints #}
|
||||
<div class="mt-3 flex flex-wrap gap-2" x-show="!searchQuery">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Popular searches:</div>
|
||||
<button type="button" @click="searchQuery = 'Disney'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Disney</button>
|
||||
<button type="button" @click="searchQuery = 'roller coaster'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Roller Coaster</button>
|
||||
<button type="button" @click="searchQuery = 'California'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">California</button>
|
||||
<button type="button" @click="searchQuery = 'water park'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Water Park</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Loading Indicator #}
|
||||
<div id="loading-indicator" class="htmx-indicator fixed top-4 right-4 z-50">
|
||||
<div class="bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-3 rounded-xl shadow-2xl border border-blue-500/20 backdrop-blur-sm">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="animate-spin h-5 w-5 text-white">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">Updating results...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Filters Summary - Enhanced #}
|
||||
<div x-show="activeFilterCount > 0"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 transform scale-100 translate-y-0"
|
||||
class="bg-gradient-to-r from-blue-50 via-indigo-50 to-purple-50 dark:from-blue-900/20 dark:via-indigo-900/20 dark:to-purple-900/20 rounded-xl p-5 shadow-lg border border-blue-200/50 dark:border-blue-700/50 backdrop-blur-sm">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-800/30 rounded-lg">
|
||||
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-blue-900 dark:text-blue-100">Active Filters</h3>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300" x-text="`${activeFilterCount} filter${activeFilterCount !== 1 ? 's' : ''} applied`"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
@click="clearAllFilters()"
|
||||
class="px-4 py-2 text-sm font-semibold text-blue-700 dark:text-blue-300 bg-white/80 dark:bg-gray-800/80 hover:bg-blue-100 dark:hover:bg-blue-800/50 rounded-lg border border-blue-200 dark:border-blue-600 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 hover:shadow-md"
|
||||
aria-label="Clear all filters">
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Filter Groups #}
|
||||
<div class="bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-850 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm">
|
||||
<div class="p-6" x-data="{ expanded: true }">
|
||||
{# Enhanced Group Header #}
|
||||
<button type="button"
|
||||
@click="expanded = !expanded"
|
||||
class="w-full group flex justify-between items-center text-left p-3 -m-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200"
|
||||
:aria-expanded="expanded"
|
||||
aria-controls="filter-group-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-purple-500 to-pink-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">Advanced Filters</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Refine your search with detailed criteria</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">Advanced</span>
|
||||
</div>
|
||||
<svg class="w-6 h-6 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 transform transition-all duration-300"
|
||||
:class="{'rotate-180': !expanded}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{# Enhanced Group Content #}
|
||||
<div id="filter-group-1"
|
||||
class="mt-6 space-y-6"
|
||||
x-show="expanded"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 max-h-0"
|
||||
x-transition:enter-end="opacity-100 max-h-screen"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 max-h-screen"
|
||||
x-transition:leave-end="opacity-0 max-h-0">
|
||||
{% for field in filter.form %}
|
||||
<div class="filter-field group">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="flex items-center justify-between text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
{# Enhanced Field Icons Based on Type #}
|
||||
<div class="p-2 bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-900/30 dark:to-purple-900/30 rounded-lg border border-blue-200/50 dark:border-blue-700/50">
|
||||
{% if field.name == 'search' or field.field.widget.input_type == 'search' %}
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{% elif 'location' in field.name or 'city' in field.name or 'state' in field.name or 'country' in field.name %}
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{% elif 'date' in field.name or field.field.widget.input_type == 'date' %}
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% elif 'operator' in field.name or 'company' in field.name %}
|
||||
<svg class="w-4 h-4 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{% elif 'rating' in field.name or 'star' in field.name %}
|
||||
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||||
</svg>
|
||||
{% elif 'count' in field.name or field.field.widget.input_type == 'number' %}
|
||||
<svg class="w-4 h-4 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
{% elif field.field.widget.input_type == 'checkbox' %}
|
||||
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<svg class="w-4 h-4 text-pink-600 dark:text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span>{{ field.label }}</span>
|
||||
{% if field.help_text %}
|
||||
<button type="button"
|
||||
class="text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 focus:outline-none transition-colors duration-200"
|
||||
@click="$tooltip('{{ field.help_text|escapejs }}', $event)"
|
||||
aria-label="Help for {{ field.label }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Field Clear Button #}
|
||||
<button type="button"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 dark:hover:text-red-400 focus:outline-none transition-all duration-200 text-xs"
|
||||
@click="clearField('{{ field.id_for_label }}')"
|
||||
aria-label="Clear {{ field.label }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'search' %}
|
||||
<div class="relative group/input">
|
||||
<input type="{{ field.field.widget.input_type }}"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
placeholder="{{ field.field.widget.attrs.placeholder|default:'' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{% if field.name == 'search' or field.field.widget.input_type == 'search' %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{% elif 'location' in field.name or 'city' in field.name %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'number' %}
|
||||
<div class="relative group/input">
|
||||
<input type="number"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
min="{{ field.field.widget.attrs.min|default:'' }}"
|
||||
max="{{ field.field.widget.attrs.max|default:'' }}"
|
||||
placeholder="{{ field.field.widget.attrs.placeholder|default:'Enter number...' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'checkbox' %}
|
||||
<div class="relative group/checkbox">
|
||||
<div class="flex items-center p-4 bg-gradient-to-r from-gray-50 to-white dark:from-gray-700/50 dark:to-gray-800/50 rounded-lg border-2 border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-gradient-to-r hover:from-blue-50 hover:to-purple-50 dark:hover:from-blue-900/20 dark:hover:to-purple-900/20 transition-all duration-200 cursor-pointer">
|
||||
<div class="relative flex items-center">
|
||||
<input type="checkbox"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
{% if field.value %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-2 focus:ring-blue-500/20 border-2 border-gray-300 dark:border-gray-500 dark:bg-gray-600 rounded transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<div class="absolute inset-0 rounded bg-blue-500 opacity-0 group-hover/checkbox:opacity-10 transition-opacity duration-200"></div>
|
||||
</div>
|
||||
<label for="{{ field.id_for_label }}" class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{# Checkbox Icon #}
|
||||
<div class="ml-auto text-gray-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<div class="relative group/select">
|
||||
<select name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
class="block w-full pl-12 pr-10 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/select:shadow-md appearance-none">
|
||||
{% for choice in field.field.choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == field.value %}selected{% endif %}>{{ choice.1 }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{% if 'operator' in field.name %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Right Dropdown Arrow #}
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg class="h-5 w-5 text-gray-400 group-hover/select:text-gray-600 dark:group-hover/select:text-gray-300 transition-colors duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'date' %}
|
||||
<div class="relative group/input">
|
||||
<input type="date"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="relative group/input">
|
||||
{{ field|add_class:"block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md" }}
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Mobile Apply Button #}
|
||||
<div class="lg:hidden">
|
||||
<button type="submit"
|
||||
class="w-full group relative overflow-hidden bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-6 py-4 rounded-xl font-semibold text-lg shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-300 transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
:disabled="isLoading">
|
||||
<div class="relative z-10 flex items-center justify-center">
|
||||
<span x-show="!isLoading" class="flex items-center space-x-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
<span>Apply Filters</span>
|
||||
</span>
|
||||
<span x-show="isLoading" class="flex items-center space-x-3">
|
||||
<div class="animate-spin h-5 w-5 text-white">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">Applying Filters...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Enhanced Scripts #}
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('filterManager', () => ({
|
||||
mobileFiltersOpen: false,
|
||||
activeFilterCount: 0,
|
||||
isLoading: false,
|
||||
quickFilterCounts: {
|
||||
disney: 0,
|
||||
coasters: 0,
|
||||
top_rated: 0,
|
||||
major_parks: 0
|
||||
},
|
||||
|
||||
init() {
|
||||
this.updateActiveFilterCount();
|
||||
this.loadQuickFilterCounts();
|
||||
|
||||
// Listen for HTMX events
|
||||
document.addEventListener('htmx:beforeRequest', () => {
|
||||
this.isLoading = true;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:responseError', () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
toggleMobileFilters() {
|
||||
this.mobileFiltersOpen = !this.mobileFiltersOpen;
|
||||
},
|
||||
|
||||
updateActiveFilterCount() {
|
||||
// Count active filters from form inputs
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
let count = 0;
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value && input.value !== '' && input.value !== 'all') {
|
||||
// Skip hidden fields and empty values
|
||||
if (input.type !== 'hidden' && input.value !== '0') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.activeFilterCount = count;
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear all form inputs
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger form submission
|
||||
this.activeFilterCount = 0;
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
applyQuickFilter(filterType) {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear existing filters first
|
||||
this.clearAllFilters();
|
||||
|
||||
// Apply specific filter based on type
|
||||
switch(filterType) {
|
||||
case 'disney':
|
||||
const parkTypeField = form.querySelector('[name="park_type"]');
|
||||
if (parkTypeField) parkTypeField.value = 'disney';
|
||||
break;
|
||||
|
||||
case 'coasters':
|
||||
const coastersField = form.querySelector('[name="has_coasters"]');
|
||||
if (coastersField) coastersField.checked = true;
|
||||
break;
|
||||
|
||||
case 'top_rated':
|
||||
const ratingField = form.querySelector('[name="min_rating"]');
|
||||
if (ratingField) ratingField.value = '4';
|
||||
break;
|
||||
|
||||
case 'major_parks':
|
||||
const bigParksField = form.querySelector('[name="big_parks_only"]');
|
||||
if (bigParksField) bigParksField.checked = true;
|
||||
break;
|
||||
}
|
||||
|
||||
this.updateActiveFilterCount();
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
isQuickFilterActive(filterType) {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return false;
|
||||
|
||||
switch(filterType) {
|
||||
case 'disney':
|
||||
const parkTypeField = form.querySelector('[name="park_type"]');
|
||||
return parkTypeField && parkTypeField.value === 'disney';
|
||||
|
||||
case 'coasters':
|
||||
const coastersField = form.querySelector('[name="has_coasters"]');
|
||||
return coastersField && coastersField.checked;
|
||||
|
||||
case 'top_rated':
|
||||
const ratingField = form.querySelector('[name="min_rating"]');
|
||||
return ratingField && ratingField.value === '4';
|
||||
|
||||
case 'major_parks':
|
||||
const bigParksField = form.querySelector('[name="big_parks_only"]');
|
||||
return bigParksField && bigParksField.checked;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
loadQuickFilterCounts() {
|
||||
// This would typically fetch from an API endpoint
|
||||
// For now, set some placeholder values
|
||||
this.quickFilterCounts = {
|
||||
disney: 12,
|
||||
coasters: 156,
|
||||
top_rated: 89,
|
||||
major_parks: 78
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
// Tooltip directive
|
||||
Alpine.directive('tooltip', (el, { expression }) => {
|
||||
el._tooltip = expression;
|
||||
});
|
||||
|
||||
// Global tooltip function
|
||||
window.$tooltip = function(text, event) {
|
||||
// Simple tooltip implementation - could be enhanced with a library
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'fixed z-50 bg-gray-900 text-white text-sm rounded py-1 px-2 pointer-events-none';
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.left = event.pageX + 10 + 'px';
|
||||
tooltip.style.top = event.pageY - 30 + 'px';
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.parentNode.removeChild(tooltip);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Global field clearing function
|
||||
window.clearField = function(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field) return;
|
||||
|
||||
if (field.type === 'checkbox' || field.type === 'radio') {
|
||||
field.checked = false;
|
||||
} else {
|
||||
field.value = '';
|
||||
}
|
||||
|
||||
// Trigger change event to update filters
|
||||
const form = field.closest('form');
|
||||
if (form) {
|
||||
htmx.trigger(form, 'change');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Enhanced debouncing for text inputs
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target.type === 'text' || e.target.type === 'search') {
|
||||
clearTimeout(e.target._debounceTimer);
|
||||
e.target._debounceTimer = setTimeout(() => {
|
||||
htmx.trigger(e.target.form, 'change');
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Mobile filter toggle functionality
|
||||
function toggleMobileFilters() {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
const backdrop = document.querySelector('.filter-backdrop');
|
||||
|
||||
if (filterPanel) {
|
||||
filterPanel.classList.toggle('is-open');
|
||||
|
||||
// Create backdrop if it doesn't exist
|
||||
if (!backdrop) {
|
||||
const newBackdrop = document.createElement('div');
|
||||
newBackdrop.className = 'filter-backdrop fixed inset-0 bg-black/50 z-40 md:hidden';
|
||||
newBackdrop.onclick = toggleMobileFilters;
|
||||
document.body.appendChild(newBackdrop);
|
||||
} else {
|
||||
backdrop.style.display = filterPanel.classList.contains('is-open') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.classList.toggle('overflow-hidden', filterPanel.classList.contains('is-open'));
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced toast notification system
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toast-container') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification transform transition-all duration-300 ease-out translate-x-full opacity-0 mb-4 p-4 rounded-lg shadow-lg flex items-center space-x-3 ${getToastClasses(type)}`;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="flex-shrink-0">
|
||||
${getToastIcon(type)}
|
||||
</div>
|
||||
<div class="flex-1 text-sm font-medium">${message}</div>
|
||||
<button onclick="removeToast(this.parentElement)" class="flex-shrink-0 ml-4 text-current opacity-70 hover:opacity-100 transition-opacity">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-x-full', 'opacity-0');
|
||||
}, 100);
|
||||
|
||||
// Auto remove
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(toast), duration);
|
||||
}
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed top-4 right-4 z-50 max-w-sm w-full';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
toast.classList.add('translate-x-full', 'opacity-0');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
|
||||
function getToastClasses(type) {
|
||||
const classes = {
|
||||
success: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800',
|
||||
error: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800',
|
||||
info: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800'
|
||||
};
|
||||
return classes[type] || classes.info;
|
||||
}
|
||||
|
||||
function getToastIcon(type) {
|
||||
const icons = {
|
||||
success: `<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
error: `<svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
warning: `<svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
info: `<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>`
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
|
||||
// Enhanced filter manager with additional functionality
|
||||
function enhancedFilterManager() {
|
||||
return {
|
||||
init() {
|
||||
this.setupKeyboardShortcuts();
|
||||
this.setupIntersectionObserver();
|
||||
this.setupTouchGestures();
|
||||
},
|
||||
|
||||
setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Escape to close mobile filters
|
||||
if (e.key === 'Escape') {
|
||||
const filterPanel = document.querySelector('.filter-panel.is-open');
|
||||
if (filterPanel) {
|
||||
toggleMobileFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + K to focus search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
showToast('Search focused - start typing!', 'info', 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupIntersectionObserver() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('fade-in-scale');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
// Observe park cards for scroll animations
|
||||
document.querySelectorAll('.park-card').forEach(card => {
|
||||
observer.observe(card);
|
||||
});
|
||||
},
|
||||
|
||||
setupTouchGestures() {
|
||||
let startY = 0;
|
||||
let currentY = 0;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
currentY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
const deltaY = startY - currentY;
|
||||
|
||||
// Swipe up to show filters (mobile)
|
||||
if (deltaY > 50 && window.innerWidth < 768) {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
if (filterPanel && !filterPanel.classList.contains('is-open')) {
|
||||
toggleMobileFilters();
|
||||
showToast('Filters opened', 'info', 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize enhanced functionality when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const enhanced = enhancedFilterManager();
|
||||
enhanced.init();
|
||||
|
||||
// Show welcome message
|
||||
setTimeout(() => {
|
||||
showToast('Welcome! Use Ctrl+K to focus search or swipe up for filters.', 'info', 4000);
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
@@ -1,28 +1,310 @@
|
||||
<form hx-get="{% url 'search:search' %}" hx-target="#search-results" hx-swap="outerHTML" class="space-y-4">
|
||||
{% for field in filters.form %}
|
||||
<div class="flex flex-col">
|
||||
<label for="{{ field.id_for_label }}" class="text-sm font-medium text-gray-700">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ field }}
|
||||
{# Modern Filter Interface - timestamp: 2025-08-29 #}
|
||||
{% load static %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="space-y-6">
|
||||
{# Search Section #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
Search
|
||||
</h3>
|
||||
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="input changed delay:500ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="space-y-4">
|
||||
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
name="search"
|
||||
value="{{ request.GET.search|default:'' }}"
|
||||
placeholder="Search parks by name, location..."
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<p class="text-sm text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Filters Section #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z"/>
|
||||
</svg>
|
||||
Filters
|
||||
</h3>
|
||||
|
||||
<form id="filter-form"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="change delay:300ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="space-y-6">
|
||||
|
||||
{# Preserve search term #}
|
||||
{% if request.GET.search %}
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% endif %}
|
||||
|
||||
{# Status Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">All Status</option>
|
||||
<option value="OPERATING" {% if request.GET.status == 'OPERATING' %}selected{% endif %}>Operating</option>
|
||||
<option value="CLOSED_TEMP" {% if request.GET.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
|
||||
<option value="CLOSED_PERM" {% if request.GET.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||
<option value="UNDER_CONSTRUCTION" {% if request.GET.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
|
||||
<option value="DEMOLISHED" {% if request.GET.status == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
|
||||
<option value="RELOCATED" {% if request.GET.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Operator Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Operator
|
||||
</label>
|
||||
<select name="operator"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">All Operators</option>
|
||||
<option value="cedar-fair" {% if request.GET.operator == 'cedar-fair' %}selected{% endif %}>Cedar Fair</option>
|
||||
<option value="six-flags" {% if request.GET.operator == 'six-flags' %}selected{% endif %}>Six Flags</option>
|
||||
<option value="disney" {% if request.GET.operator == 'disney' %}selected{% endif %}>Disney</option>
|
||||
<option value="universal" {% if request.GET.operator == 'universal' %}selected{% endif %}>Universal</option>
|
||||
<option value="busch-gardens" {% if request.GET.operator == 'busch-gardens' %}selected{% endif %}>Busch Gardens</option>
|
||||
<option value="knott's" {% if request.GET.operator == 'knotts' %}selected{% endif %}>Knott's</option>
|
||||
<option value="herschend" {% if request.GET.operator == 'herschend' %}selected{% endif %}>Herschend</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Rating Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Rating
|
||||
</label>
|
||||
<select name="min_rating"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">Any Rating</option>
|
||||
<option value="1" {% if request.GET.min_rating == '1' %}selected{% endif %}>1+ Stars</option>
|
||||
<option value="2" {% if request.GET.min_rating == '2' %}selected{% endif %}>2+ Stars</option>
|
||||
<option value="3" {% if request.GET.min_rating == '3' %}selected{% endif %}>3+ Stars</option>
|
||||
<option value="4" {% if request.GET.min_rating == '4' %}selected{% endif %}>4+ Stars</option>
|
||||
<option value="5" {% if request.GET.min_rating == '5' %}selected{% endif %}>5+ Stars</option>
|
||||
<option value="6" {% if request.GET.min_rating == '6' %}selected{% endif %}>6+ Stars</option>
|
||||
<option value="7" {% if request.GET.min_rating == '7' %}selected{% endif %}>7+ Stars</option>
|
||||
<option value="8" {% if request.GET.min_rating == '8' %}selected{% endif %}>8+ Stars</option>
|
||||
<option value="9" {% if request.GET.min_rating == '9' %}selected{% endif %}>9+ Stars</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Ride Count Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Rides
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_rides"
|
||||
value="{{ request.GET.min_rides|default:'' }}"
|
||||
min="0"
|
||||
placeholder="Any number"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Coaster Count Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Coasters
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_coasters"
|
||||
value="{{ request.GET.min_coasters|default:'' }}"
|
||||
min="0"
|
||||
placeholder="Any number"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Opening Year Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Opening Year
|
||||
</label>
|
||||
<input type="number"
|
||||
name="opening_year"
|
||||
value="{{ request.GET.opening_year|default:'' }}"
|
||||
min="1800"
|
||||
max="2030"
|
||||
placeholder="Any year"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Size Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Size (acres)
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_size"
|
||||
value="{{ request.GET.min_size|default:'' }}"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="Any size"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Special Features #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Special Features
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="has_roller_coasters"
|
||||
value="true"
|
||||
{% if request.GET.has_roller_coasters %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Roller Coasters</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="has_water_rides"
|
||||
value="true"
|
||||
{% if request.GET.has_water_rides %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Water Rides</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="family_friendly"
|
||||
value="true"
|
||||
{% if request.GET.family_friendly %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Family Friendly</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sort & View Options #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
|
||||
</svg>
|
||||
Sort By
|
||||
</h3>
|
||||
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="change"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true">
|
||||
|
||||
{# Preserve all current filters #}
|
||||
{% for key, value in request.GET.items %}
|
||||
{% if key != 'sort' and key != 'view_mode' %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<select name="sort"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="name" {% if request.GET.sort == 'name' or not request.GET.sort %}selected{% endif %}>Name A-Z</option>
|
||||
<option value="-name" {% if request.GET.sort == '-name' %}selected{% endif %}>Name Z-A</option>
|
||||
<option value="-average_rating" {% if request.GET.sort == '-average_rating' %}selected{% endif %}>Highest Rated</option>
|
||||
<option value="average_rating" {% if request.GET.sort == 'average_rating' %}selected{% endif %}>Lowest Rated</option>
|
||||
<option value="-opening_date" {% if request.GET.sort == '-opening_date' %}selected{% endif %}>Newest</option>
|
||||
<option value="opening_date" {% if request.GET.sort == 'opening_date' %}selected{% endif %}>Oldest</option>
|
||||
<option value="-ride_count" {% if request.GET.sort == '-ride_count' %}selected{% endif %}>Most Rides</option>
|
||||
<option value="-coaster_count" {% if request.GET.sort == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
|
||||
<option value="-size" {% if request.GET.sort == '-size' %}selected{% endif %}>Largest</option>
|
||||
<option value="size" {% if request.GET.sort == 'size' %}selected{% endif %}>Smallest</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Clear Filters Button #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<button type="button"
|
||||
onclick="clearAllFilters()"
|
||||
class="w-full px-4 py-3 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white font-medium rounded-lg transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Loading Indicator #}
|
||||
<div id="loading-indicator" class="htmx-indicator fixed top-4 right-4 z-50">
|
||||
<div class="bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function clearAllFilters() {
|
||||
// Navigate to the base URL without any query parameters
|
||||
window.location.href = "{% url 'parks:park_list' %}";
|
||||
}
|
||||
|
||||
// Enhanced form handling
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-submit forms on change with debouncing
|
||||
const forms = document.querySelectorAll('#filter-form, form[hx-trigger*="change"]');
|
||||
forms.forEach(form => {
|
||||
let timeout;
|
||||
form.addEventListener('change', function(e) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
htmx.trigger(form, 'submit');
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Apply Filters
|
||||
</button>
|
||||
{% if applied_filters %}
|
||||
<a href="{% url 'search:search' %}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-xs text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Clear Filters
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
// Handle search input with longer delay
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
htmx.trigger(e.target.closest('form'), 'submit');
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,19 @@
|
||||
{# Enhanced Filters Sidebar with Sticky Positioning #}
|
||||
<aside class="xl:col-span-1 order-2 xl:order-1">
|
||||
<div class="sticky top-4 z-30 space-y-6">
|
||||
{# Mobile Filter Toggle Container #}
|
||||
{# Desktop Filter Content Container #}
|
||||
<div id="desktop-filter-panel" class="hidden xl:block">
|
||||
<div class="xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto xl:scrollbar-thin xl:scrollbar-thumb-gray-300 dark:xl:scrollbar-thumb-gray-600 xl:scrollbar-track-transparent">
|
||||
{% include "core/search/filters.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# Enhanced Results Section with Better Responsiveness #}
|
||||
<main class="xl:col-span-3 order-1 xl:order-2">
|
||||
<div id="results-container" class="space-y-6">
|
||||
{# Mobile Filter Toggle and Panel - Moved to top for better UX #}
|
||||
<div class="xl:hidden">
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg">
|
||||
<button type="button"
|
||||
@@ -29,24 +41,19 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# Mobile Filter Panel - Adjacent to toggle for proper JavaScript functionality #}
|
||||
<div id="mobile-filter-panel" class="hidden border-t border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="p-4">
|
||||
{% block filter_section %}
|
||||
<!-- Mobile filter content -->
|
||||
{% include "core/search/filters.html" %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter Content Container #}
|
||||
<div id="mobile-filter-panel" class="hidden xl:block">
|
||||
<div class="xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto xl:scrollbar-thin xl:scrollbar-thumb-gray-300 dark:xl:scrollbar-thumb-gray-600 xl:scrollbar-track-transparent">
|
||||
{% block filter_section %}
|
||||
<!-- DEBUG: base filtered_list.html filter_section block - timestamp: 2025-08-21 -->
|
||||
{% include "core/search/components/filter_form.html" %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# Enhanced Results Section with Better Responsiveness #}
|
||||
<main class="xl:col-span-3 order-1 xl:order-2">
|
||||
<div id="results-container" class="space-y-6">
|
||||
{# Enhanced Header Section #}
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg">
|
||||
<div class="p-4 lg:p-6">
|
||||
@@ -180,4 +187,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,100 +1,117 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Search Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<!-- Filters Sidebar -->
|
||||
<div class="lg:w-1/4">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Filter Parks</h2>
|
||||
{% include "search/filters.html" %}
|
||||
<!-- Top Filter Bar -->
|
||||
{% include "core/search/filters.html" %}
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="search-results">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Search Results
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ results.count|default:0 }} found)</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="lg:w-3/4" id="search-results">
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">
|
||||
Search Results
|
||||
<span class="text-sm font-normal text-gray-500">({{ results.count }} found)</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 rounded-lg overflow-hidden">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No Image
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if park.formatted_location %}
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400 flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{{ park.formatted_location }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
{{ park.average_rating }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600">
|
||||
{% if park.formatted_location %}
|
||||
<p>{{ park.formatted_location }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} dark:bg-opacity-30">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ park.average_rating }} ★
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{{ park.ride_count }} Ride{{ park.ride_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{{ park.ride_count }} Rides
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{{ park.coaster_count }} Coasters
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
|
||||
{{ park.description }}
|
||||
</p>
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||
{{ park.coaster_count }} Coaster{{ park.coaster_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ park.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if park.opening_date %}
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
Opened: {{ park.opening_date|date:"Y" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-6 text-center text-gray-500">
|
||||
No parks found matching your criteria.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No parks found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria or filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# Include required scripts #}
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://unpkg.com/unpoly@3/unpoly.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -107,13 +107,7 @@ urlpatterns = [
|
||||
if HAS_AUTOCOMPLETE and autocomplete_urls:
|
||||
urlpatterns.insert(
|
||||
2,
|
||||
path(
|
||||
"ac/",
|
||||
include(
|
||||
(autocomplete_urls[0], autocomplete_urls[1]),
|
||||
namespace=autocomplete_urls[2],
|
||||
),
|
||||
),
|
||||
path("ac/", include(autocomplete_urls)),
|
||||
)
|
||||
|
||||
# Add API Documentation URLs if available
|
||||
|
||||
106
backend/uv.lock
generated
106
backend/uv.lock
generated
@@ -354,55 +354,55 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.5"
|
||||
version = "7.10.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662, upload-time = "2025-08-23T14:42:44.78Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/08/4166ecfb60ba011444f38a5a6107814b80c34c717bc7a23be0d22e92ca09/coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c", size = 217106, upload-time = "2025-08-23T14:41:15.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d7/b71022408adbf040a680b8c64bf6ead3be37b553e5844f7465643979f7ca/coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44", size = 217353, upload-time = "2025-08-23T14:41:16.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/68/21e0d254dbf8972bb8dd95e3fe7038f4be037ff04ba47d6d1b12b37510ba/coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc", size = 248350, upload-time = "2025-08-23T14:41:18.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/65/28752c3a896566ec93e0219fc4f47ff71bd2b745f51554c93e8dcb659796/coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869", size = 250955, upload-time = "2025-08-23T14:41:19.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/eb/ca6b7967f57f6fef31da8749ea20417790bb6723593c8cd98a987be20423/coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f", size = 252230, upload-time = "2025-08-23T14:41:20.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/29/17a411b2a2a18f8b8c952aa01c00f9284a1fbc677c68a0003b772ea89104/coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5", size = 250387, upload-time = "2025-08-23T14:41:22.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/89/97a9e271188c2fbb3db82235c33980bcbc733da7da6065afbaa1d685a169/coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c", size = 248280, upload-time = "2025-08-23T14:41:24.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c6/0ad7d0137257553eb4706b4ad6180bec0a1b6a648b092c5bbda48d0e5b2c/coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2", size = 249894, upload-time = "2025-08-23T14:41:26.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/56/fb3aba936addb4c9e5ea14f5979393f1c2466b4c89d10591fd05f2d6b2aa/coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4", size = 219536, upload-time = "2025-08-23T14:41:27.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/54/baacb8f2f74431e3b175a9a2881feaa8feb6e2f187a0e7e3046f3c7742b2/coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b", size = 220330, upload-time = "2025-08-23T14:41:29.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8a/82a3788f8e31dee51d350835b23d480548ea8621f3effd7c3ba3f7e5c006/coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84", size = 218961, upload-time = "2025-08-23T14:41:30.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/a1/590154e6eae07beee3b111cc1f907c30da6fc8ce0a83ef756c72f3c7c748/coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7", size = 217819, upload-time = "2025-08-23T14:41:31.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ff/436ffa3cfc7741f0973c5c89405307fe39b78dcf201565b934e6616fc4ad/coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b", size = 218040, upload-time = "2025-08-23T14:41:33.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/ca/5787fb3d7820e66273913affe8209c534ca11241eb34ee8c4fd2aaa9dd87/coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae", size = 259374, upload-time = "2025-08-23T14:41:34.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/89/21af956843896adc2e64fc075eae3c1cadb97ee0a6960733e65e696f32dd/coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760", size = 261551, upload-time = "2025-08-23T14:41:36.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/96/390a69244ab837e0ac137989277879a084c786cf036c3c4a3b9637d43a89/coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235", size = 263776, upload-time = "2025-08-23T14:41:38.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/32/cfd6ae1da0a521723349f3129b2455832fc27d3f8882c07e5b6fefdd0da2/coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5", size = 261326, upload-time = "2025-08-23T14:41:40.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/c4/bf8d459fb4ce2201e9243ce6c015936ad283a668774430a3755f467b39d1/coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db", size = 259090, upload-time = "2025-08-23T14:41:42.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/5d/a234f7409896468e5539d42234016045e4015e857488b0b5b5f3f3fa5f2b/coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e", size = 260217, upload-time = "2025-08-23T14:41:43.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/87560f036099f46c2ddd235be6476dd5c1d6be6bb57569a9348d43eeecea/coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee", size = 220194, upload-time = "2025-08-23T14:41:45.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a8/04a482594fdd83dc677d4a6c7e2d62135fff5a1573059806b8383fad9071/coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14", size = 221258, upload-time = "2025-08-23T14:41:46.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ad/7da28594ab66fe2bc720f1bc9b131e62e9b4c6e39f044d9a48d18429cc21/coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff", size = 219521, upload-time = "2025-08-23T14:41:47.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7f/c8b6e4e664b8a95254c35a6c8dd0bf4db201ec681c169aae2f1256e05c85/coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031", size = 217090, upload-time = "2025-08-23T14:41:49.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/74/3ee14ede30a6e10a94a104d1d0522d5fb909a7c7cac2643d2a79891ff3b9/coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3", size = 217365, upload-time = "2025-08-23T14:41:50.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/5f/06ac21bf87dfb7620d1f870dfa3c2cae1186ccbcdc50b8b36e27a0d52f50/coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031", size = 248413, upload-time = "2025-08-23T14:41:52.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/bc/cc5bed6e985d3a14228539631573f3863be6a2587381e8bc5fdf786377a1/coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2", size = 250943, upload-time = "2025-08-23T14:41:53.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/43/6a9fc323c2c75cd80b18d58db4a25dc8487f86dd9070f9592e43e3967363/coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762", size = 252301, upload-time = "2025-08-23T14:41:56.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/7c/3e791b8845f4cd515275743e3775adb86273576596dc9f02dca37357b4f2/coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae", size = 250302, upload-time = "2025-08-23T14:41:58.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/bc/5099c1e1cb0c9ac6491b281babea6ebbf999d949bf4aa8cdf4f2b53505e8/coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262", size = 248237, upload-time = "2025-08-23T14:41:59.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/51/d346eb750a0b2f1e77f391498b753ea906fde69cc11e4b38dca28c10c88c/coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99", size = 249726, upload-time = "2025-08-23T14:42:01.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/85/eebcaa0edafe427e93286b94f56ea7e1280f2c49da0a776a6f37e04481f9/coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde", size = 219825, upload-time = "2025-08-23T14:42:03.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f7/6d43e037820742603f1e855feb23463979bf40bd27d0cde1f761dcc66a3e/coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13", size = 220618, upload-time = "2025-08-23T14:42:05.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/b0/ed9432e41424c51509d1da603b0393404b828906236fb87e2c8482a93468/coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9", size = 219199, upload-time = "2025-08-23T14:42:06.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/54/5a7ecfa77910f22b659c820f67c16fc1e149ed132ad7117f0364679a8fa9/coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508", size = 217833, upload-time = "2025-08-23T14:42:08.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/0e/25672d917cc57857d40edf38f0b867fb9627115294e4f92c8fcbbc18598d/coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357", size = 218048, upload-time = "2025-08-23T14:42:10.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7c/0b2b4f1c6f71885d4d4b2b8608dcfc79057adb7da4143eb17d6260389e42/coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b", size = 259549, upload-time = "2025-08-23T14:42:11.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/73/abb8dab1609abec7308d83c6aec547944070526578ee6c833d2da9a0ad42/coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4", size = 261715, upload-time = "2025-08-23T14:42:13.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d1/abf31de21ec92731445606b8d5e6fa5144653c2788758fcf1f47adb7159a/coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba", size = 263969, upload-time = "2025-08-23T14:42:15.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/b3/ef274927f4ebede96056173b620db649cc9cb746c61ffc467946b9d0bc67/coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842", size = 261408, upload-time = "2025-08-23T14:42:16.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/fc/83ca2812be616d69b4cdd4e0c62a7bc526d56875e68fd0f79d47c7923584/coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874", size = 259168, upload-time = "2025-08-23T14:42:18.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/4f/e0779e5716f72d5c9962e709d09815d02b3b54724e38567308304c3fc9df/coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732", size = 260317, upload-time = "2025-08-23T14:42:20.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/fe/4247e732f2234bb5eb9984a0888a70980d681f03cbf433ba7b48f08ca5d5/coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df", size = 220600, upload-time = "2025-08-23T14:42:22.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a0/f294cff6d1034b87839987e5b6ac7385bec599c44d08e0857ac7f164ad0c/coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f", size = 221714, upload-time = "2025-08-23T14:42:23.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/18/fa1afdc60b5528d17416df440bcbd8fd12da12bfea9da5b6ae0f7a37d0f7/coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2", size = 219735, upload-time = "2025-08-23T14:42:25.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736, upload-time = "2025-08-23T14:42:43.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1328,11 +1328,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pbs-installer"
|
||||
version = "2025.8.27"
|
||||
version = "2025.8.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/e3/3dc8ee142299bac2329b78d93754f9711d692f233771adbe1a3e4deafafb/pbs_installer-2025.8.27.tar.gz", hash = "sha256:606430ca10940f9600a1a7f20b2a4a0ea62d8e327dcaf8a7b9acf2a2a6a39cb4", size = 59170, upload-time = "2025-08-27T00:59:35.336Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/f1/81217cee0b777bb454b7d3189b99a0369517e3e67f71dac88cadbfee5cfc/pbs_installer-2025.8.28.tar.gz", hash = "sha256:3accb1a184a048e657323c17d1c48b2969e49501b165e7200a520af7022d9bb0", size = 59192, upload-time = "2025-08-28T22:02:39.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/0b/a286029828fe6ddf0fa0a4a8a46b7d90c7d6ac26a3237f4c211df9143e92/pbs_installer-2025.8.27-py3-none-any.whl", hash = "sha256:145ed15f222af5157f5d4512a75041bc3c32784d4939d678231d41b15c0f16be", size = 60847, upload-time = "2025-08-27T00:59:33.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/55/dcc53d096e69cdb9f6a6f51bde721f66caf4e0ebea650d517049a4349b3f/pbs_installer-2025.8.28-py3-none-any.whl", hash = "sha256:a42cd2532dbac5dc836db41cf1bc5ed26ef275214e8e30e35aff961883997048", size = 60908, upload-time = "2025-08-28T22:02:38.605Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2351,11 +2351,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
version = "4.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user