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:
pacnpal
2025-08-29 16:03:51 -04:00
parent 7b9f64be72
commit bb7da85516
92 changed files with 19690 additions and 9076 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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

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

View File

@@ -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

View File

@@ -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."""

View File

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

View File

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

View File

@@ -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

View File

@@ -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)),
]

View File

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

View File

@@ -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...

View File

@@ -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

View File

@@ -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)),
]

View File

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

View File

@@ -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",

View 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

View File

@@ -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:

View File

@@ -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

View File

@@ -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]

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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)),
]

View File

@@ -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,
},

View File

@@ -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] = {

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)}")

View File

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

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(
{

View File

@@ -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 = []

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

View File

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

View File

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

View 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

View File

@@ -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]

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

View File

@@ -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

View File

@@ -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

View File

@@ -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."""

View File

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

View File

@@ -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

View File

@@ -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 %}

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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 = [

View File

@@ -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

View File

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

View File

@@ -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}/"

View File

@@ -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

View File

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