mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 02:31:08 -05:00
Refactor API structure and add comprehensive user management features
- Restructure API v1 with improved serializers organization - Add user deletion requests and moderation queue system - Implement bulk moderation operations and permissions - Add user profile enhancements with display names and avatars - Expand ride and park API endpoints with better filtering - Add manufacturer API with detailed ride relationships - Improve authentication flows and error handling - Update frontend documentation and API specifications
This commit is contained in:
54
.clinerules/cline_rules.md
Normal file
54
.clinerules/cline_rules.md
Normal file
@@ -0,0 +1,54 @@
|
||||
## Mandatory Development Rules
|
||||
|
||||
### API Organization
|
||||
- **MANDATORY NESTING**: All API directory structures MUST match URL nesting patterns. No exceptions.
|
||||
- **NO TOP-LEVEL ENDPOINTS**: URLs must be nested under top-level domains. Avoid creating separate top-level API endpoints - nest them under existing domains instead.
|
||||
|
||||
### Data Model Rules
|
||||
- **RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
|
||||
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
|
||||
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
|
||||
- Individual rides reference BOTH the model (what product) and type (how it operates)
|
||||
- Ride types must be available for ALL ride categories, not just roller coasters
|
||||
|
||||
### Development Commands
|
||||
- **Django Server**: Always use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
|
||||
- **Django Migrations**: Always use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
|
||||
- **Package Management**: Always use `uv add <package>` instead of `pip install <package>`
|
||||
- **Django Management**: Always use `uv run manage.py <command>` instead of `python manage.py <command>`
|
||||
|
||||
### ThrillWiki Project Rules
|
||||
- **Domain Structure**: Parks contain rides, rides have models, companies have multiple roles (manufacturer/operator/designer)
|
||||
- **Media Integration**: Use CloudflareImagesField for all photo uploads with variants and transformations
|
||||
- **Tracking**: All models use pghistory for change tracking and TrackedModel base class
|
||||
- **Slugs**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
|
||||
- **Status Management**: Rides have operational status (OPERATING, CLOSED_TEMP, SBNO, etc.) with date tracking
|
||||
- **Company Roles**: Companies can be MANUFACTURER, OPERATOR, DESIGNER, PROPERTY_OWNER with array field
|
||||
- **Location Data**: Use PostGIS for geographic data, separate location models for parks and rides
|
||||
- **API Patterns**: Use DRF with drf-spectacular, comprehensive serializers, nested endpoints, caching
|
||||
- **Photo Management**: Banner/card image references, photo types, attribution fields, primary photo logic
|
||||
- **Search Integration**: Text search, filtering, autocomplete endpoints, pagination
|
||||
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
|
||||
|
||||
### CRITICAL DOCUMENTATION RULE
|
||||
- CRITICAL: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. Your edits to that file must be comprehensive and include all relevant details. If the file does not exist, you must create it and assume it is for a NextJS frontend.
|
||||
- CRITICAL: It is MANDATORY to include any types that need to be added to the frontend in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/api.ts. Full type safety.
|
||||
- CRITICAL: It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/types.ts. Full type safety.
|
||||
|
||||
### CRITICAL DATA RULE
|
||||
- **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data must come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
|
||||
|
||||
### CRITICAL DOMAIN SEPARATION RULE
|
||||
- **OPERATORS AND PROPERTY_OWNERS ARE FOR PARKS ONLY**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They should NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain.
|
||||
- **Correct URL patterns**:
|
||||
- Parks: `/parks/{park_slug}/` and `/parks/` showing a global list of parks with all possible fields.
|
||||
- Rides: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/` showing a global list of rides with all possible fields.
|
||||
- Parks Companies: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/` and `/parks/operators/` and `/parks/owners/` showing a global list of park operators or owners respectively with filter options based on all fields.
|
||||
- Rides Companies: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/` and `/rides/manufacturers/` and `/rides/designers/` showing a global list of ride manufacturers or designers respectively with filter options based on all fields.
|
||||
- **NEVER mix these domains** - this is a fundamental and DANGEROUS business rule violation.
|
||||
|
||||
### CRITICAL PHOTO MANAGEMENT RULE
|
||||
- **Use CloudflareImagesField**: All photo uploads must use CloudflareImagesField with variants and transformations.
|
||||
- **Photo Types**: Clearly define and use photo types (e.g., banner, card) for all images.
|
||||
- **Attribution Fields**: Include attribution fields for all photos, specifying the source and copyright information.
|
||||
- **Primary Photo Logic**: Implement logic to determine the primary photo for each model, ensuring consistency across the application.
|
||||
38
.clinerules/django-moderation-integration.md
Normal file
38
.clinerules/django-moderation-integration.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## Brief overview
|
||||
Guidelines for integrating new Django moderation systems while preserving existing functionality, based on ThrillWiki project patterns. These rules ensure backward compatibility and proper integration when extending moderation capabilities.
|
||||
|
||||
## Integration approach
|
||||
- Always preserve existing models and functionality when adding new moderation features
|
||||
- Use comprehensive model integration rather than replacement - combine original and new models in single files
|
||||
- Maintain existing method signatures and property aliases for backward compatibility
|
||||
- Fix field name mismatches systematically across services, selectors, and related components
|
||||
|
||||
## Django model patterns
|
||||
- All models must inherit from TrackedModel base class and use pghistory for change tracking
|
||||
- Use proper Meta class inheritance: `class Meta(TrackedModel.Meta):`
|
||||
- Maintain comprehensive business logic methods on models (approve, reject, escalate)
|
||||
- Include property aliases for backward compatibility when field names change
|
||||
|
||||
## Service and selector updates
|
||||
- Update services and selectors to match restored model field names systematically
|
||||
- Use proper field references: `user` instead of `submitted_by`, `created_at` instead of `submitted_at`
|
||||
- Maintain transaction safety with `select_for_update()` in services
|
||||
- Keep comprehensive error handling and validation in service methods
|
||||
|
||||
## Migration strategy
|
||||
- Create migrations incrementally and handle field changes with proper defaults
|
||||
- Test Django checks after each major integration step
|
||||
- Apply migrations immediately after creation to verify database compatibility
|
||||
- Handle nullable field changes with appropriate migration prompts
|
||||
|
||||
## Documentation requirements
|
||||
- Update docs/types-api.ts with complete TypeScript interface definitions
|
||||
- Update docs/lib-api.ts with comprehensive API client implementation
|
||||
- Ensure full type safety and proper error handling in TypeScript clients
|
||||
- Document all new API endpoints and integration patterns
|
||||
|
||||
## Testing and validation
|
||||
- Run Django system checks after each integration step
|
||||
- Verify existing functionality still works after adding new features
|
||||
- Test both original workflow and new moderation system functionality
|
||||
- Confirm database migrations apply successfully without errors
|
||||
164
backend/apps/accounts/management/commands/delete_user.py
Normal file
164
backend/apps/accounts/management/commands/delete_user.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Django management command to delete a user while preserving their submissions.
|
||||
|
||||
Usage:
|
||||
uv run manage.py delete_user <username>
|
||||
uv run manage.py delete_user --user-id <user_id>
|
||||
uv run manage.py delete_user <username> --dry-run
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.services import UserDeletionService
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Delete a user while preserving all their submissions"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"username", nargs="?", type=str, help="Username of the user to delete"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
type=str,
|
||||
help="User ID of the user to delete (alternative to username)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be deleted without actually deleting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Skip confirmation prompt"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = options.get("username")
|
||||
user_id = options.get("user_id")
|
||||
dry_run = options.get("dry_run", False)
|
||||
force = options.get("force", False)
|
||||
|
||||
# Validate arguments
|
||||
if not username and not user_id:
|
||||
raise CommandError("You must provide either a username or --user-id")
|
||||
|
||||
if username and user_id:
|
||||
raise CommandError("You cannot provide both username and --user-id")
|
||||
|
||||
# Find the user
|
||||
try:
|
||||
if username:
|
||||
user = User.objects.get(username=username)
|
||||
else:
|
||||
user = User.objects.get(user_id=user_id)
|
||||
except User.DoesNotExist:
|
||||
identifier = username or user_id
|
||||
raise CommandError(f'User "{identifier}" does not exist')
|
||||
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise CommandError(f"Cannot delete user: {reason}")
|
||||
|
||||
# Count submissions
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "top_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
}
|
||||
|
||||
total_submissions = sum(submission_counts.values())
|
||||
|
||||
# Display user information
|
||||
self.stdout.write(self.style.WARNING("\nUser Information:"))
|
||||
self.stdout.write(f" Username: {user.username}")
|
||||
self.stdout.write(f" User ID: {user.user_id}")
|
||||
self.stdout.write(f" Email: {user.email}")
|
||||
self.stdout.write(f" Date Joined: {user.date_joined}")
|
||||
self.stdout.write(f" Role: {user.role}")
|
||||
|
||||
# Display submission counts
|
||||
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
|
||||
for submission_type, count in submission_counts.items():
|
||||
if count > 0:
|
||||
self.stdout.write(
|
||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
||||
)
|
||||
|
||||
self.stdout.write(f"\nTotal submissions: {total_submissions}")
|
||||
|
||||
if total_submissions > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nAll {total_submissions} submissions will be transferred to the "deleted_user" placeholder.'
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("\nNo submissions found for this user.")
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
|
||||
return
|
||||
|
||||
# Confirmation prompt
|
||||
if not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f'\nThis will permanently delete the user "{user.username}" '
|
||||
f"but preserve all {total_submissions} submissions."
|
||||
)
|
||||
)
|
||||
confirm = input("Are you sure you want to continue? (yes/no): ")
|
||||
if confirm.lower() not in ["yes", "y"]:
|
||||
self.stdout.write(self.style.ERROR("Operation cancelled."))
|
||||
return
|
||||
|
||||
# Perform the deletion
|
||||
try:
|
||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'
|
||||
)
|
||||
)
|
||||
|
||||
preserved_count = sum(result["preserved_submissions"].values())
|
||||
if preserved_count > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Preserved {preserved_count} submissions under user "{result["transferred_to"]["username"]}"'
|
||||
)
|
||||
)
|
||||
|
||||
# Show detailed preservation summary
|
||||
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
|
||||
for submission_type, count in result["preserved_submissions"].items():
|
||||
if count > 0:
|
||||
self.stdout.write(
|
||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise CommandError(f"Error deleting user: {str(e)}")
|
||||
@@ -0,0 +1,219 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 14:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"accounts",
|
||||
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
|
||||
),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserDeletionRequest",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"verification_code",
|
||||
models.CharField(
|
||||
help_text="Unique verification code sent to user's email",
|
||||
max_length=32,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(
|
||||
help_text="When this deletion request expires"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_sent_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When the verification email was sent",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=5,
|
||||
help_text="Maximum number of verification attempts allowed",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_used",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this deletion request has been used",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="deletion_request",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserDeletionRequestEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"verification_code",
|
||||
models.CharField(
|
||||
help_text="Unique verification code sent to user's email",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(
|
||||
help_text="When this deletion request expires"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_sent_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When the verification email was sent",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=5,
|
||||
help_text="Maximum number of verification attempts allowed",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_used",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this deletion request has been used",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.userdeletionrequest",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["verification_code"], name="accounts_us_verific_94460d_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userdeletionrequest",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
|
||||
hash="c1735fe8eb50247b0afe2bea9d32f83c31da6419",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_b982c",
|
||||
table="accounts_userdeletionrequest",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userdeletionrequest",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
|
||||
hash="6bf807ce3bed069ab30462d3fd7688a7593a7fd0",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_27723",
|
||||
table="accounts_userdeletionrequest",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,309 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 15:10
|
||||
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0004_userdeletionrequest_userdeletionrequestevent_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="activity_visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_friend_requests",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_messages",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_profile_comments",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="email_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="last_password_change",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="login_history_retention",
|
||||
field=models.IntegerField(default=90),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="login_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="notification_preferences",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="privacy_level",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="push_notifications",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="search_visibility",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="session_timeout",
|
||||
field=models.IntegerField(default=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_email",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_join_date",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_photos",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_real_name",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_reviews",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_statistics",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_top_lists",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="two_factor_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="activity_visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_friend_requests",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_messages",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_profile_comments",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="email_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="last_password_change",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="login_history_retention",
|
||||
field=models.IntegerField(default=90),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="login_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="notification_preferences",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="privacy_level",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="push_notifications",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="search_visibility",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="session_timeout",
|
||||
field=models.IntegerField(default=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_email",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_join_date",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_photos",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_real_name",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_reviews",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_statistics",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_top_lists",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="two_factor_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="63ede44a0db376d673078f3464edc89aa8ca80c7",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="9157131b568edafe1e5fcdf313bfeaaa8adcfee4",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,456 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 15:29
|
||||
|
||||
import cloudflare_images.field
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"accounts",
|
||||
"0005_remove_user_insert_insert_remove_user_update_update_and_more",
|
||||
),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="userprofile",
|
||||
name="avatar",
|
||||
field=cloudflare_images.field.CloudflareImagesField(
|
||||
blank=True, null=True, upload_to="", variant="public"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofileevent",
|
||||
name="avatar",
|
||||
field=cloudflare_images.field.CloudflareImagesField(
|
||||
blank=True, null=True, upload_to="", variant="public"
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NotificationPreference",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("submission_approved_email", models.BooleanField(default=True)),
|
||||
("submission_approved_push", models.BooleanField(default=True)),
|
||||
("submission_approved_inapp", models.BooleanField(default=True)),
|
||||
("submission_rejected_email", models.BooleanField(default=True)),
|
||||
("submission_rejected_push", models.BooleanField(default=True)),
|
||||
("submission_rejected_inapp", models.BooleanField(default=True)),
|
||||
("submission_pending_email", models.BooleanField(default=False)),
|
||||
("submission_pending_push", models.BooleanField(default=False)),
|
||||
("submission_pending_inapp", models.BooleanField(default=True)),
|
||||
("review_reply_email", models.BooleanField(default=True)),
|
||||
("review_reply_push", models.BooleanField(default=True)),
|
||||
("review_reply_inapp", models.BooleanField(default=True)),
|
||||
("review_helpful_email", models.BooleanField(default=False)),
|
||||
("review_helpful_push", models.BooleanField(default=True)),
|
||||
("review_helpful_inapp", models.BooleanField(default=True)),
|
||||
("friend_request_email", models.BooleanField(default=True)),
|
||||
("friend_request_push", models.BooleanField(default=True)),
|
||||
("friend_request_inapp", models.BooleanField(default=True)),
|
||||
("friend_accepted_email", models.BooleanField(default=False)),
|
||||
("friend_accepted_push", models.BooleanField(default=True)),
|
||||
("friend_accepted_inapp", models.BooleanField(default=True)),
|
||||
("message_received_email", models.BooleanField(default=True)),
|
||||
("message_received_push", models.BooleanField(default=True)),
|
||||
("message_received_inapp", models.BooleanField(default=True)),
|
||||
("system_announcement_email", models.BooleanField(default=True)),
|
||||
("system_announcement_push", models.BooleanField(default=False)),
|
||||
("system_announcement_inapp", models.BooleanField(default=True)),
|
||||
("account_security_email", models.BooleanField(default=True)),
|
||||
("account_security_push", models.BooleanField(default=True)),
|
||||
("account_security_inapp", models.BooleanField(default=True)),
|
||||
("feature_update_email", models.BooleanField(default=True)),
|
||||
("feature_update_push", models.BooleanField(default=False)),
|
||||
("feature_update_inapp", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_email", models.BooleanField(default=False)),
|
||||
("achievement_unlocked_push", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_inapp", models.BooleanField(default=True)),
|
||||
("milestone_reached_email", models.BooleanField(default=False)),
|
||||
("milestone_reached_push", models.BooleanField(default=True)),
|
||||
("milestone_reached_inapp", models.BooleanField(default=True)),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notification_preference",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification Preference",
|
||||
"verbose_name_plural": "Notification Preferences",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NotificationPreferenceEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("submission_approved_email", models.BooleanField(default=True)),
|
||||
("submission_approved_push", models.BooleanField(default=True)),
|
||||
("submission_approved_inapp", models.BooleanField(default=True)),
|
||||
("submission_rejected_email", models.BooleanField(default=True)),
|
||||
("submission_rejected_push", models.BooleanField(default=True)),
|
||||
("submission_rejected_inapp", models.BooleanField(default=True)),
|
||||
("submission_pending_email", models.BooleanField(default=False)),
|
||||
("submission_pending_push", models.BooleanField(default=False)),
|
||||
("submission_pending_inapp", models.BooleanField(default=True)),
|
||||
("review_reply_email", models.BooleanField(default=True)),
|
||||
("review_reply_push", models.BooleanField(default=True)),
|
||||
("review_reply_inapp", models.BooleanField(default=True)),
|
||||
("review_helpful_email", models.BooleanField(default=False)),
|
||||
("review_helpful_push", models.BooleanField(default=True)),
|
||||
("review_helpful_inapp", models.BooleanField(default=True)),
|
||||
("friend_request_email", models.BooleanField(default=True)),
|
||||
("friend_request_push", models.BooleanField(default=True)),
|
||||
("friend_request_inapp", models.BooleanField(default=True)),
|
||||
("friend_accepted_email", models.BooleanField(default=False)),
|
||||
("friend_accepted_push", models.BooleanField(default=True)),
|
||||
("friend_accepted_inapp", models.BooleanField(default=True)),
|
||||
("message_received_email", models.BooleanField(default=True)),
|
||||
("message_received_push", models.BooleanField(default=True)),
|
||||
("message_received_inapp", models.BooleanField(default=True)),
|
||||
("system_announcement_email", models.BooleanField(default=True)),
|
||||
("system_announcement_push", models.BooleanField(default=False)),
|
||||
("system_announcement_inapp", models.BooleanField(default=True)),
|
||||
("account_security_email", models.BooleanField(default=True)),
|
||||
("account_security_push", models.BooleanField(default=True)),
|
||||
("account_security_inapp", models.BooleanField(default=True)),
|
||||
("feature_update_email", models.BooleanField(default=True)),
|
||||
("feature_update_push", models.BooleanField(default=False)),
|
||||
("feature_update_inapp", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_email", models.BooleanField(default=False)),
|
||||
("achievement_unlocked_push", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_inapp", models.BooleanField(default=True)),
|
||||
("milestone_reached_email", models.BooleanField(default=False)),
|
||||
("milestone_reached_push", models.BooleanField(default=True)),
|
||||
("milestone_reached_inapp", models.BooleanField(default=True)),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.notificationpreference",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserNotification",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notification_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("submission_approved", "Submission Approved"),
|
||||
("submission_rejected", "Submission Rejected"),
|
||||
("submission_pending", "Submission Pending Review"),
|
||||
("review_reply", "Review Reply"),
|
||||
("review_helpful", "Review Marked Helpful"),
|
||||
("friend_request", "Friend Request"),
|
||||
("friend_accepted", "Friend Request Accepted"),
|
||||
("message_received", "Message Received"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
("system_announcement", "System Announcement"),
|
||||
("account_security", "Account Security"),
|
||||
("feature_update", "Feature Update"),
|
||||
("maintenance", "Maintenance Notice"),
|
||||
("achievement_unlocked", "Achievement Unlocked"),
|
||||
("milestone_reached", "Milestone Reached"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("message", models.TextField()),
|
||||
("object_id", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
default="normal",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("is_read", models.BooleanField(default=False)),
|
||||
("read_at", models.DateTimeField(blank=True, null=True)),
|
||||
("email_sent", models.BooleanField(default=False)),
|
||||
("email_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("push_sent", models.BooleanField(default=False)),
|
||||
("push_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("extra_data", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("expires_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserNotificationEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notification_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("submission_approved", "Submission Approved"),
|
||||
("submission_rejected", "Submission Rejected"),
|
||||
("submission_pending", "Submission Pending Review"),
|
||||
("review_reply", "Review Reply"),
|
||||
("review_helpful", "Review Marked Helpful"),
|
||||
("friend_request", "Friend Request"),
|
||||
("friend_accepted", "Friend Request Accepted"),
|
||||
("message_received", "Message Received"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
("system_announcement", "System Announcement"),
|
||||
("account_security", "Account Security"),
|
||||
("feature_update", "Feature Update"),
|
||||
("maintenance", "Maintenance Notice"),
|
||||
("achievement_unlocked", "Achievement Unlocked"),
|
||||
("milestone_reached", "Milestone Reached"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("message", models.TextField()),
|
||||
("object_id", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
default="normal",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("is_read", models.BooleanField(default=False)),
|
||||
("read_at", models.DateTimeField(blank=True, null=True)),
|
||||
("email_sent", models.BooleanField(default=False)),
|
||||
("email_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("push_sent", models.BooleanField(default=False)),
|
||||
("push_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("extra_data", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("expires_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.usernotification",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="notificationpreference",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="bbaa03794722dab95c97ed93731d8b55f314dbdc",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_4a06b",
|
||||
table="accounts_notificationpreference",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="notificationpreference",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="0de72b66f87f795aaeb49be8e4e57d632781bd3a",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_d3fc0",
|
||||
table="accounts_notificationpreference",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["user", "notification_type"],
|
||||
name="accounts_us_user_id_8cea97_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="accounts_us_created_a62f54_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["expires_at"], name="accounts_us_expires_f267b1_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="usernotification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="822a189e675a5903841d19738c29aa94267417f1",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_2794b",
|
||||
table="accounts_usernotification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="usernotification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="1fd24a77684747bd9a521447a2978529085b6c07",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_15c54",
|
||||
table="accounts_usernotification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 19:09
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0006_alter_userprofile_avatar_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofile",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofileevent",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="97e02685f062c04c022f6975784dce80396d4371",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="e074b317983a921b440b0c8754ba04a31ea513dd",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,11 +1,16 @@
|
||||
from django.dispatch import receiver
|
||||
from django.db.models.signals import post_save
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
from cloudflare_images.field import CloudflareImagesField
|
||||
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
@@ -34,6 +39,11 @@ class User(AbstractUser):
|
||||
LIGHT = "light", _("Light")
|
||||
DARK = "dark", _("Dark")
|
||||
|
||||
class PrivacyLevel(models.TextChoices):
|
||||
PUBLIC = "public", _("Public")
|
||||
FRIENDS = "friends", _("Friends Only")
|
||||
PRIVATE = "private", _("Private")
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
@@ -60,6 +70,54 @@ class User(AbstractUser):
|
||||
default=ThemePreference.LIGHT,
|
||||
)
|
||||
|
||||
# Notification preferences
|
||||
email_notifications = models.BooleanField(default=True)
|
||||
push_notifications = models.BooleanField(default=False)
|
||||
|
||||
# Privacy settings
|
||||
privacy_level = models.CharField(
|
||||
max_length=10,
|
||||
choices=PrivacyLevel.choices,
|
||||
default=PrivacyLevel.PUBLIC,
|
||||
)
|
||||
show_email = models.BooleanField(default=False)
|
||||
show_real_name = models.BooleanField(default=True)
|
||||
show_join_date = models.BooleanField(default=True)
|
||||
show_statistics = models.BooleanField(default=True)
|
||||
show_reviews = models.BooleanField(default=True)
|
||||
show_photos = models.BooleanField(default=True)
|
||||
show_top_lists = models.BooleanField(default=True)
|
||||
allow_friend_requests = models.BooleanField(default=True)
|
||||
allow_messages = models.BooleanField(default=True)
|
||||
allow_profile_comments = models.BooleanField(default=False)
|
||||
search_visibility = models.BooleanField(default=True)
|
||||
activity_visibility = models.CharField(
|
||||
max_length=10,
|
||||
choices=PrivacyLevel.choices,
|
||||
default=PrivacyLevel.FRIENDS,
|
||||
)
|
||||
|
||||
# Security settings
|
||||
two_factor_enabled = models.BooleanField(default=False)
|
||||
login_notifications = models.BooleanField(default=True)
|
||||
session_timeout = models.IntegerField(default=30) # days
|
||||
login_history_retention = models.IntegerField(default=90) # days
|
||||
last_password_change = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Display name - core user data for better performance
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
)
|
||||
|
||||
# Detailed notification preferences (JSON field for flexibility)
|
||||
notification_preferences = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.get_display_name()
|
||||
|
||||
@@ -68,6 +126,9 @@ class User(AbstractUser):
|
||||
|
||||
def get_display_name(self):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
if self.display_name:
|
||||
return self.display_name
|
||||
# Fallback to profile display_name for backward compatibility
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
@@ -92,10 +153,10 @@ class UserProfile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
)
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True)
|
||||
avatar = CloudflareImagesField(blank=True, null=True)
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
@@ -112,18 +173,37 @@ class UserProfile(models.Model):
|
||||
flat_ride_credits = models.IntegerField(default=0)
|
||||
water_ride_credits = models.IntegerField(default=0)
|
||||
|
||||
def get_avatar(self):
|
||||
def get_avatar_url(self):
|
||||
"""
|
||||
Return the avatar URL or serve a pre-generated avatar based on the
|
||||
first letter of the username
|
||||
Return the avatar URL or generate a default letter-based avatar URL
|
||||
"""
|
||||
if self.avatar:
|
||||
return self.avatar.url
|
||||
first_letter = self.user.username.upper()
|
||||
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
|
||||
if os.path.exists(avatar_path):
|
||||
return f"/{avatar_path}"
|
||||
return "/static/images/default-avatar.png"
|
||||
# Return Cloudflare Images URL with avatar variant
|
||||
return self.avatar.url_variant("avatar")
|
||||
|
||||
# Generate default letter-based avatar using first letter of username
|
||||
first_letter = self.user.username[0].upper() if self.user.username else "U"
|
||||
# Use a service like UI Avatars or generate a simple colored avatar
|
||||
return f"https://ui-avatars.com/api/?name={first_letter}&size=200&background=random&color=fff&bold=true"
|
||||
|
||||
def get_avatar_variants(self):
|
||||
"""
|
||||
Return avatar variants for different use cases
|
||||
"""
|
||||
if self.avatar:
|
||||
return {
|
||||
"thumbnail": self.avatar.url_variant("thumbnail"),
|
||||
"avatar": self.avatar.url_variant("avatar"),
|
||||
"large": self.avatar.url_variant("large"),
|
||||
}
|
||||
|
||||
# For default avatars, return the same URL for all variants
|
||||
default_url = self.get_avatar_url()
|
||||
return {
|
||||
"thumbnail": default_url,
|
||||
"avatar": default_url,
|
||||
"large": default_url,
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If no display name is set, use the username
|
||||
@@ -220,3 +300,334 @@ class TopListItem(TrackedModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserDeletionRequest(models.Model):
|
||||
"""
|
||||
Model to track user deletion requests with email verification.
|
||||
|
||||
When a user requests to delete their account, a verification code
|
||||
is sent to their email. The deletion is only processed when they
|
||||
provide the correct code.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User, on_delete=models.CASCADE, related_name="deletion_request"
|
||||
)
|
||||
|
||||
verification_code = models.CharField(
|
||||
max_length=32,
|
||||
unique=True,
|
||||
help_text="Unique verification code sent to user's email",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(help_text="When this deletion request expires")
|
||||
|
||||
email_sent_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When the verification email was sent"
|
||||
)
|
||||
|
||||
attempts = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
)
|
||||
|
||||
max_attempts = models.PositiveIntegerField(
|
||||
default=5, help_text="Maximum number of verification attempts allowed"
|
||||
)
|
||||
|
||||
is_used = models.BooleanField(
|
||||
default=False, help_text="Whether this deletion request has been used"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["verification_code"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
models.Index(fields=["user", "is_used"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Deletion request for {self.user.username} - {self.verification_code}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.verification_code:
|
||||
self.verification_code = self.generate_verification_code()
|
||||
|
||||
if not self.expires_at:
|
||||
# Deletion requests expire after 24 hours
|
||||
self.expires_at = timezone.now() + timedelta(hours=24)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def generate_verification_code():
|
||||
"""Generate a unique 8-character verification code."""
|
||||
while True:
|
||||
# Generate a random 8-character alphanumeric code
|
||||
code = "".join(
|
||||
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
|
||||
)
|
||||
|
||||
# Ensure it's unique
|
||||
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
|
||||
return code
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if this deletion request has expired."""
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if this deletion request is still valid."""
|
||||
return (
|
||||
not self.is_used
|
||||
and not self.is_expired()
|
||||
and self.attempts < self.max_attempts
|
||||
)
|
||||
|
||||
def increment_attempts(self):
|
||||
"""Increment the number of verification attempts."""
|
||||
self.attempts += 1
|
||||
self.save(update_fields=["attempts"])
|
||||
|
||||
def mark_as_used(self):
|
||||
"""Mark this deletion request as used."""
|
||||
self.is_used = True
|
||||
self.save(update_fields=["is_used"])
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Remove expired deletion requests."""
|
||||
expired_requests = cls.objects.filter(
|
||||
expires_at__lt=timezone.now(), is_used=False
|
||||
)
|
||||
count = expired_requests.count()
|
||||
expired_requests.delete()
|
||||
return count
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserNotification(TrackedModel):
|
||||
"""
|
||||
Model to store user notifications for various events.
|
||||
|
||||
This includes submission approvals, rejections, system announcements,
|
||||
and other user-relevant notifications.
|
||||
"""
|
||||
|
||||
class NotificationType(models.TextChoices):
|
||||
# Submission related
|
||||
SUBMISSION_APPROVED = "submission_approved", _("Submission Approved")
|
||||
SUBMISSION_REJECTED = "submission_rejected", _("Submission Rejected")
|
||||
SUBMISSION_PENDING = "submission_pending", _("Submission Pending Review")
|
||||
|
||||
# Review related
|
||||
REVIEW_REPLY = "review_reply", _("Review Reply")
|
||||
REVIEW_HELPFUL = "review_helpful", _("Review Marked Helpful")
|
||||
|
||||
# Social related
|
||||
FRIEND_REQUEST = "friend_request", _("Friend Request")
|
||||
FRIEND_ACCEPTED = "friend_accepted", _("Friend Request Accepted")
|
||||
MESSAGE_RECEIVED = "message_received", _("Message Received")
|
||||
PROFILE_COMMENT = "profile_comment", _("Profile Comment")
|
||||
|
||||
# System related
|
||||
SYSTEM_ANNOUNCEMENT = "system_announcement", _("System Announcement")
|
||||
ACCOUNT_SECURITY = "account_security", _("Account Security")
|
||||
FEATURE_UPDATE = "feature_update", _("Feature Update")
|
||||
MAINTENANCE = "maintenance", _("Maintenance Notice")
|
||||
|
||||
# Achievement related
|
||||
ACHIEVEMENT_UNLOCKED = "achievement_unlocked", _("Achievement Unlocked")
|
||||
MILESTONE_REACHED = "milestone_reached", _("Milestone Reached")
|
||||
|
||||
class Priority(models.TextChoices):
|
||||
LOW = "low", _("Low")
|
||||
NORMAL = "normal", _("Normal")
|
||||
HIGH = "high", _("High")
|
||||
URGENT = "urgent", _("Urgent")
|
||||
|
||||
# Core fields
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="notifications"
|
||||
)
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length=30, choices=NotificationType.choices
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
message = models.TextField()
|
||||
|
||||
# Optional related object (submission, review, etc.)
|
||||
content_type = models.ForeignKey(
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
related_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Metadata
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=Priority.choices, default=Priority.NORMAL
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
is_read = models.BooleanField(default=False)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Delivery tracking
|
||||
email_sent = models.BooleanField(default=False)
|
||||
email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
push_sent = models.BooleanField(default=False)
|
||||
push_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Additional data (JSON field for flexibility)
|
||||
extra_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "is_read"]),
|
||||
models.Index(fields=["user", "notification_type"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username}: {self.title}"
|
||||
|
||||
def mark_as_read(self):
|
||||
"""Mark notification as read."""
|
||||
if not self.is_read:
|
||||
self.is_read = True
|
||||
self.read_at = timezone.now()
|
||||
self.save(update_fields=["is_read", "read_at"])
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if notification has expired."""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Remove expired notifications."""
|
||||
expired_notifications = cls.objects.filter(expires_at__lt=timezone.now())
|
||||
count = expired_notifications.count()
|
||||
expired_notifications.delete()
|
||||
return count
|
||||
|
||||
@classmethod
|
||||
def mark_all_read_for_user(cls, user):
|
||||
"""Mark all notifications as read for a specific user."""
|
||||
return cls.objects.filter(user=user, is_read=False).update(
|
||||
is_read=True, read_at=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class NotificationPreference(TrackedModel):
|
||||
"""
|
||||
User preferences for different types of notifications.
|
||||
|
||||
This allows users to control which notifications they receive
|
||||
and through which channels (email, push, in-app).
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User, on_delete=models.CASCADE, related_name="notification_preference"
|
||||
)
|
||||
|
||||
# Submission notifications
|
||||
submission_approved_email = models.BooleanField(default=True)
|
||||
submission_approved_push = models.BooleanField(default=True)
|
||||
submission_approved_inapp = models.BooleanField(default=True)
|
||||
|
||||
submission_rejected_email = models.BooleanField(default=True)
|
||||
submission_rejected_push = models.BooleanField(default=True)
|
||||
submission_rejected_inapp = models.BooleanField(default=True)
|
||||
|
||||
submission_pending_email = models.BooleanField(default=False)
|
||||
submission_pending_push = models.BooleanField(default=False)
|
||||
submission_pending_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Review notifications
|
||||
review_reply_email = models.BooleanField(default=True)
|
||||
review_reply_push = models.BooleanField(default=True)
|
||||
review_reply_inapp = models.BooleanField(default=True)
|
||||
|
||||
review_helpful_email = models.BooleanField(default=False)
|
||||
review_helpful_push = models.BooleanField(default=True)
|
||||
review_helpful_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Social notifications
|
||||
friend_request_email = models.BooleanField(default=True)
|
||||
friend_request_push = models.BooleanField(default=True)
|
||||
friend_request_inapp = models.BooleanField(default=True)
|
||||
|
||||
friend_accepted_email = models.BooleanField(default=False)
|
||||
friend_accepted_push = models.BooleanField(default=True)
|
||||
friend_accepted_inapp = models.BooleanField(default=True)
|
||||
|
||||
message_received_email = models.BooleanField(default=True)
|
||||
message_received_push = models.BooleanField(default=True)
|
||||
message_received_inapp = models.BooleanField(default=True)
|
||||
|
||||
# System notifications
|
||||
system_announcement_email = models.BooleanField(default=True)
|
||||
system_announcement_push = models.BooleanField(default=False)
|
||||
system_announcement_inapp = models.BooleanField(default=True)
|
||||
|
||||
account_security_email = models.BooleanField(default=True)
|
||||
account_security_push = models.BooleanField(default=True)
|
||||
account_security_inapp = models.BooleanField(default=True)
|
||||
|
||||
feature_update_email = models.BooleanField(default=True)
|
||||
feature_update_push = models.BooleanField(default=False)
|
||||
feature_update_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Achievement notifications
|
||||
achievement_unlocked_email = models.BooleanField(default=False)
|
||||
achievement_unlocked_push = models.BooleanField(default=True)
|
||||
achievement_unlocked_inapp = models.BooleanField(default=True)
|
||||
|
||||
milestone_reached_email = models.BooleanField(default=False)
|
||||
milestone_reached_push = models.BooleanField(default=True)
|
||||
milestone_reached_inapp = models.BooleanField(default=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Notification Preference"
|
||||
verbose_name_plural = "Notification Preferences"
|
||||
|
||||
def __str__(self):
|
||||
return f"Notification preferences for {self.user.username}"
|
||||
|
||||
def should_send_notification(self, notification_type, channel):
|
||||
"""
|
||||
Check if a notification should be sent for a specific type and channel.
|
||||
|
||||
Args:
|
||||
notification_type: The type of notification (from UserNotification.NotificationType)
|
||||
channel: The delivery channel ('email', 'push', 'inapp')
|
||||
|
||||
Returns:
|
||||
bool: True if notification should be sent, False otherwise
|
||||
"""
|
||||
field_name = f"{notification_type}_{channel}"
|
||||
return getattr(self, field_name, False)
|
||||
|
||||
|
||||
# Signal handlers for automatic notification preference creation
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_notification_preference(sender, instance, created, **kwargs):
|
||||
"""Create notification preferences when a new user is created."""
|
||||
if created:
|
||||
NotificationPreference.objects.create(user=instance)
|
||||
|
||||
364
backend/apps/accounts/services.py
Normal file
364
backend/apps/accounts/services.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
User management services for ThrillWiki.
|
||||
|
||||
This module contains services for user account management including
|
||||
user deletion while preserving submissions.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from apps.email_service.services import EmailService
|
||||
from .models import User, UserProfile, UserDeletionRequest
|
||||
|
||||
|
||||
class UserDeletionService:
|
||||
"""Service for handling user deletion while preserving submissions."""
|
||||
|
||||
DELETED_USER_USERNAME = "deleted_user"
|
||||
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
|
||||
DELETED_DISPLAY_NAME = "Deleted User"
|
||||
|
||||
@classmethod
|
||||
def get_or_create_deleted_user(cls) -> User:
|
||||
"""Get or create the system deleted user placeholder."""
|
||||
deleted_user, created = User.objects.get_or_create(
|
||||
username=cls.DELETED_USER_USERNAME,
|
||||
defaults={
|
||||
"email": cls.DELETED_USER_EMAIL,
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"is_active": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
"role": User.Roles.USER,
|
||||
"is_banned": True,
|
||||
"ban_reason": "System placeholder for deleted users",
|
||||
"ban_date": timezone.now(),
|
||||
},
|
||||
)
|
||||
|
||||
if created:
|
||||
# Create profile for deleted user
|
||||
UserProfile.objects.create(
|
||||
user=deleted_user,
|
||||
display_name=cls.DELETED_DISPLAY_NAME,
|
||||
bio="This user account has been deleted.",
|
||||
)
|
||||
|
||||
return deleted_user
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def delete_user_preserve_submissions(cls, user: User) -> dict:
|
||||
"""
|
||||
Delete a user while preserving all their submissions.
|
||||
|
||||
This method:
|
||||
1. Transfers all user submissions to a system "deleted_user" placeholder
|
||||
2. Deletes the user's profile and account data
|
||||
3. Returns a summary of what was preserved
|
||||
|
||||
Args:
|
||||
user: The user to delete
|
||||
|
||||
Returns:
|
||||
dict: Summary of preserved submissions
|
||||
"""
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
raise ValueError("Cannot delete the system deleted user placeholder")
|
||||
|
||||
deleted_user = cls.get_or_create_deleted_user()
|
||||
|
||||
# Count submissions before transfer
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "top_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"moderated_park_reviews": getattr(
|
||||
user, "moderated_park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"moderated_ride_reviews": getattr(
|
||||
user, "moderated_ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"handled_submissions": getattr(
|
||||
user, "handled_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"handled_photos": getattr(
|
||||
user, "handled_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
}
|
||||
|
||||
# Transfer all submissions to deleted user
|
||||
# Reviews
|
||||
if hasattr(user, "park_reviews"):
|
||||
getattr(user, "park_reviews").update(user=deleted_user)
|
||||
if hasattr(user, "ride_reviews"):
|
||||
getattr(user, "ride_reviews").update(user=deleted_user)
|
||||
|
||||
# Photos
|
||||
if hasattr(user, "uploaded_park_photos"):
|
||||
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
|
||||
if hasattr(user, "uploaded_ride_photos"):
|
||||
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
|
||||
|
||||
# Top Lists
|
||||
if hasattr(user, "top_lists"):
|
||||
getattr(user, "top_lists").update(user=deleted_user)
|
||||
|
||||
# Moderation submissions
|
||||
if hasattr(user, "edit_submissions"):
|
||||
getattr(user, "edit_submissions").update(user=deleted_user)
|
||||
if hasattr(user, "photo_submissions"):
|
||||
getattr(user, "photo_submissions").update(user=deleted_user)
|
||||
|
||||
# Moderation actions - these can be set to NULL since they're not user content
|
||||
if hasattr(user, "moderated_park_reviews"):
|
||||
getattr(user, "moderated_park_reviews").update(moderated_by=None)
|
||||
if hasattr(user, "moderated_ride_reviews"):
|
||||
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
|
||||
if hasattr(user, "handled_submissions"):
|
||||
getattr(user, "handled_submissions").update(handled_by=None)
|
||||
if hasattr(user, "handled_photos"):
|
||||
getattr(user, "handled_photos").update(handled_by=None)
|
||||
|
||||
# Store user info for the summary
|
||||
user_info = {
|
||||
"username": user.username,
|
||||
"user_id": user.user_id,
|
||||
"email": user.email,
|
||||
"date_joined": user.date_joined,
|
||||
}
|
||||
|
||||
# Delete the user (this will cascade delete the profile)
|
||||
user.delete()
|
||||
|
||||
return {
|
||||
"deleted_user": user_info,
|
||||
"preserved_submissions": submission_counts,
|
||||
"transferred_to": {
|
||||
"username": deleted_user.username,
|
||||
"user_id": deleted_user.user_id,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a user can be safely deleted.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
tuple: (can_delete: bool, reason: Optional[str])
|
||||
"""
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
return False, "Cannot delete the system deleted user placeholder"
|
||||
|
||||
if user.is_superuser:
|
||||
return False, "Cannot delete superuser accounts"
|
||||
|
||||
# Add any other business rules here
|
||||
|
||||
return True, None
|
||||
|
||||
@classmethod
|
||||
def request_user_deletion(cls, user: User) -> UserDeletionRequest:
|
||||
"""
|
||||
Create a user deletion request and send verification email.
|
||||
|
||||
Args:
|
||||
user: The user requesting deletion
|
||||
|
||||
Returns:
|
||||
UserDeletionRequest: The created deletion request
|
||||
"""
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = cls.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise ValueError(f"Cannot delete user: {reason}")
|
||||
|
||||
# Remove any existing deletion request for this user
|
||||
UserDeletionRequest.objects.filter(user=user).delete()
|
||||
|
||||
# Create new deletion request
|
||||
deletion_request = UserDeletionRequest.objects.create(user=user)
|
||||
|
||||
# Send verification email
|
||||
cls.send_deletion_verification_email(deletion_request)
|
||||
|
||||
return deletion_request
|
||||
|
||||
@classmethod
|
||||
def send_deletion_verification_email(cls, deletion_request: UserDeletionRequest):
|
||||
"""
|
||||
Send verification email for account deletion.
|
||||
|
||||
Args:
|
||||
deletion_request: The deletion request to send email for
|
||||
"""
|
||||
user = deletion_request.user
|
||||
|
||||
# Get current site for email service
|
||||
try:
|
||||
site = Site.objects.get_current()
|
||||
except Site.DoesNotExist:
|
||||
# Fallback to default site
|
||||
site = Site.objects.get_or_create(
|
||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
|
||||
)[0]
|
||||
|
||||
# Prepare email context
|
||||
context = {
|
||||
"user": user,
|
||||
"verification_code": deletion_request.verification_code,
|
||||
"expires_at": deletion_request.expires_at,
|
||||
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
|
||||
"frontend_domain": getattr(
|
||||
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
|
||||
),
|
||||
}
|
||||
|
||||
# Render email content
|
||||
subject = f"Confirm Account Deletion - {context['site_name']}"
|
||||
|
||||
# Create email message with 1-hour expiration notice
|
||||
message = f"""
|
||||
Hello {user.get_display_name()},
|
||||
|
||||
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
|
||||
|
||||
Verification Code: {deletion_request.verification_code}
|
||||
|
||||
This code will expire in 1 hour on {deletion_request.expires_at.strftime('%B %d, %Y at %I:%M %p UTC')}.
|
||||
|
||||
IMPORTANT: This action cannot be undone. Your account will be permanently deleted, but all your reviews, photos, and other contributions will be preserved on the site.
|
||||
|
||||
If you did not request this deletion, please ignore this email and your account will remain active.
|
||||
|
||||
To complete the deletion, enter the verification code in the account deletion form on our website.
|
||||
|
||||
Best regards,
|
||||
The ThrillWiki Team
|
||||
""".strip()
|
||||
|
||||
# Send email using custom email service
|
||||
try:
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject=subject,
|
||||
text=message,
|
||||
site=site,
|
||||
from_email="no-reply@thrillwiki.com",
|
||||
)
|
||||
|
||||
# Update email sent timestamp
|
||||
deletion_request.email_sent_at = timezone.now()
|
||||
deletion_request.save(update_fields=["email_sent_at"])
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but don't fail the request creation
|
||||
print(f"Failed to send deletion verification email to {user.email}: {e}")
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def verify_and_delete_user(cls, verification_code: str) -> dict:
|
||||
"""
|
||||
Verify deletion code and delete the user account.
|
||||
|
||||
Args:
|
||||
verification_code: The verification code from the email
|
||||
|
||||
Returns:
|
||||
dict: Summary of the deletion
|
||||
|
||||
Raises:
|
||||
ValueError: If verification fails
|
||||
"""
|
||||
try:
|
||||
deletion_request = UserDeletionRequest.objects.get(
|
||||
verification_code=verification_code
|
||||
)
|
||||
except UserDeletionRequest.DoesNotExist:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Check if request is still valid
|
||||
if not deletion_request.is_valid():
|
||||
if deletion_request.is_expired():
|
||||
raise ValueError("Verification code has expired")
|
||||
elif deletion_request.is_used:
|
||||
raise ValueError("Verification code has already been used")
|
||||
elif deletion_request.attempts >= deletion_request.max_attempts:
|
||||
raise ValueError("Too many verification attempts")
|
||||
else:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Increment attempts
|
||||
deletion_request.increment_attempts()
|
||||
|
||||
# Mark as used
|
||||
deletion_request.mark_as_used()
|
||||
|
||||
# Delete the user
|
||||
user = deletion_request.user
|
||||
result = cls.delete_user_preserve_submissions(user)
|
||||
|
||||
# Add deletion request info to result
|
||||
result["deletion_request"] = {
|
||||
"verification_code": verification_code,
|
||||
"created_at": deletion_request.created_at,
|
||||
"verified_at": timezone.now(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def cancel_deletion_request(cls, user: User) -> bool:
|
||||
"""
|
||||
Cancel a pending deletion request.
|
||||
|
||||
Args:
|
||||
user: The user whose deletion request to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if a request was cancelled, False if no request existed
|
||||
"""
|
||||
try:
|
||||
deletion_request = getattr(user, "deletion_request", None)
|
||||
if deletion_request:
|
||||
deletion_request.delete()
|
||||
return True
|
||||
return False
|
||||
except UserDeletionRequest.DoesNotExist:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired_deletion_requests(cls) -> int:
|
||||
"""
|
||||
Clean up expired deletion requests.
|
||||
|
||||
Returns:
|
||||
int: Number of expired requests cleaned up
|
||||
"""
|
||||
return UserDeletionRequest.cleanup_expired()
|
||||
379
backend/apps/accounts/services/notification_service.py
Normal file
379
backend/apps/accounts/services/notification_service.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Notification service for creating and managing user notifications.
|
||||
|
||||
This service handles the creation, delivery, and management of notifications
|
||||
for various events including submission approvals/rejections.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from typing import Optional, Dict, Any, List
|
||||
import logging
|
||||
|
||||
from apps.accounts.models.notifications import UserNotification, NotificationPreference
|
||||
from apps.accounts.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for creating and managing user notifications."""
|
||||
|
||||
@staticmethod
|
||||
def create_notification(
|
||||
user: User,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
related_object: Optional[Any] = None,
|
||||
priority: str = UserNotification.Priority.NORMAL,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
expires_at: Optional[timezone.datetime] = None,
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a new notification for a user.
|
||||
|
||||
Args:
|
||||
user: The user to notify
|
||||
notification_type: Type of notification (from UserNotification.NotificationType)
|
||||
title: Notification title
|
||||
message: Notification message
|
||||
related_object: Optional related object (submission, review, etc.)
|
||||
priority: Notification priority
|
||||
extra_data: Additional data to store with notification
|
||||
expires_at: When the notification expires
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
# Get content type and object ID if related object provided
|
||||
content_type = None
|
||||
object_id = None
|
||||
if related_object:
|
||||
content_type = ContentType.objects.get_for_model(related_object)
|
||||
object_id = related_object.pk
|
||||
|
||||
# Create the notification
|
||||
notification = UserNotification.objects.create(
|
||||
user=user,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
content_type=content_type,
|
||||
object_id=object_id,
|
||||
priority=priority,
|
||||
extra_data=extra_data or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
# Send notification through appropriate channels
|
||||
NotificationService._send_notification(notification)
|
||||
|
||||
return notification
|
||||
|
||||
@staticmethod
|
||||
def create_submission_approved_notification(
|
||||
user: User,
|
||||
submission_object: Any,
|
||||
submission_type: str,
|
||||
additional_message: str = "",
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission approval.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The approved submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
additional_message: Additional message from moderator
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} has been approved!"
|
||||
message = f"Great news! Your {submission_type} submission has been approved and is now live on ThrillWiki."
|
||||
|
||||
if additional_message:
|
||||
message += f"\n\nModerator note: {additional_message}"
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"moderator_message": additional_message,
|
||||
"approved_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_APPROVED,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.NORMAL,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_submission_rejected_notification(
|
||||
user: User,
|
||||
submission_object: Any,
|
||||
submission_type: str,
|
||||
rejection_reason: str,
|
||||
additional_message: str = "",
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission rejection.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The rejected submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
rejection_reason: Reason for rejection
|
||||
additional_message: Additional message from moderator
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} needs attention"
|
||||
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
|
||||
message += f"\n\nReason: {rejection_reason}"
|
||||
|
||||
if additional_message:
|
||||
message += f"\n\nModerator note: {additional_message}"
|
||||
|
||||
message += "\n\nYou can edit and resubmit your content from your profile page."
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"rejection_reason": rejection_reason,
|
||||
"moderator_message": additional_message,
|
||||
"rejected_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_REJECTED,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.HIGH,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_submission_pending_notification(
|
||||
user: User, submission_object: Any, submission_type: str
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission pending review.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The pending submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} is under review"
|
||||
message = f"Thanks for your {submission_type} submission! It's now under review by our moderation team."
|
||||
message += "\n\nWe'll notify you once it's been reviewed. This usually takes 1-2 business days."
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"submitted_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_PENDING,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.LOW,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send notification through appropriate channels based on user preferences.
|
||||
|
||||
Args:
|
||||
notification: The notification to send
|
||||
"""
|
||||
user = notification.user
|
||||
|
||||
# Get user's notification preferences
|
||||
try:
|
||||
preferences = user.notification_preference
|
||||
except NotificationPreference.DoesNotExist:
|
||||
# Create default preferences if they don't exist
|
||||
preferences = NotificationPreference.objects.create(user=user)
|
||||
|
||||
# Send email notification if enabled
|
||||
if preferences.should_send_notification(
|
||||
notification.notification_type, "email"
|
||||
):
|
||||
NotificationService._send_email_notification(notification)
|
||||
|
||||
# Send push notification if enabled
|
||||
if preferences.should_send_notification(notification.notification_type, "push"):
|
||||
NotificationService._send_push_notification(notification)
|
||||
|
||||
# In-app notifications are always created (the notification object itself)
|
||||
# The frontend will check preferences when displaying them
|
||||
|
||||
@staticmethod
|
||||
def _send_email_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send email notification to user.
|
||||
|
||||
Args:
|
||||
notification: The notification to send via email
|
||||
"""
|
||||
try:
|
||||
user = notification.user
|
||||
|
||||
# Prepare email context
|
||||
context = {
|
||||
"user": user,
|
||||
"notification": notification,
|
||||
"site_name": "ThrillWiki",
|
||||
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
|
||||
}
|
||||
|
||||
# Render email templates
|
||||
subject = f"ThrillWiki: {notification.title}"
|
||||
html_message = render_to_string("emails/notification.html", context)
|
||||
plain_message = render_to_string("emails/notification.txt", context)
|
||||
|
||||
# Send email
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
html_message=html_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
# Mark as sent
|
||||
notification.email_sent = True
|
||||
notification.email_sent_at = timezone.now()
|
||||
notification.save(update_fields=["email_sent", "email_sent_at"])
|
||||
|
||||
logger.info(
|
||||
f"Email notification sent to {user.email} for notification {notification.id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send email notification {notification.id}: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_push_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send push notification to user.
|
||||
|
||||
Args:
|
||||
notification: The notification to send via push
|
||||
"""
|
||||
try:
|
||||
# TODO: Implement push notification service (Firebase, etc.)
|
||||
# For now, just mark as sent
|
||||
notification.push_sent = True
|
||||
notification.push_sent_at = timezone.now()
|
||||
notification.save(update_fields=["push_sent", "push_sent_at"])
|
||||
|
||||
logger.info(f"Push notification sent for notification {notification.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send push notification {notification.id}: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_notifications(
|
||||
user: User,
|
||||
unread_only: bool = False,
|
||||
notification_types: Optional[List[str]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[UserNotification]:
|
||||
"""
|
||||
Get notifications for a user.
|
||||
|
||||
Args:
|
||||
user: User to get notifications for
|
||||
unread_only: Only return unread notifications
|
||||
notification_types: Filter by notification types
|
||||
limit: Limit number of results
|
||||
|
||||
Returns:
|
||||
List[UserNotification]: List of notifications
|
||||
"""
|
||||
queryset = UserNotification.objects.filter(user=user)
|
||||
|
||||
if unread_only:
|
||||
queryset = queryset.filter(is_read=False)
|
||||
|
||||
if notification_types:
|
||||
queryset = queryset.filter(notification_type__in=notification_types)
|
||||
|
||||
# Exclude expired notifications
|
||||
queryset = queryset.filter(
|
||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
|
||||
)
|
||||
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def mark_notifications_read(
|
||||
user: User, notification_ids: Optional[List[int]] = None
|
||||
) -> int:
|
||||
"""
|
||||
Mark notifications as read for a user.
|
||||
|
||||
Args:
|
||||
user: User whose notifications to mark as read
|
||||
notification_ids: Specific notification IDs to mark as read (if None, marks all)
|
||||
|
||||
Returns:
|
||||
int: Number of notifications marked as read
|
||||
"""
|
||||
queryset = UserNotification.objects.filter(user=user, is_read=False)
|
||||
|
||||
if notification_ids:
|
||||
queryset = queryset.filter(id__in=notification_ids)
|
||||
|
||||
return queryset.update(is_read=True, read_at=timezone.now())
|
||||
|
||||
@staticmethod
|
||||
def cleanup_old_notifications(days: int = 90) -> int:
|
||||
"""
|
||||
Clean up old read notifications.
|
||||
|
||||
Args:
|
||||
days: Number of days to keep read notifications
|
||||
|
||||
Returns:
|
||||
int: Number of notifications deleted
|
||||
"""
|
||||
cutoff_date = timezone.now() - timezone.timedelta(days=days)
|
||||
|
||||
old_notifications = UserNotification.objects.filter(
|
||||
is_read=True, read_at__lt=cutoff_date
|
||||
)
|
||||
|
||||
count = old_notifications.count()
|
||||
old_notifications.delete()
|
||||
|
||||
logger.info(f"Cleaned up {count} old notifications")
|
||||
return count
|
||||
155
backend/apps/accounts/tests/test_user_deletion.py
Normal file
155
backend/apps/accounts/tests/test_user_deletion.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Tests for user deletion while preserving submissions.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django.db import transaction
|
||||
from apps.accounts.services import UserDeletionService
|
||||
from apps.accounts.models import User, UserProfile
|
||||
|
||||
|
||||
class UserDeletionServiceTest(TestCase):
|
||||
"""Test cases for UserDeletionService."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
# Create test users
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass123"
|
||||
)
|
||||
|
||||
self.admin_user = User.objects.create_user(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="adminpass123",
|
||||
is_superuser=True,
|
||||
)
|
||||
|
||||
# Create user profiles
|
||||
UserProfile.objects.create(
|
||||
user=self.user, display_name="Test User", bio="Test bio"
|
||||
)
|
||||
|
||||
UserProfile.objects.create(
|
||||
user=self.admin_user, display_name="Admin User", bio="Admin bio"
|
||||
)
|
||||
|
||||
def test_get_or_create_deleted_user(self):
|
||||
"""Test that deleted user placeholder is created correctly."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
self.assertEqual(deleted_user.username, "deleted_user")
|
||||
self.assertEqual(deleted_user.email, "deleted@thrillwiki.com")
|
||||
self.assertFalse(deleted_user.is_active)
|
||||
self.assertTrue(deleted_user.is_banned)
|
||||
self.assertEqual(deleted_user.role, User.Roles.USER)
|
||||
|
||||
# Check profile was created
|
||||
self.assertTrue(hasattr(deleted_user, "profile"))
|
||||
self.assertEqual(deleted_user.profile.display_name, "Deleted User")
|
||||
|
||||
def test_get_or_create_deleted_user_idempotent(self):
|
||||
"""Test that calling get_or_create_deleted_user multiple times returns same user."""
|
||||
deleted_user1 = UserDeletionService.get_or_create_deleted_user()
|
||||
deleted_user2 = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
self.assertEqual(deleted_user1.id, deleted_user2.id)
|
||||
self.assertEqual(User.objects.filter(username="deleted_user").count(), 1)
|
||||
|
||||
def test_can_delete_user_normal_user(self):
|
||||
"""Test that normal users can be deleted."""
|
||||
can_delete, reason = UserDeletionService.can_delete_user(self.user)
|
||||
|
||||
self.assertTrue(can_delete)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_can_delete_user_superuser(self):
|
||||
"""Test that superusers cannot be deleted."""
|
||||
can_delete, reason = UserDeletionService.can_delete_user(self.admin_user)
|
||||
|
||||
self.assertFalse(can_delete)
|
||||
self.assertEqual(reason, "Cannot delete superuser accounts")
|
||||
|
||||
def test_can_delete_user_deleted_user_placeholder(self):
|
||||
"""Test that deleted user placeholder cannot be deleted."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
can_delete, reason = UserDeletionService.can_delete_user(deleted_user)
|
||||
|
||||
self.assertFalse(can_delete)
|
||||
self.assertEqual(reason, "Cannot delete the system deleted user placeholder")
|
||||
|
||||
def test_delete_user_preserve_submissions_no_submissions(self):
|
||||
"""Test deleting user with no submissions."""
|
||||
user_id = self.user.user_id
|
||||
username = self.user.username
|
||||
|
||||
result = UserDeletionService.delete_user_preserve_submissions(self.user)
|
||||
|
||||
# Check user was deleted
|
||||
self.assertFalse(User.objects.filter(user_id=user_id).exists())
|
||||
|
||||
# Check result structure
|
||||
self.assertIn("deleted_user", result)
|
||||
self.assertIn("preserved_submissions", result)
|
||||
self.assertIn("transferred_to", result)
|
||||
|
||||
self.assertEqual(result["deleted_user"]["username"], username)
|
||||
self.assertEqual(result["deleted_user"]["user_id"], user_id)
|
||||
|
||||
# All submission counts should be 0
|
||||
for count in result["preserved_submissions"].values():
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_delete_user_cannot_delete_deleted_user_placeholder(self):
|
||||
"""Test that attempting to delete the deleted user placeholder raises error."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
UserDeletionService.delete_user_preserve_submissions(deleted_user)
|
||||
|
||||
self.assertIn(
|
||||
"Cannot delete the system deleted user placeholder", str(context.exception)
|
||||
)
|
||||
|
||||
def test_delete_user_with_submissions_transfers_correctly(self):
|
||||
"""Test that user submissions are transferred to deleted user placeholder."""
|
||||
# This test would require creating park/ride data which is complex
|
||||
# For now, we'll test the basic functionality
|
||||
|
||||
# Create deleted user first to ensure it exists
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
# Delete the test user
|
||||
result = UserDeletionService.delete_user_preserve_submissions(self.user)
|
||||
|
||||
# Verify the deleted user placeholder still exists
|
||||
self.assertTrue(User.objects.filter(username="deleted_user").exists())
|
||||
|
||||
# Verify result structure
|
||||
self.assertIn("deleted_user", result)
|
||||
self.assertIn("preserved_submissions", result)
|
||||
self.assertIn("transferred_to", result)
|
||||
|
||||
self.assertEqual(result["transferred_to"]["username"], "deleted_user")
|
||||
|
||||
def test_delete_user_atomic_transaction(self):
|
||||
"""Test that user deletion is atomic."""
|
||||
# This test ensures that if something goes wrong during deletion,
|
||||
# the transaction is rolled back
|
||||
|
||||
original_user_count = User.objects.count()
|
||||
|
||||
# Mock a failure during the deletion process
|
||||
with self.assertRaises(Exception):
|
||||
with transaction.atomic():
|
||||
# Start the deletion process
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
# Simulate an error
|
||||
raise Exception("Simulated error during deletion")
|
||||
|
||||
# Verify user count hasn't changed
|
||||
self.assertEqual(User.objects.count(), original_user_count)
|
||||
|
||||
# Verify our test user still exists
|
||||
self.assertTrue(User.objects.filter(user_id=self.user.user_id).exists())
|
||||
@@ -1,18 +1,108 @@
|
||||
"""
|
||||
Accounts API URL Configuration
|
||||
URL configuration for user account management API endpoints.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# Create router and register ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r"profiles", views.UserProfileViewSet, basename="user-profile")
|
||||
router.register(r"toplists", views.TopListViewSet, basename="top-list")
|
||||
router.register(r"toplist-items", views.TopListItemViewSet, basename="top-list-item")
|
||||
|
||||
urlpatterns = [
|
||||
# Include router URLs for ViewSets
|
||||
path("", include(router.urls)),
|
||||
# Admin endpoints for user management
|
||||
path(
|
||||
"users/<str:user_id>/delete/",
|
||||
views.delete_user_preserve_submissions,
|
||||
name="delete_user_preserve_submissions",
|
||||
),
|
||||
path(
|
||||
"users/<str:user_id>/deletion-check/",
|
||||
views.check_user_deletion_eligibility,
|
||||
name="check_user_deletion_eligibility",
|
||||
),
|
||||
# Self-service account deletion endpoints
|
||||
path(
|
||||
"delete-account/request/",
|
||||
views.request_account_deletion,
|
||||
name="request_account_deletion",
|
||||
),
|
||||
path(
|
||||
"delete-account/verify/",
|
||||
views.verify_account_deletion,
|
||||
name="verify_account_deletion",
|
||||
),
|
||||
path(
|
||||
"delete-account/cancel/",
|
||||
views.cancel_account_deletion,
|
||||
name="cancel_account_deletion",
|
||||
),
|
||||
# User profile endpoints
|
||||
path("profile/", views.get_user_profile, name="get_user_profile"),
|
||||
path("profile/account/", views.update_user_account, name="update_user_account"),
|
||||
path("profile/update/", views.update_user_profile, name="update_user_profile"),
|
||||
# User preferences endpoints
|
||||
path("preferences/", views.get_user_preferences, name="get_user_preferences"),
|
||||
path(
|
||||
"preferences/update/",
|
||||
views.update_user_preferences,
|
||||
name="update_user_preferences",
|
||||
),
|
||||
path(
|
||||
"preferences/theme/",
|
||||
views.update_theme_preference,
|
||||
name="update_theme_preference",
|
||||
),
|
||||
# Notification settings endpoints
|
||||
path(
|
||||
"settings/notifications/",
|
||||
views.get_notification_settings,
|
||||
name="get_notification_settings",
|
||||
),
|
||||
path(
|
||||
"settings/notifications/update/",
|
||||
views.update_notification_settings,
|
||||
name="update_notification_settings",
|
||||
),
|
||||
# Privacy settings endpoints
|
||||
path("settings/privacy/", views.get_privacy_settings, name="get_privacy_settings"),
|
||||
path(
|
||||
"settings/privacy/update/",
|
||||
views.update_privacy_settings,
|
||||
name="update_privacy_settings",
|
||||
),
|
||||
# Security settings endpoints
|
||||
path(
|
||||
"settings/security/", views.get_security_settings, name="get_security_settings"
|
||||
),
|
||||
path(
|
||||
"settings/security/update/",
|
||||
views.update_security_settings,
|
||||
name="update_security_settings",
|
||||
),
|
||||
# User statistics endpoints
|
||||
path("statistics/", views.get_user_statistics, name="get_user_statistics"),
|
||||
# Top lists endpoints
|
||||
path("top-lists/", views.get_user_top_lists, name="get_user_top_lists"),
|
||||
path("top-lists/create/", views.create_top_list, name="create_top_list"),
|
||||
path("top-lists/<int:list_id>/", views.update_top_list, name="update_top_list"),
|
||||
path(
|
||||
"top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"
|
||||
),
|
||||
# Notification endpoints
|
||||
path("notifications/", views.get_user_notifications, name="get_user_notifications"),
|
||||
path(
|
||||
"notifications/mark-read/",
|
||||
views.mark_notifications_read,
|
||||
name="mark_notifications_read",
|
||||
),
|
||||
path(
|
||||
"notification-preferences/",
|
||||
views.get_notification_preferences,
|
||||
name="get_notification_preferences",
|
||||
),
|
||||
path(
|
||||
"notification-preferences/update/",
|
||||
views.update_notification_preferences,
|
||||
name="update_notification_preferences",
|
||||
),
|
||||
# Avatar endpoints
|
||||
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
||||
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,7 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"""User serializer for API responses."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
@@ -87,9 +88,14 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined", "is_active"]
|
||||
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
"""Get user avatar URL."""
|
||||
|
||||
@@ -4,27 +4,27 @@ Migrated from apps.core.views.map_views
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.db.models import Q
|
||||
from django.core.cache import cache
|
||||
from django.contrib.gis.geos import Polygon
|
||||
from django.contrib.gis.db.models.functions import Distance
|
||||
from django.contrib.gis.geos import Point
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema,
|
||||
extend_schema_view,
|
||||
OpenApiParameter,
|
||||
OpenApiExample,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
from apps.parks.models import Park, ParkLocation
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from ..serializers.maps import (
|
||||
MapLocationSerializer,
|
||||
MapLocationsResponseSerializer,
|
||||
MapSearchResultSerializer,
|
||||
MapSearchResponseSerializer,
|
||||
MapLocationDetailSerializer,
|
||||
)
|
||||
@@ -86,7 +86,7 @@ logger = logging.getLogger(__name__)
|
||||
examples=[
|
||||
OpenApiExample("All types", value="park,ride"),
|
||||
OpenApiExample("Parks only", value="park"),
|
||||
OpenApiExample("Rides only", value="ride")
|
||||
OpenApiExample("Rides only", value="ride"),
|
||||
],
|
||||
),
|
||||
OpenApiParameter(
|
||||
@@ -97,7 +97,7 @@ logger = logging.getLogger(__name__)
|
||||
description="Enable location clustering for high-density areas. Default: false",
|
||||
examples=[
|
||||
OpenApiExample("Enable clustering", value=True),
|
||||
OpenApiExample("Disable clustering", value=False)
|
||||
OpenApiExample("Disable clustering", value=False),
|
||||
],
|
||||
),
|
||||
OpenApiParameter(
|
||||
@@ -109,7 +109,7 @@ logger = logging.getLogger(__name__)
|
||||
examples=[
|
||||
OpenApiExample("Park name", value="Cedar Point"),
|
||||
OpenApiExample("Ride type", value="roller coaster"),
|
||||
OpenApiExample("Location", value="Ohio")
|
||||
OpenApiExample("Location", value="Ohio"),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -150,27 +150,28 @@ class MapLocationsAPIView(APIView):
|
||||
|
||||
# Get parks if requested
|
||||
if "park" in types:
|
||||
parks_query = Park.objects.select_related("location", "operator").filter(
|
||||
location__point__isnull=False
|
||||
)
|
||||
parks_query = Park.objects.select_related(
|
||||
"location", "operator"
|
||||
).filter(location__point__isnull=False)
|
||||
|
||||
# Apply bounds filtering
|
||||
if all([north, south, east, west]):
|
||||
try:
|
||||
bounds_polygon = Polygon.from_bbox((
|
||||
float(west), float(south), float(east), float(north)
|
||||
))
|
||||
bounds_polygon = Polygon.from_bbox(
|
||||
(float(west), float(south), float(east), float(north))
|
||||
)
|
||||
parks_query = parks_query.filter(
|
||||
location__point__within=bounds_polygon)
|
||||
location__point__within=bounds_polygon
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply text search
|
||||
if query:
|
||||
parks_query = parks_query.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(location__city__icontains=query) |
|
||||
Q(location__state__icontains=query)
|
||||
Q(name__icontains=query)
|
||||
| Q(location__city__icontains=query)
|
||||
| Q(location__state__icontains=query)
|
||||
)
|
||||
|
||||
# Serialize parks
|
||||
@@ -180,46 +181,75 @@ class MapLocationsAPIView(APIView):
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"latitude": (
|
||||
park.location.latitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
park.location.longitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"status": park.status,
|
||||
"location": {
|
||||
"city": park.location.city if hasattr(park, 'location') and park.location else "",
|
||||
"state": park.location.state if hasattr(park, 'location') and park.location else "",
|
||||
"country": park.location.country if hasattr(park, 'location') and park.location else "",
|
||||
"formatted_address": park.location.formatted_address if hasattr(park, 'location') and park.location else "",
|
||||
"city": (
|
||||
park.location.city
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
park.location.state
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
park.location.country
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"formatted_address": (
|
||||
park.location.formatted_address
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": park.coaster_count or 0,
|
||||
"ride_count": park.ride_count or 0,
|
||||
"average_rating": float(park.average_rating) if park.average_rating else None,
|
||||
"average_rating": (
|
||||
float(park.average_rating)
|
||||
if park.average_rating
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
locations.append(park_data)
|
||||
|
||||
# Get rides if requested
|
||||
if "ride" in types:
|
||||
rides_query = Ride.objects.select_related("park__location", "manufacturer").filter(
|
||||
park__location__point__isnull=False
|
||||
)
|
||||
rides_query = Ride.objects.select_related(
|
||||
"park__location", "manufacturer"
|
||||
).filter(park__location__point__isnull=False)
|
||||
|
||||
# Apply bounds filtering
|
||||
if all([north, south, east, west]):
|
||||
try:
|
||||
bounds_polygon = Polygon.from_bbox((
|
||||
float(west), float(south), float(east), float(north)
|
||||
))
|
||||
bounds_polygon = Polygon.from_bbox(
|
||||
(float(west), float(south), float(east), float(north))
|
||||
)
|
||||
rides_query = rides_query.filter(
|
||||
park__location__point__within=bounds_polygon)
|
||||
park__location__point__within=bounds_polygon
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply text search
|
||||
if query:
|
||||
rides_query = rides_query.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(park__name__icontains=query) |
|
||||
Q(park__location__city__icontains=query)
|
||||
Q(name__icontains=query)
|
||||
| Q(park__name__icontains=query)
|
||||
| Q(park__location__city__icontains=query)
|
||||
)
|
||||
|
||||
# Serialize rides
|
||||
@@ -229,18 +259,48 @@ class MapLocationsAPIView(APIView):
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"latitude": (
|
||||
ride.park.location.latitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
ride.park.location.longitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"status": ride.status,
|
||||
"location": {
|
||||
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"formatted_address": ride.park.location.formatted_address if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"city": (
|
||||
ride.park.location.city
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
ride.park.location.state
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
ride.park.location.country
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"formatted_address": (
|
||||
ride.park.location.formatted_address
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"category": ride.get_category_display() if ride.category else None,
|
||||
"average_rating": float(ride.average_rating) if ride.average_rating else None,
|
||||
"category": (
|
||||
ride.get_category_display() if ride.category else None
|
||||
),
|
||||
"average_rating": (
|
||||
float(ride.average_rating)
|
||||
if ride.average_rating
|
||||
else None
|
||||
),
|
||||
"park_name": ride.park.name,
|
||||
},
|
||||
}
|
||||
@@ -324,8 +384,9 @@ class MapLocationDetailAPIView(APIView):
|
||||
try:
|
||||
if location_type == "park":
|
||||
try:
|
||||
obj = Park.objects.select_related(
|
||||
"location", "operator").get(id=location_id)
|
||||
obj = Park.objects.select_related("location", "operator").get(
|
||||
id=location_id
|
||||
)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"status": "error", "message": "Park not found"},
|
||||
@@ -334,7 +395,8 @@ class MapLocationDetailAPIView(APIView):
|
||||
elif location_type == "ride":
|
||||
try:
|
||||
obj = Ride.objects.select_related(
|
||||
"park__location", "manufacturer").get(id=location_id)
|
||||
"park__location", "manufacturer"
|
||||
).get(id=location_id)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"status": "error", "message": "Ride not found"},
|
||||
@@ -354,23 +416,59 @@ class MapLocationDetailAPIView(APIView):
|
||||
"name": obj.name,
|
||||
"slug": obj.slug,
|
||||
"description": obj.description,
|
||||
"latitude": obj.location.latitude if hasattr(obj, 'location') and obj.location else None,
|
||||
"longitude": obj.location.longitude if hasattr(obj, 'location') and obj.location else None,
|
||||
"latitude": (
|
||||
obj.location.latitude
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
obj.location.longitude
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else None
|
||||
),
|
||||
"status": obj.status,
|
||||
"location": {
|
||||
"street_address": obj.location.street_address if hasattr(obj, 'location') and obj.location else "",
|
||||
"city": obj.location.city if hasattr(obj, 'location') and obj.location else "",
|
||||
"state": obj.location.state if hasattr(obj, 'location') and obj.location else "",
|
||||
"country": obj.location.country if hasattr(obj, 'location') and obj.location else "",
|
||||
"postal_code": obj.location.postal_code if hasattr(obj, 'location') and obj.location else "",
|
||||
"formatted_address": obj.location.formatted_address if hasattr(obj, 'location') and obj.location else "",
|
||||
"street_address": (
|
||||
obj.location.street_address
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"city": (
|
||||
obj.location.city
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
obj.location.state
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
obj.location.country
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"postal_code": (
|
||||
obj.location.postal_code
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
"formatted_address": (
|
||||
obj.location.formatted_address
|
||||
if hasattr(obj, "location") and obj.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"size_acres": float(obj.size_acres) if obj.size_acres else None,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
},
|
||||
"nearby_locations": [], # TODO: Implement nearby locations
|
||||
}
|
||||
@@ -381,31 +479,73 @@ class MapLocationDetailAPIView(APIView):
|
||||
"name": obj.name,
|
||||
"slug": obj.slug,
|
||||
"description": obj.description,
|
||||
"latitude": obj.park.location.latitude if hasattr(obj.park, 'location') and obj.park.location else None,
|
||||
"longitude": obj.park.location.longitude if hasattr(obj.park, 'location') and obj.park.location else None,
|
||||
"latitude": (
|
||||
obj.park.location.latitude
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
obj.park.location.longitude
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else None
|
||||
),
|
||||
"status": obj.status,
|
||||
"location": {
|
||||
"street_address": obj.park.location.street_address if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"city": obj.park.location.city if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"state": obj.park.location.state if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"country": obj.park.location.country if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"postal_code": obj.park.location.postal_code if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"formatted_address": obj.park.location.formatted_address if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"street_address": (
|
||||
obj.park.location.street_address
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"city": (
|
||||
obj.park.location.city
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
obj.park.location.state
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
obj.park.location.country
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"postal_code": (
|
||||
obj.park.location.postal_code
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
"formatted_address": (
|
||||
obj.park.location.formatted_address
|
||||
if hasattr(obj.park, "location") and obj.park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"category": (
|
||||
obj.get_category_display() if obj.category else None
|
||||
),
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
"manufacturer": (
|
||||
obj.manufacturer.name if obj.manufacturer else None
|
||||
),
|
||||
},
|
||||
"nearby_locations": [], # TODO: Implement nearby locations
|
||||
}
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"data": data,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
|
||||
@@ -484,51 +624,106 @@ class MapSearchAPIView(APIView):
|
||||
|
||||
# Search parks
|
||||
if "park" in types:
|
||||
parks_query = Park.objects.select_related("location").filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(location__city__icontains=query) |
|
||||
Q(location__state__icontains=query)
|
||||
).filter(location__point__isnull=False)
|
||||
parks_query = (
|
||||
Park.objects.select_related("location")
|
||||
.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(location__city__icontains=query)
|
||||
| Q(location__state__icontains=query)
|
||||
)
|
||||
.filter(location__point__isnull=False)
|
||||
)
|
||||
|
||||
for park in parks_query[:50]: # Limit results
|
||||
results.append({
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"location": {
|
||||
"city": park.location.city if hasattr(park, 'location') and park.location else "",
|
||||
"state": park.location.state if hasattr(park, 'location') and park.location else "",
|
||||
"country": park.location.country if hasattr(park, 'location') and park.location else "",
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": (
|
||||
park.location.latitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
park.location.longitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"location": {
|
||||
"city": (
|
||||
park.location.city
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
park.location.state
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
park.location.country
|
||||
if hasattr(park, "location") and park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
}
|
||||
)
|
||||
|
||||
# Search rides
|
||||
if "ride" in types:
|
||||
rides_query = Ride.objects.select_related("park__location").filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(park__name__icontains=query) |
|
||||
Q(park__location__city__icontains=query)
|
||||
).filter(park__location__point__isnull=False)
|
||||
rides_query = (
|
||||
Ride.objects.select_related("park__location")
|
||||
.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(park__name__icontains=query)
|
||||
| Q(park__location__city__icontains=query)
|
||||
)
|
||||
.filter(park__location__point__isnull=False)
|
||||
)
|
||||
|
||||
for ride in rides_query[:50]: # Limit results
|
||||
results.append({
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"location": {
|
||||
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": (
|
||||
ride.park.location.latitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
ride.park.location.longitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"location": {
|
||||
"city": (
|
||||
ride.park.location.city
|
||||
if hasattr(ride.park, "location")
|
||||
and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"state": (
|
||||
ride.park.location.state
|
||||
if hasattr(ride.park, "location")
|
||||
and ride.park.location
|
||||
else ""
|
||||
),
|
||||
"country": (
|
||||
ride.park.location.country
|
||||
if hasattr(ride.park, "location")
|
||||
and ride.park.location
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
}
|
||||
)
|
||||
|
||||
total_count = len(results)
|
||||
|
||||
@@ -537,14 +732,16 @@ class MapSearchAPIView(APIView):
|
||||
end_idx = start_idx + page_size
|
||||
paginated_results = results[start_idx:end_idx]
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"results": paginated_results,
|
||||
"query": query,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"results": paginated_results,
|
||||
"query": query,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
|
||||
@@ -622,13 +819,19 @@ class MapBoundsAPIView(APIView):
|
||||
# Validate bounds
|
||||
if north <= south:
|
||||
return Response(
|
||||
{"status": "error", "message": "North bound must be greater than south bound"},
|
||||
{
|
||||
"status": "error",
|
||||
"message": "North bound must be greater than south bound",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if west >= east:
|
||||
return Response(
|
||||
{"status": "error", "message": "West bound must be less than east bound"},
|
||||
{
|
||||
"status": "error",
|
||||
"message": "West bound must be less than east bound",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -645,15 +848,25 @@ class MapBoundsAPIView(APIView):
|
||||
)
|
||||
|
||||
for park in parks_query[:100]: # Limit results
|
||||
locations.append({
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"status": park.status,
|
||||
})
|
||||
locations.append(
|
||||
{
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": (
|
||||
park.location.latitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
park.location.longitude
|
||||
if hasattr(park, "location") and park.location
|
||||
else None
|
||||
),
|
||||
"status": park.status,
|
||||
}
|
||||
)
|
||||
|
||||
# Get rides within bounds
|
||||
if "ride" in types:
|
||||
@@ -662,32 +875,47 @@ class MapBoundsAPIView(APIView):
|
||||
)
|
||||
|
||||
for ride in rides_query[:100]: # Limit results
|
||||
locations.append({
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"status": ride.status,
|
||||
})
|
||||
locations.append(
|
||||
{
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": (
|
||||
ride.park.location.latitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"longitude": (
|
||||
ride.park.location.longitude
|
||||
if hasattr(ride.park, "location") and ride.park.location
|
||||
else None
|
||||
),
|
||||
"status": ride.status,
|
||||
}
|
||||
)
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"locations": locations,
|
||||
"bounds": {
|
||||
"north": north,
|
||||
"south": south,
|
||||
"east": east,
|
||||
"west": west,
|
||||
},
|
||||
"total_count": len(locations),
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"locations": locations,
|
||||
"bounds": {
|
||||
"north": north,
|
||||
"south": south,
|
||||
"east": east,
|
||||
"west": west,
|
||||
},
|
||||
"total_count": len(locations),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"status": "error", "message": "Failed to retrieve locations within bounds"},
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Failed to retrieve locations within bounds",
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@@ -710,21 +938,25 @@ class MapStatsAPIView(APIView):
|
||||
try:
|
||||
# Count locations with coordinates
|
||||
parks_with_location = Park.objects.filter(
|
||||
location__point__isnull=False).count()
|
||||
location__point__isnull=False
|
||||
).count()
|
||||
rides_with_location = Ride.objects.filter(
|
||||
park__location__point__isnull=False).count()
|
||||
park__location__point__isnull=False
|
||||
).count()
|
||||
total_locations = parks_with_location + rides_with_location
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"data": {
|
||||
"total_locations": total_locations,
|
||||
"parks_with_location": parks_with_location,
|
||||
"rides_with_location": rides_with_location,
|
||||
"cache_hits": 0, # TODO: Implement cache statistics
|
||||
"cache_misses": 0, # TODO: Implement cache statistics
|
||||
},
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"total_locations": total_locations,
|
||||
"parks_with_location": parks_with_location,
|
||||
"rides_with_location": rides_with_location,
|
||||
"cache_hits": 0, # TODO: Implement cache statistics
|
||||
"cache_misses": 0, # TODO: Implement cache statistics
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
|
||||
@@ -764,10 +996,12 @@ class MapCacheAPIView(APIView):
|
||||
else:
|
||||
cleared_count = 0
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
|
||||
@@ -787,10 +1021,12 @@ class MapCacheAPIView(APIView):
|
||||
else:
|
||||
invalidated_count = 0
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
|
||||
|
||||
@@ -16,8 +16,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import NotFound
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
@@ -422,9 +421,17 @@ class ParkSearchSuggestionsAPIView(APIView):
|
||||
@extend_schema(
|
||||
summary="Set park banner and card images",
|
||||
description="Set banner_image and card_image for a park from existing park photos",
|
||||
request=("ParkImageSettingsInputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
|
||||
request=(
|
||||
"ParkImageSettingsInputSerializer"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
),
|
||||
responses={
|
||||
200: ("ParkDetailOutputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
|
||||
200: (
|
||||
"ParkDetailOutputSerializer"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
),
|
||||
400: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
@@ -462,5 +469,6 @@ class ParkImageSettingsAPIView(APIView):
|
||||
|
||||
# Return updated park data
|
||||
output_serializer = ParkDetailOutputSerializer(
|
||||
park, context={"request": request})
|
||||
park, context={"request": request}
|
||||
)
|
||||
return Response(output_serializer.data)
|
||||
|
||||
@@ -6,39 +6,43 @@ Enhanced from rogue implementation to maintain full feature parity.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.parks.models import Park, ParkPhoto
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name='Park Photo with Cloudflare Images',
|
||||
summary='Complete park photo response',
|
||||
description='Example response showing all fields including Cloudflare Images URLs and variants',
|
||||
name="Park Photo with Cloudflare Images",
|
||||
summary="Complete park photo response",
|
||||
description="Example response showing all fields including Cloudflare Images URLs and variants",
|
||||
value={
|
||||
'id': 456,
|
||||
'image': 'https://imagedelivery.net/account-hash/def456ghi789/public',
|
||||
'image_url': 'https://imagedelivery.net/account-hash/def456ghi789/public',
|
||||
'image_variants': {
|
||||
'thumbnail': 'https://imagedelivery.net/account-hash/def456ghi789/thumbnail',
|
||||
'medium': 'https://imagedelivery.net/account-hash/def456ghi789/medium',
|
||||
'large': 'https://imagedelivery.net/account-hash/def456ghi789/large',
|
||||
'public': 'https://imagedelivery.net/account-hash/def456ghi789/public'
|
||||
"id": 456,
|
||||
"image": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
"image_url": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def456ghi789/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def456ghi789/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def456ghi789/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
},
|
||||
'caption': 'Beautiful park entrance',
|
||||
'alt_text': 'Main entrance gate with decorative archway',
|
||||
'is_primary': True,
|
||||
'is_approved': True,
|
||||
'created_at': '2023-01-01T12:00:00Z',
|
||||
'updated_at': '2023-01-01T12:00:00Z',
|
||||
'date_taken': '2023-01-01T11:00:00Z',
|
||||
'uploaded_by_username': 'parkfan456',
|
||||
'file_size': 1536000,
|
||||
'dimensions': [1600, 900],
|
||||
'park_slug': 'cedar-point',
|
||||
'park_name': 'Cedar Point'
|
||||
}
|
||||
"caption": "Beautiful park entrance",
|
||||
"alt_text": "Main entrance gate with decorative archway",
|
||||
"is_primary": True,
|
||||
"is_approved": True,
|
||||
"created_at": "2023-01-01T12:00:00Z",
|
||||
"updated_at": "2023-01-01T12:00:00Z",
|
||||
"date_taken": "2023-01-01T11:00:00Z",
|
||||
"uploaded_by_username": "parkfan456",
|
||||
"file_size": 1536000,
|
||||
"dimensions": [1600, 900],
|
||||
"park_slug": "cedar-point",
|
||||
"park_name": "Cedar Point",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -76,8 +80,7 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.URLField(
|
||||
help_text="Full URL to the Cloudflare Images asset",
|
||||
allow_null=True
|
||||
help_text="Full URL to the Cloudflare Images asset", allow_null=True
|
||||
)
|
||||
)
|
||||
def get_image_url(self, obj):
|
||||
@@ -89,7 +92,7 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
@extend_schema_field(
|
||||
serializers.DictField(
|
||||
child=serializers.URLField(),
|
||||
help_text="Available Cloudflare Images variants with their URLs"
|
||||
help_text="Available Cloudflare Images variants with their URLs",
|
||||
)
|
||||
)
|
||||
def get_image_variants(self, obj):
|
||||
@@ -99,10 +102,10 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Common variants for park photos
|
||||
variants = {
|
||||
'thumbnail': f"{obj.image.url}/thumbnail",
|
||||
'medium': f"{obj.image.url}/medium",
|
||||
'large': f"{obj.image.url}/large",
|
||||
'public': f"{obj.image.url}/public"
|
||||
"thumbnail": f"{obj.image.url}/thumbnail",
|
||||
"medium": f"{obj.image.url}/medium",
|
||||
"large": f"{obj.image.url}/large",
|
||||
"public": f"{obj.image.url}/public",
|
||||
}
|
||||
return variants
|
||||
|
||||
|
||||
@@ -44,7 +44,11 @@ urlpatterns = [
|
||||
# Detail and action endpoints
|
||||
path("<int:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
# Park image settings endpoint
|
||||
path("<int:pk>/image-settings/", ParkImageSettingsAPIView.as_view(), name="park-image-settings"),
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
ParkImageSettingsAPIView.as_view(),
|
||||
name="park-image-settings",
|
||||
),
|
||||
# Park photo endpoints - domain-specific photo management
|
||||
path("<int:park_pk>/photos/", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -29,37 +29,51 @@ app_name = "api_v1_ride_models"
|
||||
urlpatterns = [
|
||||
# Core ride model endpoints - nested under manufacturer
|
||||
path("", RideModelListCreateAPIView.as_view(), name="ride-model-list-create"),
|
||||
path("<slug:ride_model_slug>/", RideModelDetailAPIView.as_view(), name="ride-model-detail"),
|
||||
|
||||
path(
|
||||
"<slug:ride_model_slug>/",
|
||||
RideModelDetailAPIView.as_view(),
|
||||
name="ride-model-detail",
|
||||
),
|
||||
# Search and filtering (global, not manufacturer-specific)
|
||||
path("search/", RideModelSearchAPIView.as_view(), name="ride-model-search"),
|
||||
path("filter-options/", RideModelFilterOptionsAPIView.as_view(),
|
||||
name="ride-model-filter-options"),
|
||||
|
||||
path(
|
||||
"filter-options/",
|
||||
RideModelFilterOptionsAPIView.as_view(),
|
||||
name="ride-model-filter-options",
|
||||
),
|
||||
# Statistics (global, not manufacturer-specific)
|
||||
path("stats/", RideModelStatsAPIView.as_view(), name="ride-model-stats"),
|
||||
|
||||
# Ride model variants - using slug-based lookup
|
||||
path("<slug:ride_model_slug>/variants/",
|
||||
RideModelVariantListCreateAPIView.as_view(),
|
||||
name="ride-model-variant-list-create"),
|
||||
path("<slug:ride_model_slug>/variants/<int:pk>/",
|
||||
RideModelVariantDetailAPIView.as_view(),
|
||||
name="ride-model-variant-detail"),
|
||||
|
||||
path(
|
||||
"<slug:ride_model_slug>/variants/",
|
||||
RideModelVariantListCreateAPIView.as_view(),
|
||||
name="ride-model-variant-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/variants/<int:pk>/",
|
||||
RideModelVariantDetailAPIView.as_view(),
|
||||
name="ride-model-variant-detail",
|
||||
),
|
||||
# Technical specifications - using slug-based lookup
|
||||
path("<slug:ride_model_slug>/technical-specs/",
|
||||
RideModelTechnicalSpecListCreateAPIView.as_view(),
|
||||
name="ride-model-technical-spec-list-create"),
|
||||
path("<slug:ride_model_slug>/technical-specs/<int:pk>/",
|
||||
RideModelTechnicalSpecDetailAPIView.as_view(),
|
||||
name="ride-model-technical-spec-detail"),
|
||||
|
||||
path(
|
||||
"<slug:ride_model_slug>/technical-specs/",
|
||||
RideModelTechnicalSpecListCreateAPIView.as_view(),
|
||||
name="ride-model-technical-spec-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/technical-specs/<int:pk>/",
|
||||
RideModelTechnicalSpecDetailAPIView.as_view(),
|
||||
name="ride-model-technical-spec-detail",
|
||||
),
|
||||
# Photos - using slug-based lookup
|
||||
path("<slug:ride_model_slug>/photos/",
|
||||
RideModelPhotoListCreateAPIView.as_view(),
|
||||
name="ride-model-photo-list-create"),
|
||||
path("<slug:ride_model_slug>/photos/<int:pk>/",
|
||||
RideModelPhotoDetailAPIView.as_view(),
|
||||
name="ride-model-photo-detail"),
|
||||
path(
|
||||
"<slug:ride_model_slug>/photos/",
|
||||
RideModelPhotoListCreateAPIView.as_view(),
|
||||
name="ride-model-photo-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/photos/<int:pk>/",
|
||||
RideModelPhotoDetailAPIView.as_view(),
|
||||
name="ride-model-photo-detail",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@ This module implements comprehensive endpoints for ride model management:
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
@@ -36,25 +36,31 @@ from apps.api.v1.serializers.ride_models import (
|
||||
RideModelVariantOutputSerializer,
|
||||
RideModelVariantCreateInputSerializer,
|
||||
RideModelVariantUpdateInputSerializer,
|
||||
RideModelTechnicalSpecOutputSerializer,
|
||||
RideModelTechnicalSpecCreateInputSerializer,
|
||||
RideModelTechnicalSpecUpdateInputSerializer,
|
||||
RideModelPhotoOutputSerializer,
|
||||
RideModelPhotoCreateInputSerializer,
|
||||
RideModelPhotoUpdateInputSerializer,
|
||||
RideModelStatsOutputSerializer,
|
||||
)
|
||||
|
||||
# Attempt to import models; fall back gracefully if not present
|
||||
try:
|
||||
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
from apps.rides.models import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
from apps.rides.models.company import Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except ImportError:
|
||||
try:
|
||||
# Try alternative import path
|
||||
from apps.rides.models.rides import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
from apps.rides.models.rides import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
from apps.rides.models.rides import Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except ImportError:
|
||||
RideModel = None
|
||||
@@ -82,7 +88,10 @@ class RideModelListCreateAPIView(APIView):
|
||||
description="List ride models with comprehensive filtering and pagination.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
|
||||
@@ -97,10 +106,14 @@ class RideModelListCreateAPIView(APIView):
|
||||
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="target_market", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
name="target_market",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="is_discontinued", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL
|
||||
name="is_discontinued",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.BOOL,
|
||||
),
|
||||
],
|
||||
responses={200: RideModelListOutputSerializer(many=True)},
|
||||
@@ -123,7 +136,11 @@ class RideModelListCreateAPIView(APIView):
|
||||
except Company.DoesNotExist:
|
||||
raise NotFound("Manufacturer not found")
|
||||
|
||||
qs = RideModel.objects.filter(manufacturer=manufacturer).select_related("manufacturer").prefetch_related("photos")
|
||||
qs = (
|
||||
RideModel.objects.filter(manufacturer=manufacturer)
|
||||
.select_related("manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
filter_serializer = RideModelFilterInputSerializer(data=request.query_params)
|
||||
@@ -134,9 +151,9 @@ class RideModelListCreateAPIView(APIView):
|
||||
if filters.get("search"):
|
||||
search_term = filters["search"]
|
||||
qs = qs.filter(
|
||||
Q(name__icontains=search_term) |
|
||||
Q(description__icontains=search_term) |
|
||||
Q(manufacturer__name__icontains=search_term)
|
||||
Q(name__icontains=search_term)
|
||||
| Q(description__icontains=search_term)
|
||||
| Q(manufacturer__name__icontains=search_term)
|
||||
)
|
||||
|
||||
# Category filter
|
||||
@@ -160,10 +177,12 @@ class RideModelListCreateAPIView(APIView):
|
||||
# Year filters
|
||||
if filters.get("first_installation_year_min"):
|
||||
qs = qs.filter(
|
||||
first_installation_year__gte=filters["first_installation_year_min"])
|
||||
first_installation_year__gte=filters["first_installation_year_min"]
|
||||
)
|
||||
if filters.get("first_installation_year_max"):
|
||||
qs = qs.filter(
|
||||
first_installation_year__lte=filters["first_installation_year_max"])
|
||||
first_installation_year__lte=filters["first_installation_year_max"]
|
||||
)
|
||||
|
||||
# Installation count filter
|
||||
if filters.get("min_installations"):
|
||||
@@ -172,18 +191,22 @@ class RideModelListCreateAPIView(APIView):
|
||||
# Height filters
|
||||
if filters.get("min_height_ft"):
|
||||
qs = qs.filter(
|
||||
typical_height_range_max_ft__gte=filters["min_height_ft"])
|
||||
typical_height_range_max_ft__gte=filters["min_height_ft"]
|
||||
)
|
||||
if filters.get("max_height_ft"):
|
||||
qs = qs.filter(
|
||||
typical_height_range_min_ft__lte=filters["max_height_ft"])
|
||||
typical_height_range_min_ft__lte=filters["max_height_ft"]
|
||||
)
|
||||
|
||||
# Speed filters
|
||||
if filters.get("min_speed_mph"):
|
||||
qs = qs.filter(
|
||||
typical_speed_range_max_mph__gte=filters["min_speed_mph"])
|
||||
typical_speed_range_max_mph__gte=filters["min_speed_mph"]
|
||||
)
|
||||
if filters.get("max_speed_mph"):
|
||||
qs = qs.filter(
|
||||
typical_speed_range_min_mph__lte=filters["max_speed_mph"])
|
||||
typical_speed_range_min_mph__lte=filters["max_speed_mph"]
|
||||
)
|
||||
|
||||
# Ordering
|
||||
ordering = filters.get("ordering", "manufacturer__name,name")
|
||||
@@ -203,7 +226,10 @@ class RideModelListCreateAPIView(APIView):
|
||||
description="Create a new ride model for a specific manufacturer.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
request=RideModelCreateInputSerializer,
|
||||
@@ -262,13 +288,17 @@ class RideModelListCreateAPIView(APIView):
|
||||
class RideModelDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def _get_ride_model_or_404(self, manufacturer_slug: str, ride_model_slug: str) -> Any:
|
||||
def _get_ride_model_or_404(
|
||||
self, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Any:
|
||||
if not MODELS_AVAILABLE:
|
||||
raise NotFound("Ride model models not available")
|
||||
try:
|
||||
return RideModel.objects.select_related("manufacturer").prefetch_related(
|
||||
"photos", "variants", "technical_specs"
|
||||
).get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
|
||||
return (
|
||||
RideModel.objects.select_related("manufacturer")
|
||||
.prefetch_related("photos", "variants", "technical_specs")
|
||||
.get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
|
||||
)
|
||||
except RideModel.DoesNotExist:
|
||||
raise NotFound("Ride model not found")
|
||||
|
||||
@@ -277,16 +307,24 @@ class RideModelDetailAPIView(APIView):
|
||||
description="Get detailed information about a specific ride model.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={200: RideModelDetailOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
|
||||
def get(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
serializer = RideModelDetailOutputSerializer(
|
||||
ride_model, context={"request": request}
|
||||
@@ -298,17 +336,25 @@ class RideModelDetailAPIView(APIView):
|
||||
description="Update a ride model (partial update supported).",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
request=RideModelUpdateInputSerializer,
|
||||
responses={200: RideModelDetailOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def patch(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
|
||||
def patch(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
serializer_in = RideModelUpdateInputSerializer(data=request.data, partial=True)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
@@ -331,7 +377,9 @@ class RideModelDetailAPIView(APIView):
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
|
||||
def put(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
# Full replace - reuse patch behavior for simplicity
|
||||
return self.patch(request, manufacturer_slug, ride_model_slug)
|
||||
|
||||
@@ -340,16 +388,24 @@ class RideModelDetailAPIView(APIView):
|
||||
description="Delete a ride model.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={204: None},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def delete(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
|
||||
def delete(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
ride_model.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -366,7 +422,10 @@ class RideModelSearchAPIView(APIView):
|
||||
description="Search ride models by name, description, or manufacturer.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True
|
||||
name="q",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
)
|
||||
],
|
||||
responses={200: RideModelListOutputSerializer(many=True)},
|
||||
@@ -384,15 +443,15 @@ class RideModelSearchAPIView(APIView):
|
||||
"id": 1,
|
||||
"name": "Hyper Coaster",
|
||||
"manufacturer": {"name": "Bolliger & Mabillard"},
|
||||
"category": "RC"
|
||||
"category": "RC",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
qs = RideModel.objects.filter(
|
||||
Q(name__icontains=q) |
|
||||
Q(description__icontains=q) |
|
||||
Q(manufacturer__name__icontains=q)
|
||||
Q(name__icontains=q)
|
||||
| Q(description__icontains=q)
|
||||
| Q(manufacturer__name__icontains=q)
|
||||
).select_related("manufacturer")[:20]
|
||||
|
||||
results = [
|
||||
@@ -426,54 +485,65 @@ class RideModelFilterOptionsAPIView(APIView):
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return filter options for ride models."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response({
|
||||
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
|
||||
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
|
||||
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
|
||||
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
|
||||
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
|
||||
}
|
||||
)
|
||||
|
||||
# Get actual data from database
|
||||
manufacturers = Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"],
|
||||
ride_models__isnull=False
|
||||
).distinct().values("id", "name", "slug")
|
||||
manufacturers = (
|
||||
Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"], ride_models__isnull=False
|
||||
)
|
||||
.distinct()
|
||||
.values("id", "name", "slug")
|
||||
)
|
||||
|
||||
categories = RideModel.objects.exclude(category="").values_list(
|
||||
"category", flat=True
|
||||
).distinct()
|
||||
categories = (
|
||||
RideModel.objects.exclude(category="")
|
||||
.values_list("category", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
target_markets = RideModel.objects.exclude(target_market="").values_list(
|
||||
"target_market", flat=True
|
||||
).distinct()
|
||||
target_markets = (
|
||||
RideModel.objects.exclude(target_market="")
|
||||
.values_list("target_market", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return Response({
|
||||
"categories": [
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
"target_markets": [
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
"manufacturers": list(manufacturers),
|
||||
"ordering_options": [
|
||||
("name", "Name A-Z"),
|
||||
("-name", "Name Z-A"),
|
||||
("manufacturer__name", "Manufacturer A-Z"),
|
||||
("-manufacturer__name", "Manufacturer Z-A"),
|
||||
("first_installation_year", "Oldest First"),
|
||||
("-first_installation_year", "Newest First"),
|
||||
("total_installations", "Fewest Installations"),
|
||||
("-total_installations", "Most Installations"),
|
||||
],
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"categories": [
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
"target_markets": [
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
"manufacturers": list(manufacturers),
|
||||
"ordering_options": [
|
||||
("name", "Name A-Z"),
|
||||
("-name", "Name Z-A"),
|
||||
("manufacturer__name", "Manufacturer A-Z"),
|
||||
("-manufacturer__name", "Manufacturer Z-A"),
|
||||
("first_installation_year", "Oldest First"),
|
||||
("-first_installation_year", "Newest First"),
|
||||
("total_installations", "Fewest Installations"),
|
||||
("-total_installations", "Most Installations"),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL STATISTICS ===
|
||||
@@ -491,69 +561,84 @@ class RideModelStatsAPIView(APIView):
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get ride model statistics."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response({
|
||||
"total_models": 50,
|
||||
"total_installations": 500,
|
||||
"active_manufacturers": 15,
|
||||
"discontinued_models": 10,
|
||||
"by_category": {"RC": 30, "FR": 15, "WR": 5},
|
||||
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
|
||||
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
|
||||
"recent_models": 3,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"total_models": 50,
|
||||
"total_installations": 500,
|
||||
"active_manufacturers": 15,
|
||||
"discontinued_models": 10,
|
||||
"by_category": {"RC": 30, "FR": 15, "WR": 5},
|
||||
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
|
||||
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
|
||||
"recent_models": 3,
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate statistics
|
||||
total_models = RideModel.objects.count()
|
||||
total_installations = RideModel.objects.aggregate(
|
||||
total=Count('rides')
|
||||
)['total'] or 0
|
||||
total_installations = (
|
||||
RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
|
||||
)
|
||||
|
||||
active_manufacturers = Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"],
|
||||
ride_models__isnull=False
|
||||
).distinct().count()
|
||||
active_manufacturers = (
|
||||
Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"], ride_models__isnull=False
|
||||
)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
discontinued_models = RideModel.objects.filter(is_discontinued=True).count()
|
||||
|
||||
# Category breakdown
|
||||
by_category = {}
|
||||
category_counts = RideModel.objects.exclude(category="").values(
|
||||
"category"
|
||||
).annotate(count=Count("id"))
|
||||
category_counts = (
|
||||
RideModel.objects.exclude(category="")
|
||||
.values("category")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
for item in category_counts:
|
||||
by_category[item["category"]] = item["count"]
|
||||
|
||||
# Target market breakdown
|
||||
by_target_market = {}
|
||||
market_counts = RideModel.objects.exclude(target_market="").values(
|
||||
"target_market"
|
||||
).annotate(count=Count("id"))
|
||||
market_counts = (
|
||||
RideModel.objects.exclude(target_market="")
|
||||
.values("target_market")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
for item in market_counts:
|
||||
by_target_market[item["target_market"]] = item["count"]
|
||||
|
||||
# Manufacturer breakdown (top 10)
|
||||
by_manufacturer = {}
|
||||
manufacturer_counts = RideModel.objects.filter(
|
||||
manufacturer__isnull=False
|
||||
).values("manufacturer__name").annotate(count=Count("id")).order_by("-count")[:10]
|
||||
manufacturer_counts = (
|
||||
RideModel.objects.filter(manufacturer__isnull=False)
|
||||
.values("manufacturer__name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:10]
|
||||
)
|
||||
for item in manufacturer_counts:
|
||||
by_manufacturer[item["manufacturer__name"]] = item["count"]
|
||||
|
||||
# Recent models (last 30 days)
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
recent_models = RideModel.objects.filter(
|
||||
created_at__gte=thirty_days_ago).count()
|
||||
created_at__gte=thirty_days_ago
|
||||
).count()
|
||||
|
||||
return Response({
|
||||
"total_models": total_models,
|
||||
"total_installations": total_installations,
|
||||
"active_manufacturers": active_manufacturers,
|
||||
"discontinued_models": discontinued_models,
|
||||
"by_category": by_category,
|
||||
"by_target_market": by_target_market,
|
||||
"by_manufacturer": by_manufacturer,
|
||||
"recent_models": recent_models,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"total_models": total_models,
|
||||
"total_installations": total_installations,
|
||||
"active_manufacturers": active_manufacturers,
|
||||
"discontinued_models": discontinued_models,
|
||||
"by_category": by_category,
|
||||
"by_target_market": by_target_market,
|
||||
"by_manufacturer": by_manufacturer,
|
||||
"recent_models": recent_models,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL VARIANTS ===
|
||||
@@ -592,7 +677,7 @@ class RideModelVariantListCreateAPIView(APIView):
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Variants not available"},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -653,7 +738,8 @@ class RideModelVariantDetailAPIView(APIView):
|
||||
def patch(self, request: Request, ride_model_pk: int, pk: int) -> Response:
|
||||
variant = self._get_variant_or_404(ride_model_pk, pk)
|
||||
serializer_in = RideModelVariantUpdateInputSerializer(
|
||||
data=request.data, partial=True)
|
||||
data=request.data, partial=True
|
||||
)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
|
||||
for field, value in serializer_in.validated_data.items():
|
||||
@@ -677,25 +763,30 @@ class RideModelVariantDetailAPIView(APIView):
|
||||
# Note: Similar patterns would be implemented for RideModelTechnicalSpec and RideModelPhoto
|
||||
# For brevity, I'm including the class definitions but not the full implementations
|
||||
|
||||
|
||||
class RideModelTechnicalSpecListCreateAPIView(APIView):
|
||||
"""CRUD operations for ride model technical specifications."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variants...
|
||||
|
||||
|
||||
class RideModelTechnicalSpecDetailAPIView(APIView):
|
||||
"""CRUD operations for individual technical specifications."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variant detail...
|
||||
|
||||
|
||||
class RideModelPhotoListCreateAPIView(APIView):
|
||||
"""CRUD operations for ride model photos."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variants...
|
||||
|
||||
|
||||
class RideModelPhotoDetailAPIView(APIView):
|
||||
"""CRUD operations for individual ride model photos."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variant detail...
|
||||
|
||||
@@ -5,42 +5,46 @@ This module contains serializers for ride-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.rides.models import Ride, RidePhoto
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name='Ride Photo with Cloudflare Images',
|
||||
summary='Complete ride photo response',
|
||||
description='Example response showing all fields including Cloudflare Images URLs and variants',
|
||||
name="Ride Photo with Cloudflare Images",
|
||||
summary="Complete ride photo response",
|
||||
description="Example response showing all fields including Cloudflare Images URLs and variants",
|
||||
value={
|
||||
'id': 123,
|
||||
'image': 'https://imagedelivery.net/account-hash/abc123def456/public',
|
||||
'image_url': 'https://imagedelivery.net/account-hash/abc123def456/public',
|
||||
'image_variants': {
|
||||
'thumbnail': 'https://imagedelivery.net/account-hash/abc123def456/thumbnail',
|
||||
'medium': 'https://imagedelivery.net/account-hash/abc123def456/medium',
|
||||
'large': 'https://imagedelivery.net/account-hash/abc123def456/large',
|
||||
'public': 'https://imagedelivery.net/account-hash/abc123def456/public'
|
||||
"id": 123,
|
||||
"image": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
'caption': 'Amazing roller coaster photo',
|
||||
'alt_text': 'Steel roller coaster with multiple inversions',
|
||||
'is_primary': True,
|
||||
'is_approved': True,
|
||||
'photo_type': 'exterior',
|
||||
'created_at': '2023-01-01T12:00:00Z',
|
||||
'updated_at': '2023-01-01T12:00:00Z',
|
||||
'date_taken': '2023-01-01T10:00:00Z',
|
||||
'uploaded_by_username': 'photographer123',
|
||||
'file_size': 2048576,
|
||||
'dimensions': [1920, 1080],
|
||||
'ride_slug': 'steel-vengeance',
|
||||
'ride_name': 'Steel Vengeance',
|
||||
'park_slug': 'cedar-point',
|
||||
'park_name': 'Cedar Point'
|
||||
}
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"alt_text": "Steel roller coaster with multiple inversions",
|
||||
"is_primary": True,
|
||||
"is_approved": True,
|
||||
"photo_type": "exterior",
|
||||
"created_at": "2023-01-01T12:00:00Z",
|
||||
"updated_at": "2023-01-01T12:00:00Z",
|
||||
"date_taken": "2023-01-01T10:00:00Z",
|
||||
"uploaded_by_username": "photographer123",
|
||||
"file_size": 2048576,
|
||||
"dimensions": [1920, 1080],
|
||||
"ride_slug": "steel-vengeance",
|
||||
"ride_name": "Steel Vengeance",
|
||||
"park_slug": "cedar-point",
|
||||
"park_name": "Cedar Point",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -78,8 +82,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.URLField(
|
||||
help_text="Full URL to the Cloudflare Images asset",
|
||||
allow_null=True
|
||||
help_text="Full URL to the Cloudflare Images asset", allow_null=True
|
||||
)
|
||||
)
|
||||
def get_image_url(self, obj):
|
||||
@@ -91,7 +94,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
@extend_schema_field(
|
||||
serializers.DictField(
|
||||
child=serializers.URLField(),
|
||||
help_text="Available Cloudflare Images variants with their URLs"
|
||||
help_text="Available Cloudflare Images variants with their URLs",
|
||||
)
|
||||
)
|
||||
def get_image_variants(self, obj):
|
||||
@@ -101,10 +104,10 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Common variants for ride photos
|
||||
variants = {
|
||||
'thumbnail': f"{obj.image.url}/thumbnail",
|
||||
'medium': f"{obj.image.url}/medium",
|
||||
'large': f"{obj.image.url}/large",
|
||||
'public': f"{obj.image.url}/public"
|
||||
"thumbnail": f"{obj.image.url}/thumbnail",
|
||||
"medium": f"{obj.image.url}/medium",
|
||||
"large": f"{obj.image.url}/large",
|
||||
"public": f"{obj.image.url}/public",
|
||||
}
|
||||
return variants
|
||||
|
||||
|
||||
@@ -50,13 +50,18 @@ urlpatterns = [
|
||||
name="ride-search-suggestions",
|
||||
),
|
||||
# Ride model management endpoints - nested under rides/manufacturers
|
||||
path("manufacturers/<slug:manufacturer_slug>/",
|
||||
include("apps.api.v1.rides.manufacturers.urls")),
|
||||
path(
|
||||
"manufacturers/<slug:manufacturer_slug>/",
|
||||
include("apps.api.v1.rides.manufacturers.urls"),
|
||||
),
|
||||
# Detail and action endpoints
|
||||
path("<int:pk>/", RideDetailAPIView.as_view(), name="ride-detail"),
|
||||
# Ride image settings endpoint
|
||||
path("<int:pk>/image-settings/", RideImageSettingsAPIView.as_view(),
|
||||
name="ride-image-settings"),
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
RideImageSettingsAPIView.as_view(),
|
||||
name="ride-image-settings",
|
||||
),
|
||||
# Ride photo endpoints - domain-specific photo management
|
||||
path("<int:ride_pk>/photos/", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -21,7 +21,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.exceptions import NotFound
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
@@ -73,136 +73,202 @@ class RideListCreateAPIView(APIView):
|
||||
description="List rides with comprehensive filtering options including category, status, manufacturer, designer, ride model, and more.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Page number for pagination"
|
||||
name="page",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Page number for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Number of results per page (max 1000)"
|
||||
name="page_size",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Number of results per page (max 1000)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Search in ride names and descriptions"
|
||||
name="search",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Search in ride names and descriptions",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by park slug"
|
||||
name="park_slug",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by park slug",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="park_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by park ID"
|
||||
name="park_id",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by park ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR"
|
||||
name="category",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="status", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP"
|
||||
name="status",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="manufacturer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by manufacturer company ID"
|
||||
name="manufacturer_id",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by manufacturer company ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by manufacturer company slug"
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by manufacturer company slug",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="designer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by designer company ID"
|
||||
name="designer_id",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by designer company ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="designer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by designer company slug"
|
||||
name="designer_slug",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by designer company slug",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by specific ride model ID"
|
||||
name="ride_model_id",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by specific ride model ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter by ride model slug (requires manufacturer_slug)"
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride model slug (requires manufacturer_slug)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="roller_coaster_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)"
|
||||
name="roller_coaster_type",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="track_material", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)"
|
||||
name="track_material",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="launch_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)"
|
||||
name="launch_type",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter by minimum average rating (1-10)"
|
||||
name="min_rating",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter by minimum average rating (1-10)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter by maximum average rating (1-10)"
|
||||
name="max_rating",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter by maximum average rating (1-10)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by minimum height requirement in inches"
|
||||
name="min_height_requirement",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by minimum height requirement in inches",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by maximum height requirement in inches"
|
||||
name="max_height_requirement",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by maximum height requirement in inches",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by minimum hourly capacity"
|
||||
name="min_capacity",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by minimum hourly capacity",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by maximum hourly capacity"
|
||||
name="max_capacity",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by maximum hourly capacity",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by minimum height in feet"
|
||||
name="min_height_ft",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by minimum height in feet",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by maximum height in feet"
|
||||
name="max_height_ft",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by maximum height in feet",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by minimum speed in mph"
|
||||
name="min_speed_mph",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by minimum speed in mph",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by maximum speed in mph"
|
||||
name="max_speed_mph",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NUMBER,
|
||||
description="Filter roller coasters by maximum speed in mph",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter roller coasters by minimum number of inversions"
|
||||
name="min_inversions",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter roller coasters by minimum number of inversions",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter roller coasters by maximum number of inversions"
|
||||
name="max_inversions",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter roller coasters by maximum number of inversions",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="has_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL,
|
||||
description="Filter roller coasters that have inversions (true) or don't have inversions (false)"
|
||||
name="has_inversions",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.BOOL,
|
||||
description="Filter roller coasters that have inversions (true) or don't have inversions (false)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by opening year"
|
||||
name="opening_year",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by opening year",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by minimum opening year"
|
||||
name="min_opening_year",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by minimum opening year",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="max_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description="Filter by maximum opening year"
|
||||
name="max_opening_year",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Filter by maximum opening year",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph"
|
||||
name="ordering",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph",
|
||||
),
|
||||
],
|
||||
responses={200: RideListOutputSerializer(many=True)},
|
||||
@@ -220,17 +286,25 @@ class RideListCreateAPIView(APIView):
|
||||
)
|
||||
|
||||
# Start with base queryset with optimized joins
|
||||
qs = Ride.objects.all().select_related(
|
||||
"park", "manufacturer", "designer", "ride_model", "ride_model__manufacturer"
|
||||
).prefetch_related("coaster_stats") # type: ignore
|
||||
qs = (
|
||||
Ride.objects.all()
|
||||
.select_related(
|
||||
"park",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"ride_model__manufacturer",
|
||||
)
|
||||
.prefetch_related("coaster_stats")
|
||||
) # type: ignore
|
||||
|
||||
# Text search
|
||||
search = request.query_params.get("search")
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
models.Q(name__icontains=search) |
|
||||
models.Q(description__icontains=search) |
|
||||
models.Q(park__name__icontains=search)
|
||||
models.Q(name__icontains=search)
|
||||
| models.Q(description__icontains=search)
|
||||
| models.Q(park__name__icontains=search)
|
||||
)
|
||||
|
||||
# Park filters
|
||||
@@ -292,7 +366,7 @@ class RideListCreateAPIView(APIView):
|
||||
if ride_model_slug and manufacturer_slug_for_model:
|
||||
qs = qs.filter(
|
||||
ride_model__slug=ride_model_slug,
|
||||
ride_model__manufacturer__slug=manufacturer_slug_for_model
|
||||
ride_model__manufacturer__slug=manufacturer_slug_for_model,
|
||||
)
|
||||
|
||||
# Rating filters
|
||||
@@ -422,24 +496,36 @@ class RideListCreateAPIView(APIView):
|
||||
|
||||
has_inversions = request.query_params.get("has_inversions")
|
||||
if has_inversions is not None:
|
||||
if has_inversions.lower() in ['true', '1', 'yes']:
|
||||
if has_inversions.lower() in ["true", "1", "yes"]:
|
||||
qs = qs.filter(coaster_stats__inversions__gt=0)
|
||||
elif has_inversions.lower() in ['false', '0', 'no']:
|
||||
elif has_inversions.lower() in ["false", "0", "no"]:
|
||||
qs = qs.filter(coaster_stats__inversions=0)
|
||||
|
||||
# Ordering
|
||||
ordering = request.query_params.get("ordering", "name")
|
||||
valid_orderings = [
|
||||
"name", "-name", "opening_date", "-opening_date",
|
||||
"average_rating", "-average_rating", "capacity_per_hour", "-capacity_per_hour",
|
||||
"created_at", "-created_at", "height_ft", "-height_ft", "speed_mph", "-speed_mph"
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"average_rating",
|
||||
"-average_rating",
|
||||
"capacity_per_hour",
|
||||
"-capacity_per_hour",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"height_ft",
|
||||
"-height_ft",
|
||||
"speed_mph",
|
||||
"-speed_mph",
|
||||
]
|
||||
|
||||
if ordering in valid_orderings:
|
||||
if ordering in ["height_ft", "-height_ft", "speed_mph", "-speed_mph"]:
|
||||
# For coaster stats ordering, we need to join and order by the stats
|
||||
ordering_field = ordering.replace("height_ft", "coaster_stats__height_ft").replace(
|
||||
"speed_mph", "coaster_stats__speed_mph")
|
||||
ordering_field = ordering.replace(
|
||||
"height_ft", "coaster_stats__height_ft"
|
||||
).replace("speed_mph", "coaster_stats__speed_mph")
|
||||
qs = qs.order_by(ordering_field)
|
||||
else:
|
||||
qs = qs.order_by(ordering)
|
||||
@@ -592,16 +678,24 @@ class FilterOptionsAPIView(APIView):
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
{"value": "opening_date",
|
||||
"label": "Opening Date (Oldest First)"},
|
||||
{"value": "-opening_date",
|
||||
"label": "Opening Date (Newest First)"},
|
||||
{
|
||||
"value": "opening_date",
|
||||
"label": "Opening Date (Oldest First)",
|
||||
},
|
||||
{
|
||||
"value": "-opening_date",
|
||||
"label": "Opening Date (Newest First)",
|
||||
},
|
||||
{"value": "average_rating", "label": "Rating (Lowest First)"},
|
||||
{"value": "-average_rating", "label": "Rating (Highest First)"},
|
||||
{"value": "capacity_per_hour",
|
||||
"label": "Capacity (Lowest First)"},
|
||||
{"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)"},
|
||||
{
|
||||
"value": "capacity_per_hour",
|
||||
"label": "Capacity (Lowest First)",
|
||||
},
|
||||
{
|
||||
"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)",
|
||||
},
|
||||
{"value": "height_ft", "label": "Height (Shortest First)"},
|
||||
{"value": "-height_ft", "label": "Height (Tallest First)"},
|
||||
{"value": "speed_mph", "label": "Speed (Slowest First)"},
|
||||
@@ -611,16 +705,39 @@ class FilterOptionsAPIView(APIView):
|
||||
],
|
||||
"filter_ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1},
|
||||
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
|
||||
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
|
||||
"height_requirement": {
|
||||
"min": 30,
|
||||
"max": 90,
|
||||
"step": 1,
|
||||
"unit": "inches",
|
||||
},
|
||||
"capacity": {
|
||||
"min": 0,
|
||||
"max": 5000,
|
||||
"step": 50,
|
||||
"unit": "riders/hour",
|
||||
},
|
||||
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
|
||||
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
|
||||
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
|
||||
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
|
||||
"inversions": {
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"unit": "inversions",
|
||||
},
|
||||
"opening_year": {
|
||||
"min": 1800,
|
||||
"max": 2030,
|
||||
"step": 1,
|
||||
"unit": "year",
|
||||
},
|
||||
},
|
||||
"boolean_filters": [
|
||||
{"key": "has_inversions", "label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions"},
|
||||
{
|
||||
"key": "has_inversions",
|
||||
"label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions",
|
||||
},
|
||||
],
|
||||
}
|
||||
return Response(data)
|
||||
@@ -682,8 +799,10 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "average_rating", "label": "Rating (Lowest First)"},
|
||||
{"value": "-average_rating", "label": "Rating (Highest First)"},
|
||||
{"value": "capacity_per_hour", "label": "Capacity (Lowest First)"},
|
||||
{"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)"},
|
||||
{
|
||||
"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)",
|
||||
},
|
||||
{"value": "height_ft", "label": "Height (Shortest First)"},
|
||||
{"value": "-height_ft", "label": "Height (Tallest First)"},
|
||||
{"value": "speed_mph", "label": "Speed (Slowest First)"},
|
||||
@@ -693,16 +812,39 @@ class FilterOptionsAPIView(APIView):
|
||||
],
|
||||
"filter_ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1},
|
||||
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
|
||||
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
|
||||
"height_requirement": {
|
||||
"min": 30,
|
||||
"max": 90,
|
||||
"step": 1,
|
||||
"unit": "inches",
|
||||
},
|
||||
"capacity": {
|
||||
"min": 0,
|
||||
"max": 5000,
|
||||
"step": 50,
|
||||
"unit": "riders/hour",
|
||||
},
|
||||
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
|
||||
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
|
||||
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
|
||||
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
|
||||
"inversions": {
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"unit": "inversions",
|
||||
},
|
||||
"opening_year": {
|
||||
"min": 1800,
|
||||
"max": 2030,
|
||||
"step": 1,
|
||||
"unit": "year",
|
||||
},
|
||||
},
|
||||
"boolean_filters": [
|
||||
{"key": "has_inversions", "label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions"},
|
||||
{
|
||||
"key": "has_inversions",
|
||||
"label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -853,7 +995,8 @@ class RideImageSettingsAPIView(APIView):
|
||||
|
||||
# Return updated ride data
|
||||
output_serializer = RideDetailOutputSerializer(
|
||||
ride, context={"request": request})
|
||||
ride, context={"request": request}
|
||||
)
|
||||
return Response(output_serializer.data)
|
||||
|
||||
|
||||
|
||||
@@ -264,7 +264,6 @@ __all__ = [
|
||||
"LocationOutputSerializer",
|
||||
"CompanyOutputSerializer",
|
||||
"UserModel",
|
||||
|
||||
# Parks exports
|
||||
"ParkListOutputSerializer",
|
||||
"ParkDetailOutputSerializer",
|
||||
@@ -279,7 +278,6 @@ __all__ = [
|
||||
"ParkLocationUpdateInputSerializer",
|
||||
"ParkSuggestionSerializer",
|
||||
"ParkSuggestionOutputSerializer",
|
||||
|
||||
# Companies exports
|
||||
"CompanyDetailOutputSerializer",
|
||||
"CompanyCreateInputSerializer",
|
||||
@@ -287,7 +285,6 @@ __all__ = [
|
||||
"RideModelDetailOutputSerializer",
|
||||
"RideModelCreateInputSerializer",
|
||||
"RideModelUpdateInputSerializer",
|
||||
|
||||
# Rides exports
|
||||
"RideParkOutputSerializer",
|
||||
"RideModelOutputSerializer",
|
||||
@@ -305,7 +302,6 @@ __all__ = [
|
||||
"RideReviewOutputSerializer",
|
||||
"RideReviewCreateInputSerializer",
|
||||
"RideReviewUpdateInputSerializer",
|
||||
|
||||
# Services exports
|
||||
"HealthCheckOutputSerializer",
|
||||
"PerformanceMetricsOutputSerializer",
|
||||
|
||||
873
backend/apps/api/v1/serializers/accounts.py
Normal file
873
backend/apps/api/v1/serializers/accounts.py
Normal file
@@ -0,0 +1,873 @@
|
||||
"""
|
||||
User accounts and settings serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to user account management,
|
||||
profile settings, preferences, privacy, notifications, and security.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.accounts.models import (
|
||||
User,
|
||||
UserProfile,
|
||||
TopList,
|
||||
UserNotification,
|
||||
NotificationPreference,
|
||||
)
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
# === USER PROFILE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Profile Example",
|
||||
summary="Complete user profile",
|
||||
description="Full user profile with all fields",
|
||||
value={
|
||||
"user_id": "1234",
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"theme_preference": "dark",
|
||||
"profile": {
|
||||
"profile_id": "5678",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar": "https://example.com/avatars/user.jpg",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Love roller coasters and theme parks!",
|
||||
"twitter": "https://twitter.com/thrillseeker",
|
||||
"instagram": "https://instagram.com/thrillseeker",
|
||||
"youtube": "https://youtube.com/thrillseeker",
|
||||
"discord": "thrillseeker#1234",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user profile data."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
avatar_variants = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"profile_id",
|
||||
"display_name",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"avatar_variants",
|
||||
"pronouns",
|
||||
"bio",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"youtube",
|
||||
"discord",
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
]
|
||||
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the avatar URL with fallback to default letter-based avatar."""
|
||||
return obj.get_avatar_url()
|
||||
|
||||
def get_avatar_variants(self, obj):
|
||||
"""Get avatar variants for different use cases."""
|
||||
return obj.get_avatar_variants()
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display name uniqueness - now checks User model first."""
|
||||
user = self.context["request"].user
|
||||
# Check User model for display_name uniqueness (primary location)
|
||||
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
# Also check UserProfile for backward compatibility during transition
|
||||
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Complete User Example",
|
||||
summary="Complete user with profile",
|
||||
description="Full user object with embedded profile",
|
||||
value={
|
||||
"user_id": "1234",
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"theme_preference": "dark",
|
||||
"profile": {
|
||||
"profile_id": "5678",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar": "https://example.com/avatars/user.jpg",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Love roller coasters and theme parks!",
|
||||
"twitter": "https://twitter.com/thrillseeker",
|
||||
"instagram": "https://instagram.com/thrillseeker",
|
||||
"youtube": "https://youtube.com/thrillseeker",
|
||||
"discord": "thrillseeker#1234",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class CompleteUserSerializer(serializers.ModelSerializer):
|
||||
"""Complete user serializer with profile data."""
|
||||
|
||||
profile = UserProfileSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"user_id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
"role",
|
||||
"theme_preference",
|
||||
"profile",
|
||||
]
|
||||
read_only_fields = ["user_id", "date_joined", "role"]
|
||||
|
||||
|
||||
# === USER SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Preferences Example",
|
||||
summary="User preferences and settings",
|
||||
description="User's preference settings",
|
||||
value={
|
||||
"theme_preference": "dark",
|
||||
"email_notifications": True,
|
||||
"push_notifications": False,
|
||||
"privacy_level": "public",
|
||||
"show_email": False,
|
||||
"show_real_name": True,
|
||||
"show_statistics": True,
|
||||
"allow_friend_requests": True,
|
||||
"allow_messages": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserPreferencesSerializer(serializers.Serializer):
|
||||
"""Serializer for user preferences and settings."""
|
||||
|
||||
theme_preference = serializers.ChoiceField(
|
||||
choices=User.ThemePreference.choices, help_text="User's theme preference"
|
||||
)
|
||||
email_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Whether to receive email notifications"
|
||||
)
|
||||
push_notifications = serializers.BooleanField(
|
||||
default=False, help_text="Whether to receive push notifications"
|
||||
)
|
||||
privacy_level = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
help_text="Profile visibility level",
|
||||
)
|
||||
show_email = serializers.BooleanField(
|
||||
default=False, help_text="Whether to show email on profile"
|
||||
)
|
||||
show_real_name = serializers.BooleanField(
|
||||
default=True, help_text="Whether to show real name on profile"
|
||||
)
|
||||
show_statistics = serializers.BooleanField(
|
||||
default=True, help_text="Whether to show ride statistics on profile"
|
||||
)
|
||||
allow_friend_requests = serializers.BooleanField(
|
||||
default=True, help_text="Whether to allow friend requests"
|
||||
)
|
||||
allow_messages = serializers.BooleanField(
|
||||
default=True, help_text="Whether to allow direct messages"
|
||||
)
|
||||
|
||||
|
||||
# === NOTIFICATION SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Notification Settings Example",
|
||||
summary="User notification preferences",
|
||||
description="Detailed notification settings",
|
||||
value={
|
||||
"email_notifications": {
|
||||
"new_reviews": True,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
"weekly_digest": False,
|
||||
"new_features": True,
|
||||
"security_alerts": True,
|
||||
},
|
||||
"push_notifications": {
|
||||
"new_reviews": False,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
},
|
||||
"in_app_notifications": {
|
||||
"new_reviews": True,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
"system_announcements": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class NotificationSettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for detailed notification settings."""
|
||||
|
||||
class EmailNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=True)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
weekly_digest = serializers.BooleanField(default=False)
|
||||
new_features = serializers.BooleanField(default=True)
|
||||
security_alerts = serializers.BooleanField(default=True)
|
||||
|
||||
class PushNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=False)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
|
||||
class InAppNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=True)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
system_announcements = serializers.BooleanField(default=True)
|
||||
|
||||
email_notifications = EmailNotificationsSerializer()
|
||||
push_notifications = PushNotificationsSerializer()
|
||||
in_app_notifications = InAppNotificationsSerializer()
|
||||
|
||||
|
||||
# === PRIVACY SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Privacy Settings Example",
|
||||
summary="User privacy settings",
|
||||
description="Detailed privacy and visibility settings",
|
||||
value={
|
||||
"profile_visibility": "public",
|
||||
"show_email": False,
|
||||
"show_real_name": True,
|
||||
"show_join_date": True,
|
||||
"show_statistics": True,
|
||||
"show_reviews": True,
|
||||
"show_photos": True,
|
||||
"show_top_lists": True,
|
||||
"allow_friend_requests": True,
|
||||
"allow_messages": True,
|
||||
"allow_profile_comments": False,
|
||||
"search_visibility": True,
|
||||
"activity_visibility": "friends",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PrivacySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for privacy and visibility settings."""
|
||||
|
||||
profile_visibility = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
help_text="Overall profile visibility",
|
||||
)
|
||||
show_email = serializers.BooleanField(
|
||||
default=False, help_text="Show email address on profile"
|
||||
)
|
||||
show_real_name = serializers.BooleanField(
|
||||
default=True, help_text="Show real name on profile"
|
||||
)
|
||||
show_join_date = serializers.BooleanField(
|
||||
default=True, help_text="Show join date on profile"
|
||||
)
|
||||
show_statistics = serializers.BooleanField(
|
||||
default=True, help_text="Show ride statistics on profile"
|
||||
)
|
||||
show_reviews = serializers.BooleanField(
|
||||
default=True, help_text="Show reviews on profile"
|
||||
)
|
||||
show_photos = serializers.BooleanField(
|
||||
default=True, help_text="Show uploaded photos on profile"
|
||||
)
|
||||
show_top_lists = serializers.BooleanField(
|
||||
default=True, help_text="Show top lists on profile"
|
||||
)
|
||||
allow_friend_requests = serializers.BooleanField(
|
||||
default=True, help_text="Allow others to send friend requests"
|
||||
)
|
||||
allow_messages = serializers.BooleanField(
|
||||
default=True, help_text="Allow others to send direct messages"
|
||||
)
|
||||
allow_profile_comments = serializers.BooleanField(
|
||||
default=False, help_text="Allow others to comment on profile"
|
||||
)
|
||||
search_visibility = serializers.BooleanField(
|
||||
default=True, help_text="Allow profile to appear in search results"
|
||||
)
|
||||
activity_visibility = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
help_text="Who can see your activity feed",
|
||||
)
|
||||
|
||||
|
||||
# === SECURITY SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Security Settings Example",
|
||||
summary="User security settings",
|
||||
description="Account security and authentication settings",
|
||||
value={
|
||||
"two_factor_enabled": False,
|
||||
"login_notifications": True,
|
||||
"session_timeout": 30,
|
||||
"require_password_change": False,
|
||||
"last_password_change": "2024-01-01T00:00:00Z",
|
||||
"active_sessions": 2,
|
||||
"login_history_retention": 90,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SecuritySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for security settings."""
|
||||
|
||||
two_factor_enabled = serializers.BooleanField(
|
||||
default=False, help_text="Whether two-factor authentication is enabled"
|
||||
)
|
||||
login_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Send notifications for new logins"
|
||||
)
|
||||
session_timeout = serializers.IntegerField(
|
||||
default=30, min_value=5, max_value=180, help_text="Session timeout in days"
|
||||
)
|
||||
require_password_change = serializers.BooleanField(
|
||||
default=False, help_text="Whether password change is required"
|
||||
)
|
||||
last_password_change = serializers.DateTimeField(
|
||||
read_only=True, help_text="When password was last changed"
|
||||
)
|
||||
active_sessions = serializers.IntegerField(
|
||||
read_only=True, help_text="Number of active sessions"
|
||||
)
|
||||
login_history_retention = serializers.IntegerField(
|
||||
default=90,
|
||||
min_value=30,
|
||||
max_value=365,
|
||||
help_text="How long to keep login history (days)",
|
||||
)
|
||||
|
||||
|
||||
# === USER STATISTICS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Statistics Example",
|
||||
summary="User activity statistics",
|
||||
description="Comprehensive user activity and contribution statistics",
|
||||
value={
|
||||
"ride_credits": {
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
"total_credits": 307,
|
||||
},
|
||||
"contributions": {
|
||||
"park_reviews": 25,
|
||||
"ride_reviews": 87,
|
||||
"photos_uploaded": 156,
|
||||
"top_lists_created": 8,
|
||||
"helpful_votes_received": 342,
|
||||
},
|
||||
"activity": {
|
||||
"days_active": 45,
|
||||
"last_active": "2024-01-15T10:30:00Z",
|
||||
"average_review_rating": 4.2,
|
||||
"most_reviewed_park": "Cedar Point",
|
||||
"favorite_ride_type": "Roller Coaster",
|
||||
},
|
||||
"achievements": {
|
||||
"first_review": True,
|
||||
"photo_contributor": True,
|
||||
"top_reviewer": False,
|
||||
"park_explorer": True,
|
||||
"coaster_enthusiast": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserStatisticsSerializer(serializers.Serializer):
|
||||
"""Serializer for user statistics and achievements."""
|
||||
|
||||
class RideCreditsSerializer(serializers.Serializer):
|
||||
coaster_credits = serializers.IntegerField()
|
||||
dark_ride_credits = serializers.IntegerField()
|
||||
flat_ride_credits = serializers.IntegerField()
|
||||
water_ride_credits = serializers.IntegerField()
|
||||
total_credits = serializers.IntegerField()
|
||||
|
||||
class ContributionsSerializer(serializers.Serializer):
|
||||
park_reviews = serializers.IntegerField()
|
||||
ride_reviews = serializers.IntegerField()
|
||||
photos_uploaded = serializers.IntegerField()
|
||||
top_lists_created = serializers.IntegerField()
|
||||
helpful_votes_received = serializers.IntegerField()
|
||||
|
||||
class ActivitySerializer(serializers.Serializer):
|
||||
days_active = serializers.IntegerField()
|
||||
last_active = serializers.DateTimeField()
|
||||
average_review_rating = serializers.FloatField()
|
||||
most_reviewed_park = serializers.CharField()
|
||||
favorite_ride_type = serializers.CharField()
|
||||
|
||||
class AchievementsSerializer(serializers.Serializer):
|
||||
first_review = serializers.BooleanField()
|
||||
photo_contributor = serializers.BooleanField()
|
||||
top_reviewer = serializers.BooleanField()
|
||||
park_explorer = serializers.BooleanField()
|
||||
coaster_enthusiast = serializers.BooleanField()
|
||||
|
||||
ride_credits = RideCreditsSerializer()
|
||||
contributions = ContributionsSerializer()
|
||||
activity = ActivitySerializer()
|
||||
achievements = AchievementsSerializer()
|
||||
|
||||
|
||||
# === TOP LISTS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Top List Example",
|
||||
summary="User's top list",
|
||||
description="A user's ranked list of rides or parks",
|
||||
value={
|
||||
"id": 1,
|
||||
"title": "My Top 10 Roller Coasters",
|
||||
"category": "RC",
|
||||
"description": "My favorite roller coasters from around the world",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"items_count": 10,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class TopListSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user's top lists."""
|
||||
|
||||
items_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"category",
|
||||
"description",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"items_count",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_items_count(self, obj):
|
||||
"""Get the number of items in the list."""
|
||||
return obj.items.count()
|
||||
|
||||
|
||||
# === ACCOUNT UPDATE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Account Update Example",
|
||||
summary="Update account information",
|
||||
description="Update basic account information",
|
||||
value={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "newemail@example.com",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class AccountUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating account information."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
]
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email uniqueness."""
|
||||
user = self.context["request"].user
|
||||
if User.objects.filter(email=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Email already in use")
|
||||
return value
|
||||
|
||||
|
||||
# === PROFILE UPDATE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Profile Update Example",
|
||||
summary="Update profile information",
|
||||
description="Update profile information and social links",
|
||||
value={
|
||||
"display_name": "New Display Name",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Updated bio text",
|
||||
"twitter": "https://twitter.com/newhandle",
|
||||
"instagram": "",
|
||||
"youtube": "https://youtube.com/newchannel",
|
||||
"discord": "newhandle#5678",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating profile information."""
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"display_name",
|
||||
"pronouns",
|
||||
"bio",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"youtube",
|
||||
"discord",
|
||||
]
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display name uniqueness - now checks User model first."""
|
||||
user = self.context["request"].user
|
||||
# Check User model for display_name uniqueness (primary location)
|
||||
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
# Also check UserProfile for backward compatibility during transition
|
||||
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
return value
|
||||
|
||||
|
||||
# === THEME PREFERENCE SERIALIZER ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Theme Update Example",
|
||||
summary="Update theme preference",
|
||||
description="Update user's theme preference",
|
||||
value={
|
||||
"theme_preference": "dark",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ThemePreferenceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating theme preference."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["theme_preference"]
|
||||
|
||||
|
||||
# === NOTIFICATION SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Notification Example",
|
||||
summary="User notification",
|
||||
description="A notification sent to a user",
|
||||
value={
|
||||
"id": 1,
|
||||
"notification_type": "submission_approved",
|
||||
"title": "Your submission has been approved!",
|
||||
"message": "Your photo submission for Cedar Point has been approved and is now live on the site.",
|
||||
"priority": "normal",
|
||||
"is_read": False,
|
||||
"read_at": None,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"expires_at": None,
|
||||
"extra_data": {"submission_id": 123, "park_name": "Cedar Point"},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserNotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user notifications."""
|
||||
|
||||
class Meta:
|
||||
model = UserNotification
|
||||
fields = [
|
||||
"id",
|
||||
"notification_type",
|
||||
"title",
|
||||
"message",
|
||||
"priority",
|
||||
"is_read",
|
||||
"read_at",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"extra_data",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"notification_type",
|
||||
"title",
|
||||
"message",
|
||||
"priority",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"extra_data",
|
||||
]
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Notification Preferences Example",
|
||||
summary="User notification preferences",
|
||||
description="Comprehensive notification preferences for all channels",
|
||||
value={
|
||||
"submission_approved_email": True,
|
||||
"submission_approved_push": True,
|
||||
"submission_approved_inapp": True,
|
||||
"submission_rejected_email": True,
|
||||
"submission_rejected_push": True,
|
||||
"submission_rejected_inapp": True,
|
||||
"submission_pending_email": False,
|
||||
"submission_pending_push": False,
|
||||
"submission_pending_inapp": True,
|
||||
"review_reply_email": True,
|
||||
"review_reply_push": True,
|
||||
"review_reply_inapp": True,
|
||||
"review_helpful_email": False,
|
||||
"review_helpful_push": True,
|
||||
"review_helpful_inapp": True,
|
||||
"friend_request_email": True,
|
||||
"friend_request_push": True,
|
||||
"friend_request_inapp": True,
|
||||
"friend_accepted_email": False,
|
||||
"friend_accepted_push": True,
|
||||
"friend_accepted_inapp": True,
|
||||
"message_received_email": True,
|
||||
"message_received_push": True,
|
||||
"message_received_inapp": True,
|
||||
"system_announcement_email": True,
|
||||
"system_announcement_push": False,
|
||||
"system_announcement_inapp": True,
|
||||
"account_security_email": True,
|
||||
"account_security_push": True,
|
||||
"account_security_inapp": True,
|
||||
"feature_update_email": True,
|
||||
"feature_update_push": False,
|
||||
"feature_update_inapp": True,
|
||||
"achievement_unlocked_email": False,
|
||||
"achievement_unlocked_push": True,
|
||||
"achievement_unlocked_inapp": True,
|
||||
"milestone_reached_email": False,
|
||||
"milestone_reached_push": True,
|
||||
"milestone_reached_inapp": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class NotificationPreferenceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notification preferences."""
|
||||
|
||||
class Meta:
|
||||
model = NotificationPreference
|
||||
fields = [
|
||||
# Submission notifications
|
||||
"submission_approved_email",
|
||||
"submission_approved_push",
|
||||
"submission_approved_inapp",
|
||||
"submission_rejected_email",
|
||||
"submission_rejected_push",
|
||||
"submission_rejected_inapp",
|
||||
"submission_pending_email",
|
||||
"submission_pending_push",
|
||||
"submission_pending_inapp",
|
||||
# Review notifications
|
||||
"review_reply_email",
|
||||
"review_reply_push",
|
||||
"review_reply_inapp",
|
||||
"review_helpful_email",
|
||||
"review_helpful_push",
|
||||
"review_helpful_inapp",
|
||||
# Social notifications
|
||||
"friend_request_email",
|
||||
"friend_request_push",
|
||||
"friend_request_inapp",
|
||||
"friend_accepted_email",
|
||||
"friend_accepted_push",
|
||||
"friend_accepted_inapp",
|
||||
"message_received_email",
|
||||
"message_received_push",
|
||||
"message_received_inapp",
|
||||
# System notifications
|
||||
"system_announcement_email",
|
||||
"system_announcement_push",
|
||||
"system_announcement_inapp",
|
||||
"account_security_email",
|
||||
"account_security_push",
|
||||
"account_security_inapp",
|
||||
"feature_update_email",
|
||||
"feature_update_push",
|
||||
"feature_update_inapp",
|
||||
# Achievement notifications
|
||||
"achievement_unlocked_email",
|
||||
"achievement_unlocked_push",
|
||||
"achievement_unlocked_inapp",
|
||||
"milestone_reached_email",
|
||||
"milestone_reached_push",
|
||||
"milestone_reached_inapp",
|
||||
]
|
||||
|
||||
|
||||
# === NOTIFICATION ACTIONS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Mark Notifications Read Example",
|
||||
summary="Mark notifications as read",
|
||||
description="Mark specific notifications as read",
|
||||
value={"notification_ids": [1, 2, 3, 4, 5]},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MarkNotificationsReadSerializer(serializers.Serializer):
|
||||
"""Serializer for marking notifications as read."""
|
||||
|
||||
notification_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of notification IDs to mark as read",
|
||||
)
|
||||
|
||||
def validate_notification_ids(self, value):
|
||||
"""Validate that all notification IDs belong to the requesting user."""
|
||||
user = self.context["request"].user
|
||||
valid_ids = UserNotification.objects.filter(
|
||||
id__in=value, user=user
|
||||
).values_list("id", flat=True)
|
||||
|
||||
invalid_ids = set(value) - set(valid_ids)
|
||||
if invalid_ids:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid notification IDs: {list(invalid_ids)}"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Avatar Upload Example",
|
||||
summary="Upload user avatar",
|
||||
description="Upload a new avatar image",
|
||||
value={"avatar": "base64_encoded_image_data_or_file_upload"},
|
||||
)
|
||||
]
|
||||
)
|
||||
class AvatarUploadSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for uploading user avatar."""
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ["avatar"]
|
||||
|
||||
def validate_avatar(self, value):
|
||||
"""Validate avatar file."""
|
||||
if value:
|
||||
# Add any avatar-specific validation here
|
||||
# The CloudflareImagesField will handle the upload
|
||||
pass
|
||||
return value
|
||||
@@ -64,7 +64,7 @@ class MapLocationSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get location information."""
|
||||
if hasattr(obj, 'location') and obj.location:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
@@ -76,16 +76,20 @@ class MapLocationSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_stats(self, obj) -> dict:
|
||||
"""Get relevant statistics based on object type."""
|
||||
if obj._meta.model_name == 'park':
|
||||
if obj._meta.model_name == "park":
|
||||
return {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
}
|
||||
elif obj._meta.model_name == 'ride':
|
||||
elif obj._meta.model_name == "ride":
|
||||
return {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name if obj.park else None,
|
||||
}
|
||||
return {}
|
||||
@@ -210,7 +214,7 @@ class MapSearchResultSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get location information."""
|
||||
if hasattr(obj, 'location') and obj.location:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
@@ -318,7 +322,7 @@ class MapLocationDetailSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get detailed location information."""
|
||||
if hasattr(obj, 'location') and obj.location:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"street_address": obj.location.street_address,
|
||||
"city": obj.location.city,
|
||||
@@ -332,20 +336,28 @@ class MapLocationDetailSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_stats(self, obj) -> dict:
|
||||
"""Get detailed statistics based on object type."""
|
||||
if obj._meta.model_name == 'park':
|
||||
if obj._meta.model_name == "park":
|
||||
return {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"size_acres": float(obj.size_acres) if obj.size_acres else None,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
}
|
||||
elif obj._meta.model_name == 'ride':
|
||||
elif obj._meta.model_name == "ride":
|
||||
return {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name if obj.park else None,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
|
||||
}
|
||||
return {}
|
||||
@@ -370,13 +382,14 @@ class MapBoundsInputSerializer(serializers.Serializer):
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that bounds make geographic sense."""
|
||||
if attrs['north'] <= attrs['south']:
|
||||
if attrs["north"] <= attrs["south"]:
|
||||
raise serializers.ValidationError(
|
||||
"North bound must be greater than south bound")
|
||||
"North bound must be greater than south bound"
|
||||
)
|
||||
|
||||
# Handle longitude wraparound (e.g., crossing the international date line)
|
||||
# For now, we'll require west < east for simplicity
|
||||
if attrs['west'] >= attrs['east']:
|
||||
if attrs["west"] >= attrs["east"]:
|
||||
raise serializers.ValidationError("West bound must be less than east bound")
|
||||
|
||||
return attrs
|
||||
@@ -396,8 +409,8 @@ class MapSearchInputSerializer(serializers.Serializer):
|
||||
if not value:
|
||||
return []
|
||||
|
||||
valid_types = ['park', 'ride']
|
||||
types = [t.strip().lower() for t in value.split(',')]
|
||||
valid_types = ["park", "ride"]
|
||||
types = [t.strip().lower() for t in value.split(",")]
|
||||
|
||||
for location_type in types:
|
||||
if location_type not in valid_types:
|
||||
|
||||
@@ -113,10 +113,10 @@ class ParkListOutputSerializer(serializers.Serializer):
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public",
|
||||
},
|
||||
"caption": "Beautiful park entrance",
|
||||
"is_primary": True
|
||||
"is_primary": True,
|
||||
}
|
||||
],
|
||||
"primary_photo": {
|
||||
@@ -126,10 +126,10 @@ class ParkListOutputSerializer(serializers.Serializer):
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public",
|
||||
},
|
||||
"caption": "Beautiful park entrance"
|
||||
}
|
||||
"caption": "Beautiful park entrance",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -203,21 +203,28 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
"""Get all approved photos for this park."""
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
photos = ParkPhoto.objects.filter(
|
||||
park=obj,
|
||||
is_approved=True
|
||||
).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos
|
||||
photos = ParkPhoto.objects.filter(park=obj, is_approved=True).order_by(
|
||||
"-is_primary", "-created_at"
|
||||
)[
|
||||
:10
|
||||
] # Limit to 10 photos
|
||||
|
||||
return [
|
||||
{
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url if photo.image else None,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None,
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
} if photo.image else {},
|
||||
"image_variants": (
|
||||
{
|
||||
"thumbnail": (
|
||||
f"{photo.image.url}/thumbnail" if photo.image else None
|
||||
),
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
}
|
||||
if photo.image
|
||||
else {}
|
||||
),
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
@@ -232,9 +239,7 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
try:
|
||||
photo = ParkPhoto.objects.filter(
|
||||
park=obj,
|
||||
is_primary=True,
|
||||
is_approved=True
|
||||
park=obj, is_primary=True, is_approved=True
|
||||
).first()
|
||||
|
||||
if photo and photo.image:
|
||||
@@ -275,12 +280,15 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
latest_photo = ParkPhoto.objects.filter(
|
||||
park=obj,
|
||||
is_approved=True,
|
||||
image__isnull=False
|
||||
).order_by('-created_at').first()
|
||||
latest_photo = (
|
||||
ParkPhoto.objects.filter(
|
||||
park=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
@@ -321,12 +329,15 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
latest_photo = ParkPhoto.objects.filter(
|
||||
park=obj,
|
||||
is_approved=True,
|
||||
image__isnull=False
|
||||
).order_by('-created_at').first()
|
||||
latest_photo = (
|
||||
ParkPhoto.objects.filter(
|
||||
park=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
@@ -362,6 +373,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Validate that the banner image belongs to the same park."""
|
||||
if value is not None:
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
photo = ParkPhoto.objects.get(id=value)
|
||||
# The park will be validated in the view
|
||||
@@ -374,6 +386,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Validate that the card image belongs to the same park."""
|
||||
if value is not None:
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
photo = ParkPhoto.objects.get(id=value)
|
||||
# The park will be validated in the view
|
||||
|
||||
@@ -10,16 +10,17 @@ from apps.accounts.models import User
|
||||
|
||||
class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user information in reviews."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'display_name', 'avatar_url']
|
||||
fields = ["username", "display_name", "avatar_url"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the user's avatar URL."""
|
||||
if hasattr(obj, 'profile') and obj.profile:
|
||||
if hasattr(obj, "profile") and obj.profile:
|
||||
return obj.profile.get_avatar()
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
@@ -30,6 +31,7 @@ class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class LatestReviewSerializer(serializers.Serializer):
|
||||
"""Serializer for latest reviews combining park and ride reviews."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField() # 'park' or 'ride'
|
||||
title = serializers.CharField()
|
||||
@@ -52,35 +54,35 @@ class LatestReviewSerializer(serializers.Serializer):
|
||||
"""Convert review instance to serialized representation."""
|
||||
if isinstance(instance, ParkReview):
|
||||
return {
|
||||
'id': instance.pk,
|
||||
'type': 'park',
|
||||
'title': instance.title,
|
||||
'content_snippet': self._get_content_snippet(instance.content),
|
||||
'rating': instance.rating,
|
||||
'created_at': instance.created_at,
|
||||
'user': ReviewUserSerializer(instance.user).data,
|
||||
'subject_name': instance.park.name,
|
||||
'subject_slug': instance.park.slug,
|
||||
'subject_url': f"/parks/{instance.park.slug}/",
|
||||
'park_name': None,
|
||||
'park_slug': None,
|
||||
'park_url': None,
|
||||
"id": instance.pk,
|
||||
"type": "park",
|
||||
"title": instance.title,
|
||||
"content_snippet": self._get_content_snippet(instance.content),
|
||||
"rating": instance.rating,
|
||||
"created_at": instance.created_at,
|
||||
"user": ReviewUserSerializer(instance.user).data,
|
||||
"subject_name": instance.park.name,
|
||||
"subject_slug": instance.park.slug,
|
||||
"subject_url": f"/parks/{instance.park.slug}/",
|
||||
"park_name": None,
|
||||
"park_slug": None,
|
||||
"park_url": None,
|
||||
}
|
||||
elif isinstance(instance, RideReview):
|
||||
return {
|
||||
'id': instance.pk,
|
||||
'type': 'ride',
|
||||
'title': instance.title,
|
||||
'content_snippet': self._get_content_snippet(instance.content),
|
||||
'rating': instance.rating,
|
||||
'created_at': instance.created_at,
|
||||
'user': ReviewUserSerializer(instance.user).data,
|
||||
'subject_name': instance.ride.name,
|
||||
'subject_slug': instance.ride.slug,
|
||||
'subject_url': f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
|
||||
'park_name': instance.ride.park.name,
|
||||
'park_slug': instance.ride.park.slug,
|
||||
'park_url': f"/parks/{instance.ride.park.slug}/",
|
||||
"id": instance.pk,
|
||||
"type": "ride",
|
||||
"title": instance.title,
|
||||
"content_snippet": self._get_content_snippet(instance.content),
|
||||
"rating": instance.rating,
|
||||
"created_at": instance.created_at,
|
||||
"user": ReviewUserSerializer(instance.user).data,
|
||||
"subject_name": instance.ride.name,
|
||||
"subject_slug": instance.ride.slug,
|
||||
"subject_url": f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
|
||||
"park_name": instance.ride.park.name,
|
||||
"park_slug": instance.ride.park.slug,
|
||||
"park_url": f"/parks/{instance.ride.park.slug}/",
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -91,7 +93,7 @@ class LatestReviewSerializer(serializers.Serializer):
|
||||
|
||||
# Find the last complete word within the limit
|
||||
snippet = content[:max_length]
|
||||
last_space = snippet.rfind(' ')
|
||||
last_space = snippet.rfind(" ")
|
||||
if last_space > 0:
|
||||
snippet = snippet[:last_space]
|
||||
|
||||
|
||||
@@ -20,7 +20,13 @@ from .shared import ModelChoices
|
||||
|
||||
def get_ride_model_classes():
|
||||
"""Get ride model classes dynamically to avoid import issues."""
|
||||
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
from apps.rides.models import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
|
||||
return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
|
||||
|
||||
@@ -73,13 +79,17 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
min_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True)
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
max_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True)
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
min_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True)
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
max_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True)
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
distinguishing_features = serializers.CharField()
|
||||
|
||||
|
||||
@@ -98,7 +108,7 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard"
|
||||
"slug": "bolliger-mabillard",
|
||||
},
|
||||
"target_market": "THRILL",
|
||||
"is_discontinued": False,
|
||||
@@ -110,8 +120,8 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
|
||||
"id": 123,
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"caption": "B&M Hyper Coaster",
|
||||
"photo_type": "PROMOTIONAL"
|
||||
}
|
||||
"photo_type": "PROMOTIONAL",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -171,7 +181,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard"
|
||||
"slug": "bolliger-mabillard",
|
||||
},
|
||||
"typical_height_range_min_ft": 200.0,
|
||||
"typical_height_range_max_ft": 325.0,
|
||||
@@ -194,7 +204,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"caption": "B&M Hyper Coaster",
|
||||
"photo_type": "PROMOTIONAL",
|
||||
"is_primary": True
|
||||
"is_primary": True,
|
||||
}
|
||||
],
|
||||
"variants": [
|
||||
@@ -203,7 +213,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"name": "Mega Coaster",
|
||||
"description": "200-299 ft height variant",
|
||||
"min_height_ft": 200.0,
|
||||
"max_height_ft": 299.0
|
||||
"max_height_ft": 299.0,
|
||||
}
|
||||
],
|
||||
"technical_specs": [
|
||||
@@ -212,7 +222,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"spec_category": "DIMENSIONS",
|
||||
"spec_name": "Track Width",
|
||||
"spec_value": "1435",
|
||||
"spec_unit": "mm"
|
||||
"spec_unit": "mm",
|
||||
}
|
||||
],
|
||||
"installations": [
|
||||
@@ -220,9 +230,9 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"id": 1,
|
||||
"name": "Nitro",
|
||||
"park_name": "Six Flags Great Adventure",
|
||||
"opening_date": "2001-04-07"
|
||||
"opening_date": "2001-04-07",
|
||||
}
|
||||
]
|
||||
],
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -302,9 +312,10 @@ class RideModelDetailOutputSerializer(serializers.Serializer):
|
||||
def get_installations(self, obj):
|
||||
"""Get ride installations using this model."""
|
||||
from django.apps import apps
|
||||
Ride = apps.get_model('rides', 'Ride')
|
||||
|
||||
installations = Ride.objects.filter(ride_model=obj).select_related('park')[:10]
|
||||
Ride = apps.get_model("rides", "Ride")
|
||||
|
||||
installations = Ride.objects.filter(ride_model=obj).select_related("park")[:10]
|
||||
return [
|
||||
{
|
||||
"id": ride.id,
|
||||
@@ -325,9 +336,7 @@ class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(),
|
||||
allow_blank=True,
|
||||
default=""
|
||||
choices=ModelChoices.get_ride_category_choices(), allow_blank=True, default=""
|
||||
)
|
||||
|
||||
# Required manufacturer
|
||||
@@ -356,11 +365,14 @@ class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
# Design characteristics
|
||||
track_type = serializers.CharField(max_length=100, allow_blank=True, default="")
|
||||
support_structure = serializers.CharField(
|
||||
max_length=100, allow_blank=True, default="")
|
||||
max_length=100, allow_blank=True, default=""
|
||||
)
|
||||
train_configuration = serializers.CharField(
|
||||
max_length=200, allow_blank=True, default="")
|
||||
max_length=200, allow_blank=True, default=""
|
||||
)
|
||||
restraint_system = serializers.CharField(
|
||||
max_length=100, allow_blank=True, default="")
|
||||
max_length=100, allow_blank=True, default=""
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = serializers.IntegerField(
|
||||
@@ -375,14 +387,14 @@ class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
notable_features = serializers.CharField(allow_blank=True, default="")
|
||||
target_market = serializers.ChoiceField(
|
||||
choices=[
|
||||
('FAMILY', 'Family'),
|
||||
('THRILL', 'Thrill'),
|
||||
('EXTREME', 'Extreme'),
|
||||
('KIDDIE', 'Kiddie'),
|
||||
('ALL_AGES', 'All Ages'),
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
allow_blank=True,
|
||||
default=""
|
||||
default="",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -434,7 +446,7 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(),
|
||||
allow_blank=True,
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Manufacturer
|
||||
@@ -463,11 +475,14 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
# Design characteristics
|
||||
track_type = serializers.CharField(max_length=100, allow_blank=True, required=False)
|
||||
support_structure = serializers.CharField(
|
||||
max_length=100, allow_blank=True, required=False)
|
||||
max_length=100, allow_blank=True, required=False
|
||||
)
|
||||
train_configuration = serializers.CharField(
|
||||
max_length=200, allow_blank=True, required=False)
|
||||
max_length=200, allow_blank=True, required=False
|
||||
)
|
||||
restraint_system = serializers.CharField(
|
||||
max_length=100, allow_blank=True, required=False)
|
||||
max_length=100, allow_blank=True, required=False
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = serializers.IntegerField(
|
||||
@@ -482,14 +497,14 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
notable_features = serializers.CharField(allow_blank=True, required=False)
|
||||
target_market = serializers.ChoiceField(
|
||||
choices=[
|
||||
('FAMILY', 'Family'),
|
||||
('THRILL', 'Thrill'),
|
||||
('EXTREME', 'Extreme'),
|
||||
('KIDDIE', 'Kiddie'),
|
||||
('ALL_AGES', 'All Ages'),
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
allow_blank=True,
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -541,8 +556,7 @@ class RideModelFilterInputSerializer(serializers.Serializer):
|
||||
|
||||
# Category filter
|
||||
category = serializers.MultipleChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(),
|
||||
required=False
|
||||
choices=ModelChoices.get_ride_category_choices(), required=False
|
||||
)
|
||||
|
||||
# Manufacturer filter
|
||||
@@ -552,13 +566,13 @@ class RideModelFilterInputSerializer(serializers.Serializer):
|
||||
# Market filter
|
||||
target_market = serializers.MultipleChoiceField(
|
||||
choices=[
|
||||
('FAMILY', 'Family'),
|
||||
('THRILL', 'Thrill'),
|
||||
('EXTREME', 'Extreme'),
|
||||
('KIDDIE', 'Kiddie'),
|
||||
('ALL_AGES', 'All Ages'),
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Status filter
|
||||
@@ -711,14 +725,14 @@ class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
|
||||
ride_model_id = serializers.IntegerField()
|
||||
spec_category = serializers.ChoiceField(
|
||||
choices=[
|
||||
('DIMENSIONS', 'Dimensions'),
|
||||
('PERFORMANCE', 'Performance'),
|
||||
('CAPACITY', 'Capacity'),
|
||||
('SAFETY', 'Safety Features'),
|
||||
('ELECTRICAL', 'Electrical Requirements'),
|
||||
('FOUNDATION', 'Foundation Requirements'),
|
||||
('MAINTENANCE', 'Maintenance'),
|
||||
('OTHER', 'Other'),
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
]
|
||||
)
|
||||
spec_name = serializers.CharField(max_length=100)
|
||||
@@ -732,16 +746,16 @@ class RideModelTechnicalSpecUpdateInputSerializer(serializers.Serializer):
|
||||
|
||||
spec_category = serializers.ChoiceField(
|
||||
choices=[
|
||||
('DIMENSIONS', 'Dimensions'),
|
||||
('PERFORMANCE', 'Performance'),
|
||||
('CAPACITY', 'Capacity'),
|
||||
('SAFETY', 'Safety Features'),
|
||||
('ELECTRICAL', 'Electrical Requirements'),
|
||||
('FOUNDATION', 'Foundation Requirements'),
|
||||
('MAINTENANCE', 'Maintenance'),
|
||||
('OTHER', 'Other'),
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
spec_name = serializers.CharField(max_length=100, required=False)
|
||||
spec_value = serializers.CharField(max_length=255, required=False)
|
||||
@@ -761,13 +775,13 @@ class RideModelPhotoCreateInputSerializer(serializers.Serializer):
|
||||
alt_text = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
('PROMOTIONAL', 'Promotional'),
|
||||
('TECHNICAL', 'Technical Drawing'),
|
||||
('INSTALLATION', 'Installation Example'),
|
||||
('RENDERING', '3D Rendering'),
|
||||
('CATALOG', 'Catalog Image'),
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default='PROMOTIONAL'
|
||||
default="PROMOTIONAL",
|
||||
)
|
||||
is_primary = serializers.BooleanField(default=False)
|
||||
photographer = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
@@ -782,20 +796,22 @@ class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
|
||||
alt_text = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
('PROMOTIONAL', 'Promotional'),
|
||||
('TECHNICAL', 'Technical Drawing'),
|
||||
('INSTALLATION', 'Installation Example'),
|
||||
('RENDERING', '3D Rendering'),
|
||||
('CATALOG', 'Catalog Image'),
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
is_primary = serializers.BooleanField(required=False)
|
||||
photographer = serializers.CharField(
|
||||
max_length=255, allow_blank=True, required=False)
|
||||
max_length=255, allow_blank=True, required=False
|
||||
)
|
||||
source = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
copyright_info = serializers.CharField(
|
||||
max_length=255, allow_blank=True, required=False)
|
||||
max_length=255, allow_blank=True, required=False
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL STATS SERIALIZERS ===
|
||||
@@ -809,16 +825,13 @@ class RideModelStatsOutputSerializer(serializers.Serializer):
|
||||
active_manufacturers = serializers.IntegerField()
|
||||
discontinued_models = serializers.IntegerField()
|
||||
by_category = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Model counts by category"
|
||||
child=serializers.IntegerField(), help_text="Model counts by category"
|
||||
)
|
||||
by_target_market = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Model counts by target market"
|
||||
child=serializers.IntegerField(), help_text="Model counts by target market"
|
||||
)
|
||||
by_manufacturer = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Model counts by manufacturer"
|
||||
child=serializers.IntegerField(), help_text="Model counts by manufacturer"
|
||||
)
|
||||
recent_models = serializers.IntegerField(
|
||||
help_text="Models created in the last 30 days"
|
||||
|
||||
@@ -135,11 +135,11 @@ class RideListOutputSerializer(serializers.Serializer):
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"is_primary": True,
|
||||
"photo_type": "exterior"
|
||||
"photo_type": "exterior",
|
||||
}
|
||||
],
|
||||
"primary_photo": {
|
||||
@@ -149,11 +149,11 @@ class RideListOutputSerializer(serializers.Serializer):
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"photo_type": "exterior"
|
||||
}
|
||||
"photo_type": "exterior",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -249,21 +249,28 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
"""Get all approved photos for this ride."""
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
photos = RidePhoto.objects.filter(
|
||||
ride=obj,
|
||||
is_approved=True
|
||||
).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos
|
||||
photos = RidePhoto.objects.filter(ride=obj, is_approved=True).order_by(
|
||||
"-is_primary", "-created_at"
|
||||
)[
|
||||
:10
|
||||
] # Limit to 10 photos
|
||||
|
||||
return [
|
||||
{
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url if photo.image else None,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None,
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
} if photo.image else {},
|
||||
"image_variants": (
|
||||
{
|
||||
"thumbnail": (
|
||||
f"{photo.image.url}/thumbnail" if photo.image else None
|
||||
),
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
}
|
||||
if photo.image
|
||||
else {}
|
||||
),
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
@@ -279,9 +286,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
try:
|
||||
photo = RidePhoto.objects.filter(
|
||||
ride=obj,
|
||||
is_primary=True,
|
||||
is_approved=True
|
||||
ride=obj, is_primary=True, is_approved=True
|
||||
).first()
|
||||
|
||||
if photo and photo.image:
|
||||
@@ -324,12 +329,15 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
latest_photo = RidePhoto.objects.filter(
|
||||
ride=obj,
|
||||
is_approved=True,
|
||||
image__isnull=False
|
||||
).order_by('-created_at').first()
|
||||
latest_photo = (
|
||||
RidePhoto.objects.filter(
|
||||
ride=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
@@ -372,12 +380,15 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
latest_photo = RidePhoto.objects.filter(
|
||||
ride=obj,
|
||||
is_approved=True,
|
||||
image__isnull=False
|
||||
).order_by('-created_at').first()
|
||||
latest_photo = (
|
||||
RidePhoto.objects.filter(
|
||||
ride=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
@@ -410,6 +421,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Validate that the banner image belongs to the same ride."""
|
||||
if value is not None:
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
photo = RidePhoto.objects.get(id=value)
|
||||
# The ride will be validated in the view
|
||||
@@ -422,6 +434,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Validate that the card image belongs to the same ride."""
|
||||
if value is not None:
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
photo = RidePhoto.objects.get(id=value)
|
||||
# The ride will be validated in the view
|
||||
|
||||
@@ -185,19 +185,20 @@ class CompanyOutputSerializer(serializers.Serializer):
|
||||
- MANUFACTURER and DESIGNER are for rides domain
|
||||
"""
|
||||
# Use the URL field from the model if it exists (auto-generated on save)
|
||||
if hasattr(obj, 'url') and obj.url:
|
||||
if hasattr(obj, "url") and obj.url:
|
||||
return obj.url
|
||||
|
||||
# Fallback URL generation (should not be needed if model save works correctly)
|
||||
if hasattr(obj, 'roles') and obj.roles:
|
||||
if hasattr(obj, "roles") and obj.roles:
|
||||
frontend_domain = getattr(
|
||||
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
primary_role = obj.roles[0] if obj.roles else None
|
||||
|
||||
# Only generate URLs for rides domain roles here
|
||||
if primary_role == 'MANUFACTURER':
|
||||
if primary_role == "MANUFACTURER":
|
||||
return f"{frontend_domain}/rides/manufacturers/{obj.slug}/"
|
||||
elif primary_role == 'DESIGNER':
|
||||
elif primary_role == "DESIGNER":
|
||||
return f"{frontend_domain}/rides/designers/{obj.slug}/"
|
||||
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain
|
||||
|
||||
|
||||
@@ -62,88 +62,68 @@ class StatsSerializer(serializers.Serializer):
|
||||
|
||||
# Ride category counts (optional fields since they depend on data)
|
||||
roller_coasters = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as roller coasters"
|
||||
required=False, help_text="Number of rides categorized as roller coasters"
|
||||
)
|
||||
dark_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as dark rides"
|
||||
required=False, help_text="Number of rides categorized as dark rides"
|
||||
)
|
||||
flat_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as flat rides"
|
||||
required=False, help_text="Number of rides categorized as flat rides"
|
||||
)
|
||||
water_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as water rides"
|
||||
required=False, help_text="Number of rides categorized as water rides"
|
||||
)
|
||||
transport_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as transport rides"
|
||||
required=False, help_text="Number of rides categorized as transport rides"
|
||||
)
|
||||
other_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides categorized as other"
|
||||
required=False, help_text="Number of rides categorized as other"
|
||||
)
|
||||
|
||||
# Park status counts (optional fields since they depend on data)
|
||||
operating_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of currently operating parks"
|
||||
required=False, help_text="Number of currently operating parks"
|
||||
)
|
||||
temporarily_closed_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of temporarily closed parks"
|
||||
required=False, help_text="Number of temporarily closed parks"
|
||||
)
|
||||
permanently_closed_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of permanently closed parks"
|
||||
required=False, help_text="Number of permanently closed parks"
|
||||
)
|
||||
under_construction_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of parks under construction"
|
||||
required=False, help_text="Number of parks under construction"
|
||||
)
|
||||
demolished_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of demolished parks"
|
||||
required=False, help_text="Number of demolished parks"
|
||||
)
|
||||
relocated_parks = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of relocated parks"
|
||||
required=False, help_text="Number of relocated parks"
|
||||
)
|
||||
|
||||
# Ride status counts (optional fields since they depend on data)
|
||||
operating_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of currently operating rides"
|
||||
required=False, help_text="Number of currently operating rides"
|
||||
)
|
||||
temporarily_closed_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of temporarily closed rides"
|
||||
required=False, help_text="Number of temporarily closed rides"
|
||||
)
|
||||
sbno_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides standing but not operating"
|
||||
required=False, help_text="Number of rides standing but not operating"
|
||||
)
|
||||
closing_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides in the process of closing"
|
||||
required=False, help_text="Number of rides in the process of closing"
|
||||
)
|
||||
permanently_closed_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of permanently closed rides"
|
||||
required=False, help_text="Number of permanently closed rides"
|
||||
)
|
||||
under_construction_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of rides under construction"
|
||||
required=False, help_text="Number of rides under construction"
|
||||
)
|
||||
demolished_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of demolished rides"
|
||||
required=False, help_text="Number of demolished rides"
|
||||
)
|
||||
relocated_rides = serializers.IntegerField(
|
||||
required=False,
|
||||
help_text="Number of relocated rides"
|
||||
required=False, help_text="Number of relocated rides"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
|
||||
@@ -10,7 +10,13 @@ from django.dispatch import receiver
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
|
||||
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
|
||||
from apps.rides.models import (
|
||||
Ride,
|
||||
RollerCoasterStats,
|
||||
RideReview,
|
||||
RidePhoto,
|
||||
Company as RideCompany,
|
||||
)
|
||||
|
||||
|
||||
def invalidate_stats_cache():
|
||||
@@ -23,6 +29,7 @@ def invalidate_stats_cache():
|
||||
cache.delete("platform_stats")
|
||||
# Also update the timestamp for when stats were last invalidated
|
||||
from datetime import datetime
|
||||
|
||||
cache.set("platform_stats_timestamp", datetime.now().isoformat(), 300)
|
||||
|
||||
|
||||
|
||||
@@ -61,11 +61,18 @@ urlpatterns = [
|
||||
# Trending system endpoints
|
||||
path("trending/", TrendingAPIView.as_view(), name="trending"),
|
||||
path("new-content/", NewContentAPIView.as_view(), name="new-content"),
|
||||
path("trending/calculate/", TriggerTrendingCalculationAPIView.as_view(),
|
||||
name="trigger-trending-calculation"),
|
||||
path(
|
||||
"trending/calculate/",
|
||||
TriggerTrendingCalculationAPIView.as_view(),
|
||||
name="trigger-trending-calculation",
|
||||
),
|
||||
# Statistics endpoints
|
||||
path("stats/", StatsAPIView.as_view(), name="stats"),
|
||||
path("stats/recalculate/", StatsRecalculateAPIView.as_view(), name="stats-recalculate"),
|
||||
path(
|
||||
"stats/recalculate/",
|
||||
StatsRecalculateAPIView.as_view(),
|
||||
name="stats-recalculate",
|
||||
),
|
||||
# Reviews endpoints
|
||||
path("reviews/latest/", LatestReviewsAPIView.as_view(), name="latest-reviews"),
|
||||
# Ranking system endpoints
|
||||
@@ -82,6 +89,7 @@ urlpatterns = [
|
||||
path("email/", include("apps.api.v1.email.urls")),
|
||||
path("core/", include("apps.api.v1.core.urls")),
|
||||
path("maps/", include("apps.api.v1.maps.urls")),
|
||||
path("moderation/", include("apps.moderation.urls")),
|
||||
# Include router URLs (for rankings and any other router-registered endpoints)
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -372,7 +372,11 @@ class SocialProvidersAPIView(APIView):
|
||||
"code": "SOCIAL_PROVIDERS_ERROR",
|
||||
"message": "Unable to retrieve social providers",
|
||||
"details": str(e) if str(e) else None,
|
||||
"request_user": str(request.user) if hasattr(request, 'user') else "AnonymousUser",
|
||||
"request_user": (
|
||||
str(request.user)
|
||||
if hasattr(request, "user")
|
||||
else "AnonymousUser"
|
||||
),
|
||||
},
|
||||
"data": None,
|
||||
},
|
||||
|
||||
@@ -107,7 +107,7 @@ class HealthCheckAPIView(APIView):
|
||||
# Process individual health checks
|
||||
for plugin in plugins:
|
||||
# Handle both plugin objects and strings
|
||||
if hasattr(plugin, 'identifier'):
|
||||
if hasattr(plugin, "identifier"):
|
||||
plugin_name = plugin.identifier()
|
||||
plugin_class_name = plugin.__class__.__name__
|
||||
critical_service = getattr(plugin, "critical_service", False)
|
||||
@@ -120,9 +120,7 @@ class HealthCheckAPIView(APIView):
|
||||
response_time = None
|
||||
|
||||
plugin_errors = (
|
||||
errors.get(plugin_class_name, [])
|
||||
if isinstance(errors, dict)
|
||||
else []
|
||||
errors.get(plugin_class_name, []) if isinstance(errors, dict) else []
|
||||
)
|
||||
|
||||
health_data["checks"][plugin_name] = {
|
||||
|
||||
@@ -6,7 +6,6 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework import status
|
||||
from django.db.models import Q
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from itertools import chain
|
||||
@@ -24,6 +23,7 @@ class LatestReviewsAPIView(APIView):
|
||||
Returns a combined list of the most recent reviews across the platform,
|
||||
including username, user avatar, date, score, and review snippet.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
@@ -51,35 +51,35 @@ class LatestReviewsAPIView(APIView):
|
||||
"""Get the latest reviews from both parks and rides."""
|
||||
# Get limit parameter with validation
|
||||
try:
|
||||
limit = int(request.query_params.get('limit', 20))
|
||||
limit = int(request.query_params.get("limit", 20))
|
||||
limit = min(max(limit, 1), 100) # Clamp between 1 and 100
|
||||
except (ValueError, TypeError):
|
||||
limit = 20
|
||||
|
||||
# Get published reviews from both models
|
||||
park_reviews = ParkReview.objects.filter(
|
||||
is_published=True
|
||||
).select_related(
|
||||
'user', 'user__profile', 'park'
|
||||
).order_by('-created_at')[:limit]
|
||||
park_reviews = (
|
||||
ParkReview.objects.filter(is_published=True)
|
||||
.select_related("user", "user__profile", "park")
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
ride_reviews = RideReview.objects.filter(
|
||||
is_published=True
|
||||
).select_related(
|
||||
'user', 'user__profile', 'ride', 'ride__park'
|
||||
).order_by('-created_at')[:limit]
|
||||
ride_reviews = (
|
||||
RideReview.objects.filter(is_published=True)
|
||||
.select_related("user", "user__profile", "ride", "ride__park")
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
# Combine and sort by created_at
|
||||
all_reviews = sorted(
|
||||
chain(park_reviews, ride_reviews),
|
||||
key=attrgetter('created_at'),
|
||||
reverse=True
|
||||
key=attrgetter("created_at"),
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
|
||||
# Serialize the combined results
|
||||
serializer = LatestReviewSerializer(all_reviews, many=True)
|
||||
|
||||
return Response({
|
||||
'count': len(all_reviews),
|
||||
'results': serializer.data
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{"count": len(all_reviews), "results": serializer.data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -9,14 +9,20 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, OpenApiExample
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
|
||||
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
|
||||
from apps.rides.models import (
|
||||
Ride,
|
||||
RollerCoasterStats,
|
||||
RideReview,
|
||||
RidePhoto,
|
||||
Company as RideCompany,
|
||||
)
|
||||
from ..serializers.stats import StatsSerializer
|
||||
|
||||
|
||||
@@ -40,13 +46,13 @@ class StatsAPIView(APIView):
|
||||
Returns:
|
||||
str: Human-readable relative time (e.g., "2 days, 3 hours, 15 minutes ago", "just now")
|
||||
"""
|
||||
if not timestamp_str or timestamp_str == 'just_now':
|
||||
return 'just now'
|
||||
if not timestamp_str or timestamp_str == "just_now":
|
||||
return "just now"
|
||||
|
||||
try:
|
||||
# Parse the ISO timestamp
|
||||
if isinstance(timestamp_str, str):
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
timestamp = timestamp_str
|
||||
|
||||
@@ -60,7 +66,7 @@ class StatsAPIView(APIView):
|
||||
|
||||
# If less than a minute, return "just now"
|
||||
if total_seconds < 60:
|
||||
return 'just now'
|
||||
return "just now"
|
||||
|
||||
# Calculate time components
|
||||
days = diff.days
|
||||
@@ -81,16 +87,16 @@ class StatsAPIView(APIView):
|
||||
|
||||
# Join parts with commas and add "ago"
|
||||
if len(parts) == 0:
|
||||
return 'just now'
|
||||
return "just now"
|
||||
elif len(parts) == 1:
|
||||
return f'{parts[0]} ago'
|
||||
return f"{parts[0]} ago"
|
||||
elif len(parts) == 2:
|
||||
return f'{parts[0]} and {parts[1]} ago'
|
||||
return f"{parts[0]} and {parts[1]} ago"
|
||||
else:
|
||||
return f'{", ".join(parts[:-1])}, and {parts[-1]} ago'
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return 'unknown'
|
||||
return "unknown"
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_platform_stats",
|
||||
@@ -115,9 +121,12 @@ class StatsAPIView(APIView):
|
||||
500: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {"type": "string", "description": "Error message if statistics calculation fails"}
|
||||
}
|
||||
}
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Error message if statistics calculation fails",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Statistics"],
|
||||
examples=[
|
||||
@@ -142,10 +151,10 @@ class StatsAPIView(APIView):
|
||||
"operating_parks": 7,
|
||||
"operating_rides": 10,
|
||||
"last_updated": "2025-08-28T17:34:59.677143+00:00",
|
||||
"relative_last_updated": "just now"
|
||||
}
|
||||
"relative_last_updated": "just now",
|
||||
},
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
def get(self, request):
|
||||
"""Get platform statistics."""
|
||||
@@ -197,76 +206,76 @@ class StatsAPIView(APIView):
|
||||
total_roller_coasters = RollerCoasterStats.objects.count()
|
||||
|
||||
# Ride category counts
|
||||
ride_categories = Ride.objects.values('category').annotate(
|
||||
count=Count('id')
|
||||
).exclude(category='')
|
||||
ride_categories = (
|
||||
Ride.objects.values("category")
|
||||
.annotate(count=Count("id"))
|
||||
.exclude(category="")
|
||||
)
|
||||
|
||||
category_stats = {}
|
||||
for category in ride_categories:
|
||||
category_code = category['category']
|
||||
category_count = category['count']
|
||||
category_code = category["category"]
|
||||
category_count = category["count"]
|
||||
|
||||
# Convert category codes to readable names
|
||||
category_names = {
|
||||
'RC': 'roller_coasters',
|
||||
'DR': 'dark_rides',
|
||||
'FR': 'flat_rides',
|
||||
'WR': 'water_rides',
|
||||
'TR': 'transport_rides',
|
||||
'OT': 'other_rides'
|
||||
"RC": "roller_coasters",
|
||||
"DR": "dark_rides",
|
||||
"FR": "flat_rides",
|
||||
"WR": "water_rides",
|
||||
"TR": "transport_rides",
|
||||
"OT": "other_rides",
|
||||
}
|
||||
|
||||
category_name = category_names.get(
|
||||
category_code, f'category_{category_code.lower()}')
|
||||
category_code, f"category_{category_code.lower()}"
|
||||
)
|
||||
category_stats[category_name] = category_count
|
||||
|
||||
# Park status counts
|
||||
park_statuses = Park.objects.values('status').annotate(
|
||||
count=Count('id')
|
||||
)
|
||||
park_statuses = Park.objects.values("status").annotate(count=Count("id"))
|
||||
|
||||
park_status_stats = {}
|
||||
for status_item in park_statuses:
|
||||
status_code = status_item['status']
|
||||
status_count = status_item['count']
|
||||
status_code = status_item["status"]
|
||||
status_count = status_item["count"]
|
||||
|
||||
# Convert status codes to readable names
|
||||
status_names = {
|
||||
'OPERATING': 'operating_parks',
|
||||
'CLOSED_TEMP': 'temporarily_closed_parks',
|
||||
'CLOSED_PERM': 'permanently_closed_parks',
|
||||
'UNDER_CONSTRUCTION': 'under_construction_parks',
|
||||
'DEMOLISHED': 'demolished_parks',
|
||||
'RELOCATED': 'relocated_parks'
|
||||
"OPERATING": "operating_parks",
|
||||
"CLOSED_TEMP": "temporarily_closed_parks",
|
||||
"CLOSED_PERM": "permanently_closed_parks",
|
||||
"UNDER_CONSTRUCTION": "under_construction_parks",
|
||||
"DEMOLISHED": "demolished_parks",
|
||||
"RELOCATED": "relocated_parks",
|
||||
}
|
||||
|
||||
status_name = status_names.get(status_code, f'status_{status_code.lower()}')
|
||||
status_name = status_names.get(status_code, f"status_{status_code.lower()}")
|
||||
park_status_stats[status_name] = status_count
|
||||
|
||||
# Ride status counts
|
||||
ride_statuses = Ride.objects.values('status').annotate(
|
||||
count=Count('id')
|
||||
)
|
||||
ride_statuses = Ride.objects.values("status").annotate(count=Count("id"))
|
||||
|
||||
ride_status_stats = {}
|
||||
for status_item in ride_statuses:
|
||||
status_code = status_item['status']
|
||||
status_count = status_item['count']
|
||||
status_code = status_item["status"]
|
||||
status_count = status_item["count"]
|
||||
|
||||
# Convert status codes to readable names
|
||||
status_names = {
|
||||
'OPERATING': 'operating_rides',
|
||||
'CLOSED_TEMP': 'temporarily_closed_rides',
|
||||
'SBNO': 'sbno_rides',
|
||||
'CLOSING': 'closing_rides',
|
||||
'CLOSED_PERM': 'permanently_closed_rides',
|
||||
'UNDER_CONSTRUCTION': 'under_construction_rides',
|
||||
'DEMOLISHED': 'demolished_rides',
|
||||
'RELOCATED': 'relocated_rides'
|
||||
"OPERATING": "operating_rides",
|
||||
"CLOSED_TEMP": "temporarily_closed_rides",
|
||||
"SBNO": "sbno_rides",
|
||||
"CLOSING": "closing_rides",
|
||||
"CLOSED_PERM": "permanently_closed_rides",
|
||||
"UNDER_CONSTRUCTION": "under_construction_rides",
|
||||
"DEMOLISHED": "demolished_rides",
|
||||
"RELOCATED": "relocated_rides",
|
||||
}
|
||||
|
||||
status_name = status_names.get(
|
||||
status_code, f'ride_status_{status_code.lower()}')
|
||||
status_code, f"ride_status_{status_code.lower()}"
|
||||
)
|
||||
ride_status_stats[status_name] = status_count
|
||||
|
||||
# Review counts
|
||||
@@ -279,13 +288,13 @@ class StatsAPIView(APIView):
|
||||
last_updated_iso = now.isoformat()
|
||||
|
||||
# Get cached timestamp or use current time
|
||||
cached_timestamp = cache.get('platform_stats_timestamp')
|
||||
if cached_timestamp and cached_timestamp != 'just_now':
|
||||
cached_timestamp = cache.get("platform_stats_timestamp")
|
||||
if cached_timestamp and cached_timestamp != "just_now":
|
||||
# Use cached timestamp for consistency
|
||||
last_updated_iso = cached_timestamp
|
||||
else:
|
||||
# Set new timestamp in cache
|
||||
cache.set('platform_stats_timestamp', last_updated_iso, 300)
|
||||
cache.set("platform_stats_timestamp", last_updated_iso, 300)
|
||||
|
||||
# Calculate relative time
|
||||
relative_last_updated = self._get_relative_time(last_updated_iso)
|
||||
@@ -293,34 +302,29 @@ class StatsAPIView(APIView):
|
||||
# Combine all stats
|
||||
stats = {
|
||||
# Core entity counts
|
||||
'total_parks': total_parks,
|
||||
'total_rides': total_rides,
|
||||
'total_manufacturers': total_manufacturers,
|
||||
'total_operators': total_operators,
|
||||
'total_designers': total_designers,
|
||||
'total_property_owners': total_property_owners,
|
||||
'total_roller_coasters': total_roller_coasters,
|
||||
|
||||
"total_parks": total_parks,
|
||||
"total_rides": total_rides,
|
||||
"total_manufacturers": total_manufacturers,
|
||||
"total_operators": total_operators,
|
||||
"total_designers": total_designers,
|
||||
"total_property_owners": total_property_owners,
|
||||
"total_roller_coasters": total_roller_coasters,
|
||||
# Photo counts
|
||||
'total_photos': total_photos,
|
||||
'total_park_photos': total_park_photos,
|
||||
'total_ride_photos': total_ride_photos,
|
||||
|
||||
"total_photos": total_photos,
|
||||
"total_park_photos": total_park_photos,
|
||||
"total_ride_photos": total_ride_photos,
|
||||
# Review counts
|
||||
'total_reviews': total_reviews,
|
||||
'total_park_reviews': total_park_reviews,
|
||||
'total_ride_reviews': total_ride_reviews,
|
||||
|
||||
"total_reviews": total_reviews,
|
||||
"total_park_reviews": total_park_reviews,
|
||||
"total_ride_reviews": total_ride_reviews,
|
||||
# Category breakdowns
|
||||
**category_stats,
|
||||
|
||||
# Status breakdowns
|
||||
**park_status_stats,
|
||||
**ride_status_stats,
|
||||
|
||||
# Metadata
|
||||
'last_updated': last_updated_iso,
|
||||
'relative_last_updated': relative_last_updated
|
||||
"last_updated": last_updated_iso,
|
||||
"relative_last_updated": relative_last_updated,
|
||||
}
|
||||
|
||||
return stats
|
||||
@@ -351,8 +355,11 @@ class StatsRecalculateAPIView(APIView):
|
||||
cache.set("platform_stats", fresh_stats, 300)
|
||||
|
||||
# Return success response with the fresh stats
|
||||
return Response({
|
||||
"message": "Platform statistics have been successfully recalculated",
|
||||
"stats": fresh_stats,
|
||||
"recalculated_at": timezone.now().isoformat()
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{
|
||||
"message": "Platform statistics have been successfully recalculated",
|
||||
"stats": fresh_stats,
|
||||
"recalculated_at": timezone.now().isoformat(),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -125,17 +125,24 @@ class TriggerTrendingCalculationAPIView(APIView):
|
||||
try:
|
||||
# Run trending calculation command
|
||||
with redirect_stdout(trending_output), redirect_stderr(trending_output):
|
||||
call_command('calculate_trending',
|
||||
'--content-type=all', '--limit=50')
|
||||
call_command(
|
||||
"calculate_trending", "--content-type=all", "--limit=50"
|
||||
)
|
||||
trending_completed = True
|
||||
except Exception as e:
|
||||
trending_output.write(f"Error: {str(e)}")
|
||||
|
||||
try:
|
||||
# Run new content calculation command
|
||||
with redirect_stdout(new_content_output), redirect_stderr(new_content_output):
|
||||
call_command('calculate_new_content',
|
||||
'--content-type=all', '--days-back=30', '--limit=50')
|
||||
with redirect_stdout(new_content_output), redirect_stderr(
|
||||
new_content_output
|
||||
):
|
||||
call_command(
|
||||
"calculate_new_content",
|
||||
"--content-type=all",
|
||||
"--days-back=30",
|
||||
"--limit=50",
|
||||
)
|
||||
new_content_completed = True
|
||||
except Exception as e:
|
||||
new_content_output.write(f"Error: {str(e)}")
|
||||
|
||||
@@ -157,7 +157,9 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
|
||||
ranking = self.get_object()
|
||||
history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:90] # Last 3 months
|
||||
)[
|
||||
:90
|
||||
] # Last 3 months
|
||||
|
||||
serializer = self.get_serializer(history, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -166,7 +166,9 @@ def custom_exception_handler(
|
||||
request=request,
|
||||
)
|
||||
|
||||
response = Response(custom_response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
response = Response(
|
||||
custom_response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -20,39 +20,37 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Calculate new content and cache results'
|
||||
help = "Calculate new content and cache results"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--content-type',
|
||||
"--content-type",
|
||||
type=str,
|
||||
default='all',
|
||||
choices=['all', 'parks', 'rides'],
|
||||
help='Type of content to calculate (default: all)'
|
||||
default="all",
|
||||
choices=["all", "parks", "rides"],
|
||||
help="Type of content to calculate (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--days-back',
|
||||
"--days-back",
|
||||
type=int,
|
||||
default=30,
|
||||
help='Number of days to look back for new content (default: 30)'
|
||||
help="Number of days to look back for new content (default: 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit',
|
||||
"--limit",
|
||||
type=int,
|
||||
default=50,
|
||||
help='Maximum number of results to calculate (default: 50)'
|
||||
help="Maximum number of results to calculate (default: 50)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Enable verbose output'
|
||||
"--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
content_type = options['content_type']
|
||||
days_back = options['days_back']
|
||||
limit = options['limit']
|
||||
verbose = options['verbose']
|
||||
content_type = options["content_type"]
|
||||
days_back = options["days_back"]
|
||||
limit = options["limit"]
|
||||
verbose = options["verbose"]
|
||||
|
||||
if verbose:
|
||||
self.stdout.write(f"Starting new content calculation for {content_type}")
|
||||
@@ -63,14 +61,16 @@ class Command(BaseCommand):
|
||||
|
||||
if content_type in ["all", "parks"]:
|
||||
parks = self._get_new_parks(
|
||||
cutoff_date, limit if content_type == "parks" else limit * 2)
|
||||
cutoff_date, limit if content_type == "parks" else limit * 2
|
||||
)
|
||||
new_items.extend(parks)
|
||||
if verbose:
|
||||
self.stdout.write(f"Found {len(parks)} new parks")
|
||||
|
||||
if content_type in ["all", "rides"]:
|
||||
rides = self._get_new_rides(
|
||||
cutoff_date, limit if content_type == "rides" else limit * 2)
|
||||
cutoff_date, limit if content_type == "rides" else limit * 2
|
||||
)
|
||||
new_items.extend(rides)
|
||||
if verbose:
|
||||
self.stdout.write(f"Found {len(rides)} new rides")
|
||||
@@ -95,7 +95,8 @@ class Command(BaseCommand):
|
||||
if verbose:
|
||||
for item in formatted_results[:5]: # Show first 5 items
|
||||
self.stdout.write(
|
||||
f" {item['name']} ({item['park']}) - opened: {item['date_opened']}")
|
||||
f" {item['name']} ({item['park']}) - opened: {item['date_opened']}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating new content: {e}", exc_info=True)
|
||||
@@ -105,8 +106,8 @@ class Command(BaseCommand):
|
||||
"""Get recently added parks using real data."""
|
||||
new_parks = (
|
||||
Park.objects.filter(
|
||||
Q(created_at__gte=cutoff_date) | Q(
|
||||
opening_date__gte=cutoff_date.date()),
|
||||
Q(created_at__gte=cutoff_date)
|
||||
| Q(opening_date__gte=cutoff_date.date()),
|
||||
status="OPERATING",
|
||||
)
|
||||
.select_related("location", "operator")
|
||||
@@ -124,18 +125,20 @@ class Command(BaseCommand):
|
||||
if opening_date and isinstance(opening_date, datetime):
|
||||
opening_date = opening_date.date()
|
||||
|
||||
results.append({
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"id": park.pk,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"url": park.url,
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"id": park.pk,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"url": park.url,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -143,8 +146,8 @@ class Command(BaseCommand):
|
||||
"""Get recently added rides using real data."""
|
||||
new_rides = (
|
||||
Ride.objects.filter(
|
||||
Q(created_at__gte=cutoff_date) | Q(
|
||||
opening_date__gte=cutoff_date.date()),
|
||||
Q(created_at__gte=cutoff_date)
|
||||
| Q(opening_date__gte=cutoff_date.date()),
|
||||
status="OPERATING",
|
||||
)
|
||||
.select_related("park", "park__location")
|
||||
@@ -154,7 +157,8 @@ class Command(BaseCommand):
|
||||
results = []
|
||||
for ride in new_rides:
|
||||
date_added = getattr(ride, "opening_date", None) or getattr(
|
||||
ride, "created_at", None)
|
||||
ride, "created_at", None
|
||||
)
|
||||
if date_added:
|
||||
if isinstance(date_added, datetime):
|
||||
date_added = date_added.date()
|
||||
@@ -163,23 +167,27 @@ class Command(BaseCommand):
|
||||
if opening_date and isinstance(opening_date, datetime):
|
||||
opening_date = opening_date.date()
|
||||
|
||||
results.append({
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _format_new_content_results(self, new_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _format_new_content_results(
|
||||
self, new_items: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format new content results for frontend consumption."""
|
||||
formatted_results = []
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@ Run with: python manage.py calculate_trending
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from django.core.cache import cache
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.core.analytics import PageView
|
||||
from apps.parks.models import Park
|
||||
@@ -22,32 +20,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Calculate trending content and cache results'
|
||||
help = "Calculate trending content and cache results"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--content-type',
|
||||
"--content-type",
|
||||
type=str,
|
||||
default='all',
|
||||
choices=['all', 'parks', 'rides'],
|
||||
help='Type of content to calculate (default: all)'
|
||||
default="all",
|
||||
choices=["all", "parks", "rides"],
|
||||
help="Type of content to calculate (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit',
|
||||
"--limit",
|
||||
type=int,
|
||||
default=50,
|
||||
help='Maximum number of results to calculate (default: 50)'
|
||||
help="Maximum number of results to calculate (default: 50)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Enable verbose output'
|
||||
"--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
content_type = options['content_type']
|
||||
limit = options['limit']
|
||||
verbose = options['verbose']
|
||||
content_type = options["content_type"]
|
||||
limit = options["limit"]
|
||||
verbose = options["verbose"]
|
||||
|
||||
if verbose:
|
||||
self.stdout.write(f"Starting trending calculation for {content_type}")
|
||||
@@ -64,7 +60,7 @@ class Command(BaseCommand):
|
||||
park_items = self._calculate_trending_parks(
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
limit if content_type == "parks" else limit * 2
|
||||
limit if content_type == "parks" else limit * 2,
|
||||
)
|
||||
trending_items.extend(park_items)
|
||||
if verbose:
|
||||
@@ -74,7 +70,7 @@ class Command(BaseCommand):
|
||||
ride_items = self._calculate_trending_rides(
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
limit if content_type == "rides" else limit * 2
|
||||
limit if content_type == "rides" else limit * 2,
|
||||
)
|
||||
trending_items.extend(ride_items)
|
||||
if verbose:
|
||||
@@ -86,7 +82,8 @@ class Command(BaseCommand):
|
||||
|
||||
# Format results for API consumption
|
||||
formatted_results = self._format_trending_results(
|
||||
trending_items, current_period_hours, previous_period_hours)
|
||||
trending_items, current_period_hours, previous_period_hours
|
||||
)
|
||||
|
||||
# Cache results
|
||||
cache_key = f"trending:calculated:{content_type}:{limit}"
|
||||
@@ -101,74 +98,109 @@ class Command(BaseCommand):
|
||||
if verbose:
|
||||
for item in formatted_results[:5]: # Show first 5 items
|
||||
self.stdout.write(
|
||||
f" {item['name']} (score: {item.get('views_change', 'N/A')})")
|
||||
f" {item['name']} (score: {item.get('views_change', 'N/A')})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating trending content: {e}", exc_info=True)
|
||||
raise CommandError(f"Failed to calculate trending content: {e}")
|
||||
|
||||
def _calculate_trending_parks(self, current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
|
||||
def _calculate_trending_parks(
|
||||
self, current_period_hours: int, previous_period_hours: int, limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Calculate trending scores for parks using real data."""
|
||||
parks = Park.objects.filter(
|
||||
status="OPERATING").select_related("location", "operator")
|
||||
parks = Park.objects.filter(status="OPERATING").select_related(
|
||||
"location", "operator"
|
||||
)
|
||||
|
||||
trending_parks = []
|
||||
|
||||
for park in parks:
|
||||
try:
|
||||
score = self._calculate_content_score(
|
||||
park, "park", current_period_hours, previous_period_hours)
|
||||
park, "park", current_period_hours, previous_period_hours
|
||||
)
|
||||
if score > 0: # Only include items with positive trending scores
|
||||
trending_parks.append({
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"trending_score": score,
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"rating": float(park.average_rating) if park.average_rating else 0.0,
|
||||
"date_opened": park.opening_date.isoformat() if park.opening_date else "",
|
||||
"url": park.url,
|
||||
})
|
||||
trending_parks.append(
|
||||
{
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"trending_score": score,
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"rating": (
|
||||
float(park.average_rating)
|
||||
if park.average_rating
|
||||
else 0.0
|
||||
),
|
||||
"date_opened": (
|
||||
park.opening_date.isoformat()
|
||||
if park.opening_date
|
||||
else ""
|
||||
),
|
||||
"url": park.url,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating score for park {park.id}: {e}")
|
||||
|
||||
return trending_parks
|
||||
|
||||
def _calculate_trending_rides(self, current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
|
||||
def _calculate_trending_rides(
|
||||
self, current_period_hours: int, previous_period_hours: int, limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Calculate trending scores for rides using real data."""
|
||||
rides = Ride.objects.filter(status="OPERATING").select_related(
|
||||
"park", "park__location")
|
||||
"park", "park__location"
|
||||
)
|
||||
|
||||
trending_rides = []
|
||||
|
||||
for ride in rides:
|
||||
try:
|
||||
score = self._calculate_content_score(
|
||||
ride, "ride", current_period_hours, previous_period_hours)
|
||||
ride, "ride", current_period_hours, previous_period_hours
|
||||
)
|
||||
if score > 0: # Only include items with positive trending scores
|
||||
trending_rides.append({
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"trending_score": score,
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"rating": float(ride.average_rating) if ride.average_rating else 0.0,
|
||||
"date_opened": ride.opening_date.isoformat() if ride.opening_date else "",
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
})
|
||||
trending_rides.append(
|
||||
{
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"trending_score": score,
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"rating": (
|
||||
float(ride.average_rating)
|
||||
if ride.average_rating
|
||||
else 0.0
|
||||
),
|
||||
"date_opened": (
|
||||
ride.opening_date.isoformat()
|
||||
if ride.opening_date
|
||||
else ""
|
||||
),
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
|
||||
|
||||
return trending_rides
|
||||
|
||||
def _calculate_content_score(self, content_obj: Any, content_type: str, current_period_hours: int, previous_period_hours: int) -> float:
|
||||
def _calculate_content_score(
|
||||
self,
|
||||
content_obj: Any,
|
||||
content_type: str,
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> float:
|
||||
"""Calculate weighted trending score for content object using real analytics data."""
|
||||
try:
|
||||
# Get content type for PageView queries
|
||||
@@ -176,7 +208,8 @@ class Command(BaseCommand):
|
||||
|
||||
# 1. View Growth Score (40% weight)
|
||||
view_growth_score = self._calculate_view_growth_score(
|
||||
ct, content_obj.id, current_period_hours, previous_period_hours)
|
||||
ct, content_obj.id, current_period_hours, previous_period_hours
|
||||
)
|
||||
|
||||
# 2. Rating Score (30% weight)
|
||||
rating_score = self._calculate_rating_score(content_obj)
|
||||
@@ -186,31 +219,41 @@ class Command(BaseCommand):
|
||||
|
||||
# 4. Popularity Score (10% weight)
|
||||
popularity_score = self._calculate_popularity_score(
|
||||
ct, content_obj.id, current_period_hours)
|
||||
ct, content_obj.id, current_period_hours
|
||||
)
|
||||
|
||||
# Calculate weighted final score
|
||||
final_score = (
|
||||
view_growth_score * 0.4 +
|
||||
rating_score * 0.3 +
|
||||
recency_score * 0.2 +
|
||||
popularity_score * 0.1
|
||||
view_growth_score * 0.4
|
||||
+ rating_score * 0.3
|
||||
+ recency_score * 0.2
|
||||
+ popularity_score * 0.1
|
||||
)
|
||||
|
||||
return final_score
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error calculating score for {content_type} {content_obj.id}: {e}")
|
||||
f"Error calculating score for {content_type} {content_obj.id}: {e}"
|
||||
)
|
||||
return 0.0
|
||||
|
||||
def _calculate_view_growth_score(self, content_type: ContentType, object_id: int, current_period_hours: int, previous_period_hours: int) -> float:
|
||||
def _calculate_view_growth_score(
|
||||
self,
|
||||
content_type: ContentType,
|
||||
object_id: int,
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> float:
|
||||
"""Calculate normalized view growth score using real PageView data."""
|
||||
try:
|
||||
current_views, previous_views, growth_percentage = PageView.get_views_growth(
|
||||
content_type,
|
||||
object_id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
current_views, previous_views, growth_percentage = (
|
||||
PageView.get_views_growth(
|
||||
content_type,
|
||||
object_id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
)
|
||||
)
|
||||
|
||||
if previous_views == 0:
|
||||
@@ -218,8 +261,9 @@ class Command(BaseCommand):
|
||||
return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0
|
||||
|
||||
# Normalize growth percentage to 0-1 scale
|
||||
normalized_growth = min(growth_percentage / 500.0,
|
||||
1.0) if growth_percentage > 0 else 0.0
|
||||
normalized_growth = (
|
||||
min(growth_percentage / 500.0, 1.0) if growth_percentage > 0 else 0.0
|
||||
)
|
||||
return max(normalized_growth, 0.0)
|
||||
|
||||
except Exception as e:
|
||||
@@ -272,11 +316,14 @@ class Command(BaseCommand):
|
||||
logger.warning(f"Error calculating recency score: {e}")
|
||||
return 0.5
|
||||
|
||||
def _calculate_popularity_score(self, content_type: ContentType, object_id: int, hours: int) -> float:
|
||||
def _calculate_popularity_score(
|
||||
self, content_type: ContentType, object_id: int, hours: int
|
||||
) -> float:
|
||||
"""Calculate popularity score based on total view count."""
|
||||
try:
|
||||
total_views = PageView.get_total_views_count(
|
||||
content_type, object_id, hours=hours)
|
||||
content_type, object_id, hours=hours
|
||||
)
|
||||
|
||||
# Normalize views to 0-1 scale
|
||||
if total_views == 0:
|
||||
@@ -290,7 +337,12 @@ class Command(BaseCommand):
|
||||
logger.warning(f"Error calculating popularity score: {e}")
|
||||
return 0.0
|
||||
|
||||
def _format_trending_results(self, trending_items: List[Dict[str, Any]], current_period_hours: int, previous_period_hours: int) -> List[Dict[str, Any]]:
|
||||
def _format_trending_results(
|
||||
self,
|
||||
trending_items: List[Dict[str, Any]],
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format trending results for frontend consumption."""
|
||||
formatted_results = []
|
||||
|
||||
@@ -299,11 +351,13 @@ class Command(BaseCommand):
|
||||
# Get view change for display
|
||||
content_obj = item["content_object"]
|
||||
ct = ContentType.objects.get_for_model(content_obj)
|
||||
current_views, previous_views, growth_percentage = PageView.get_views_growth(
|
||||
ct,
|
||||
content_obj.id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
current_views, previous_views, growth_percentage = (
|
||||
PageView.get_views_growth(
|
||||
ct,
|
||||
content_obj.id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
)
|
||||
)
|
||||
|
||||
# Format exactly as frontend expects
|
||||
|
||||
@@ -305,7 +305,7 @@ class CacheMonitor:
|
||||
stats["cache_backend"] = cache_backend
|
||||
stats["message"] = f"Cache statistics not available for {cache_backend}"
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Don't log as error since this is expected for non-Redis backends
|
||||
cache_backend = self.cache_service.default_cache.__class__.__name__
|
||||
stats["cache_backend"] = cache_backend
|
||||
|
||||
@@ -48,7 +48,11 @@ class ParkLocationAdapter(BaseLocationAdapter):
|
||||
self, location_obj: ParkLocation
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Convert ParkLocation to UnifiedLocation."""
|
||||
if not location_obj.point or location_obj.latitude is None or location_obj.longitude is None:
|
||||
if (
|
||||
not location_obj.point
|
||||
or location_obj.latitude is None
|
||||
or location_obj.longitude is None
|
||||
):
|
||||
return None
|
||||
|
||||
park = location_obj.park
|
||||
@@ -175,7 +179,11 @@ class RideLocationAdapter(BaseLocationAdapter):
|
||||
self, location_obj: RideLocation
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Convert RideLocation to UnifiedLocation."""
|
||||
if not location_obj.point or location_obj.latitude is None or location_obj.longitude is None:
|
||||
if (
|
||||
not location_obj.point
|
||||
or location_obj.latitude is None
|
||||
or location_obj.longitude is None
|
||||
):
|
||||
return None
|
||||
|
||||
ride = location_obj.ride
|
||||
|
||||
@@ -86,12 +86,14 @@ class TrendingService:
|
||||
|
||||
if content_type in ["all", "parks"]:
|
||||
park_items = self._calculate_trending_parks(
|
||||
limit * 2 if content_type == "all" else limit)
|
||||
limit * 2 if content_type == "all" else limit
|
||||
)
|
||||
trending_items.extend(park_items)
|
||||
|
||||
if content_type in ["all", "rides"]:
|
||||
ride_items = self._calculate_trending_rides(
|
||||
limit * 2 if content_type == "all" else limit)
|
||||
limit * 2 if content_type == "all" else limit
|
||||
)
|
||||
trending_items.extend(ride_items)
|
||||
|
||||
# Sort by trending score and apply limit
|
||||
@@ -105,7 +107,8 @@ class TrendingService:
|
||||
cache.set(cache_key, formatted_results, self.CACHE_TTL)
|
||||
|
||||
self.logger.info(
|
||||
f"Calculated {len(formatted_results)} trending items for {content_type}")
|
||||
f"Calculated {len(formatted_results)} trending items for {content_type}"
|
||||
)
|
||||
return formatted_results
|
||||
|
||||
except Exception as e:
|
||||
@@ -150,12 +153,14 @@ class TrendingService:
|
||||
|
||||
if content_type in ["all", "parks"]:
|
||||
parks = self._get_new_parks(
|
||||
cutoff_date, limit * 2 if content_type == "all" else limit)
|
||||
cutoff_date, limit * 2 if content_type == "all" else limit
|
||||
)
|
||||
new_items.extend(parks)
|
||||
|
||||
if content_type in ["all", "rides"]:
|
||||
rides = self._get_new_rides(
|
||||
cutoff_date, limit * 2 if content_type == "all" else limit)
|
||||
cutoff_date, limit * 2 if content_type == "all" else limit
|
||||
)
|
||||
new_items.extend(rides)
|
||||
|
||||
# Sort by date added (most recent first) and apply limit
|
||||
@@ -169,7 +174,8 @@ class TrendingService:
|
||||
cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes
|
||||
|
||||
self.logger.info(
|
||||
f"Calculated {len(formatted_results)} new items for {content_type}")
|
||||
f"Calculated {len(formatted_results)} new items for {content_type}"
|
||||
)
|
||||
return formatted_results
|
||||
|
||||
except Exception as e:
|
||||
@@ -198,18 +204,20 @@ class TrendingService:
|
||||
state = ""
|
||||
country = ""
|
||||
try:
|
||||
location = getattr(park, 'location', None)
|
||||
location = getattr(park, "location", None)
|
||||
if location:
|
||||
city = getattr(location, 'city', '') or ""
|
||||
state = getattr(location, 'state', '') or ""
|
||||
country = getattr(location, 'country', '') or ""
|
||||
city = getattr(location, "city", "") or ""
|
||||
state = getattr(location, "state", "") or ""
|
||||
country = getattr(location, "country", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get card image URL
|
||||
card_image_url = ""
|
||||
if park.card_image and hasattr(park.card_image, 'image'):
|
||||
card_image_url = park.card_image.image.url if park.card_image.image else ""
|
||||
if park.card_image and hasattr(park.card_image, "image"):
|
||||
card_image_url = (
|
||||
park.card_image.image.url if park.card_image.image else ""
|
||||
)
|
||||
|
||||
# Get primary company (operator)
|
||||
primary_company = park.operator.name if park.operator else ""
|
||||
@@ -229,7 +237,9 @@ class TrendingService:
|
||||
if park.average_rating
|
||||
else 0.0
|
||||
),
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"date_opened": (
|
||||
opening_date.isoformat() if opening_date else ""
|
||||
),
|
||||
"url": park.url,
|
||||
"card_image": card_image_url,
|
||||
"city": city,
|
||||
@@ -262,8 +272,10 @@ class TrendingService:
|
||||
|
||||
# Get card image URL
|
||||
card_image_url = ""
|
||||
if ride.card_image and hasattr(ride.card_image, 'image'):
|
||||
card_image_url = ride.card_image.image.url if ride.card_image.image else ""
|
||||
if ride.card_image and hasattr(ride.card_image, "image"):
|
||||
card_image_url = (
|
||||
ride.card_image.image.url if ride.card_image.image else ""
|
||||
)
|
||||
|
||||
trending_rides.append(
|
||||
{
|
||||
@@ -280,7 +292,9 @@ class TrendingService:
|
||||
if ride.average_rating
|
||||
else 0.0
|
||||
),
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
"date_opened": (
|
||||
opening_date.isoformat() if opening_date else ""
|
||||
),
|
||||
"url": ride.url,
|
||||
"park_url": ride.park.url if ride.park else "",
|
||||
"card_image": card_image_url,
|
||||
@@ -474,18 +488,20 @@ class TrendingService:
|
||||
state = ""
|
||||
country = ""
|
||||
try:
|
||||
location = getattr(park, 'location', None)
|
||||
location = getattr(park, "location", None)
|
||||
if location:
|
||||
city = getattr(location, 'city', '') or ""
|
||||
state = getattr(location, 'state', '') or ""
|
||||
country = getattr(location, 'country', '') or ""
|
||||
city = getattr(location, "city", "") or ""
|
||||
state = getattr(location, "state", "") or ""
|
||||
country = getattr(location, "country", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get card image URL
|
||||
card_image_url = ""
|
||||
if park.card_image and hasattr(park.card_image, 'image'):
|
||||
card_image_url = park.card_image.image.url if park.card_image.image else ""
|
||||
if park.card_image and hasattr(park.card_image, "image"):
|
||||
card_image_url = (
|
||||
park.card_image.image.url if park.card_image.image else ""
|
||||
)
|
||||
|
||||
# Get primary company (operator)
|
||||
primary_company = park.operator.name if park.operator else ""
|
||||
@@ -543,8 +559,10 @@ class TrendingService:
|
||||
|
||||
# Get card image URL
|
||||
card_image_url = ""
|
||||
if ride.card_image and hasattr(ride.card_image, 'image'):
|
||||
card_image_url = ride.card_image.image.url if ride.card_image.image else ""
|
||||
if ride.card_image and hasattr(ride.card_image, "image"):
|
||||
card_image_url = (
|
||||
ride.card_image.image.url if ride.card_image.image else ""
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
|
||||
@@ -7,13 +7,12 @@ All tasks run asynchronously to avoid blocking the main application.
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
from typing import Dict, List, Any
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from django.core.cache import cache
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q, Count, Avg, F
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.core.analytics import PageView
|
||||
from apps.parks.models import Park
|
||||
@@ -23,7 +22,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def calculate_trending_content(self, content_type: str = "all", limit: int = 50) -> Dict[str, Any]:
|
||||
def calculate_trending_content(
|
||||
self, content_type: str = "all", limit: int = 50
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate trending content using real analytics data.
|
||||
|
||||
@@ -53,7 +54,7 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
park_items = _calculate_trending_parks(
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
limit if content_type == "parks" else limit * 2
|
||||
limit if content_type == "parks" else limit * 2,
|
||||
)
|
||||
trending_items.extend(park_items)
|
||||
|
||||
@@ -61,7 +62,7 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
ride_items = _calculate_trending_rides(
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
limit if content_type == "rides" else limit * 2
|
||||
limit if content_type == "rides" else limit * 2,
|
||||
)
|
||||
trending_items.extend(ride_items)
|
||||
|
||||
@@ -71,14 +72,16 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
|
||||
# Format results for API consumption
|
||||
formatted_results = _format_trending_results(
|
||||
trending_items, current_period_hours, previous_period_hours)
|
||||
trending_items, current_period_hours, previous_period_hours
|
||||
)
|
||||
|
||||
# Cache results
|
||||
cache_key = f"trending:calculated:{content_type}:{limit}"
|
||||
cache.set(cache_key, formatted_results, 3600) # Cache for 1 hour
|
||||
|
||||
logger.info(
|
||||
f"Calculated {len(formatted_results)} trending items for {content_type}")
|
||||
f"Calculated {len(formatted_results)} trending items for {content_type}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -95,7 +98,9 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||||
def calculate_new_content(self, content_type: str = "all", days_back: int = 30, limit: int = 50) -> Dict[str, Any]:
|
||||
def calculate_new_content(
|
||||
self, content_type: str = "all", days_back: int = 30, limit: int = 50
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate new content based on opening dates and creation dates.
|
||||
|
||||
@@ -115,12 +120,14 @@ def calculate_new_content(self, content_type: str = "all", days_back: int = 30,
|
||||
|
||||
if content_type in ["all", "parks"]:
|
||||
parks = _get_new_parks(
|
||||
cutoff_date, limit if content_type == "parks" else limit * 2)
|
||||
cutoff_date, limit if content_type == "parks" else limit * 2
|
||||
)
|
||||
new_items.extend(parks)
|
||||
|
||||
if content_type in ["all", "rides"]:
|
||||
rides = _get_new_rides(
|
||||
cutoff_date, limit if content_type == "rides" else limit * 2)
|
||||
cutoff_date, limit if content_type == "rides" else limit * 2
|
||||
)
|
||||
new_items.extend(rides)
|
||||
|
||||
# Sort by date added (most recent first) and apply limit
|
||||
@@ -177,7 +184,9 @@ def warm_trending_cache(self) -> Dict[str, Any]:
|
||||
calculate_new_content.delay(**query)
|
||||
|
||||
results[f"trending_{query['content_type']}_{query['limit']}"] = "scheduled"
|
||||
results[f"new_content_{query['content_type']}_{query['limit']}"] = "scheduled"
|
||||
results[f"new_content_{query['content_type']}_{query['limit']}"] = (
|
||||
"scheduled"
|
||||
)
|
||||
|
||||
logger.info("Trending cache warming completed")
|
||||
|
||||
@@ -197,70 +206,93 @@ def warm_trending_cache(self) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _calculate_trending_parks(current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
|
||||
def _calculate_trending_parks(
|
||||
current_period_hours: int, previous_period_hours: int, limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Calculate trending scores for parks using real data."""
|
||||
parks = Park.objects.filter(
|
||||
status="OPERATING").select_related("location", "operator")
|
||||
parks = Park.objects.filter(status="OPERATING").select_related(
|
||||
"location", "operator"
|
||||
)
|
||||
|
||||
trending_parks = []
|
||||
|
||||
for park in parks:
|
||||
try:
|
||||
score = _calculate_content_score(
|
||||
park, "park", current_period_hours, previous_period_hours)
|
||||
park, "park", current_period_hours, previous_period_hours
|
||||
)
|
||||
if score > 0: # Only include items with positive trending scores
|
||||
trending_parks.append({
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"trending_score": score,
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"location": park.formatted_location if hasattr(park, "location") else "",
|
||||
"category": "park",
|
||||
"rating": float(park.average_rating) if park.average_rating else 0.0,
|
||||
})
|
||||
trending_parks.append(
|
||||
{
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"trending_score": score,
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"location": (
|
||||
park.formatted_location if hasattr(park, "location") else ""
|
||||
),
|
||||
"category": "park",
|
||||
"rating": (
|
||||
float(park.average_rating) if park.average_rating else 0.0
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating score for park {park.id}: {e}")
|
||||
|
||||
return trending_parks
|
||||
|
||||
|
||||
def _calculate_trending_rides(current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
|
||||
def _calculate_trending_rides(
|
||||
current_period_hours: int, previous_period_hours: int, limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Calculate trending scores for rides using real data."""
|
||||
rides = Ride.objects.filter(status="OPERATING").select_related(
|
||||
"park", "park__location")
|
||||
"park", "park__location"
|
||||
)
|
||||
|
||||
trending_rides = []
|
||||
|
||||
for ride in rides:
|
||||
try:
|
||||
score = _calculate_content_score(
|
||||
ride, "ride", current_period_hours, previous_period_hours)
|
||||
ride, "ride", current_period_hours, previous_period_hours
|
||||
)
|
||||
if score > 0: # Only include items with positive trending scores
|
||||
# Get location from park
|
||||
location = ""
|
||||
if ride.park and hasattr(ride.park, "location") and ride.park.location:
|
||||
location = ride.park.formatted_location
|
||||
|
||||
trending_rides.append({
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"trending_score": score,
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"location": location,
|
||||
"category": "ride",
|
||||
"rating": float(ride.average_rating) if ride.average_rating else 0.0,
|
||||
})
|
||||
trending_rides.append(
|
||||
{
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"trending_score": score,
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"location": location,
|
||||
"category": "ride",
|
||||
"rating": (
|
||||
float(ride.average_rating) if ride.average_rating else 0.0
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
|
||||
|
||||
return trending_rides
|
||||
|
||||
|
||||
def _calculate_content_score(content_obj: Any, content_type: str, current_period_hours: int, previous_period_hours: int) -> float:
|
||||
def _calculate_content_score(
|
||||
content_obj: Any,
|
||||
content_type: str,
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate weighted trending score for content object using real analytics data.
|
||||
|
||||
@@ -279,7 +311,8 @@ def _calculate_content_score(content_obj: Any, content_type: str, current_period
|
||||
|
||||
# 1. View Growth Score (40% weight)
|
||||
view_growth_score = _calculate_view_growth_score(
|
||||
ct, content_obj.id, current_period_hours, previous_period_hours)
|
||||
ct, content_obj.id, current_period_hours, previous_period_hours
|
||||
)
|
||||
|
||||
# 2. Rating Score (30% weight)
|
||||
rating_score = _calculate_rating_score(content_obj)
|
||||
@@ -289,14 +322,15 @@ def _calculate_content_score(content_obj: Any, content_type: str, current_period
|
||||
|
||||
# 4. Popularity Score (10% weight)
|
||||
popularity_score = _calculate_popularity_score(
|
||||
ct, content_obj.id, current_period_hours)
|
||||
ct, content_obj.id, current_period_hours
|
||||
)
|
||||
|
||||
# Calculate weighted final score
|
||||
final_score = (
|
||||
view_growth_score * 0.4 +
|
||||
rating_score * 0.3 +
|
||||
recency_score * 0.2 +
|
||||
popularity_score * 0.1
|
||||
view_growth_score * 0.4
|
||||
+ rating_score * 0.3
|
||||
+ recency_score * 0.2
|
||||
+ popularity_score * 0.1
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
@@ -310,11 +344,17 @@ def _calculate_content_score(content_obj: Any, content_type: str, current_period
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error calculating score for {content_type} {content_obj.id}: {e}")
|
||||
f"Error calculating score for {content_type} {content_obj.id}: {e}"
|
||||
)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _calculate_view_growth_score(content_type: ContentType, object_id: int, current_period_hours: int, previous_period_hours: int) -> float:
|
||||
def _calculate_view_growth_score(
|
||||
content_type: ContentType,
|
||||
object_id: int,
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> float:
|
||||
"""Calculate normalized view growth score using real PageView data."""
|
||||
try:
|
||||
current_views, previous_views, growth_percentage = PageView.get_views_growth(
|
||||
@@ -330,8 +370,9 @@ def _calculate_view_growth_score(content_type: ContentType, object_id: int, curr
|
||||
|
||||
# Normalize growth percentage to 0-1 scale
|
||||
# 100% growth = 0.5, 500% growth = 1.0
|
||||
normalized_growth = min(growth_percentage / 500.0,
|
||||
1.0) if growth_percentage > 0 else 0.0
|
||||
normalized_growth = (
|
||||
min(growth_percentage / 500.0, 1.0) if growth_percentage > 0 else 0.0
|
||||
)
|
||||
return max(normalized_growth, 0.0)
|
||||
|
||||
except Exception as e:
|
||||
@@ -389,11 +430,14 @@ def _calculate_recency_score(content_obj: Any) -> float:
|
||||
return 0.5
|
||||
|
||||
|
||||
def _calculate_popularity_score(content_type: ContentType, object_id: int, hours: int) -> float:
|
||||
def _calculate_popularity_score(
|
||||
content_type: ContentType, object_id: int, hours: int
|
||||
) -> float:
|
||||
"""Calculate popularity score based on total view count."""
|
||||
try:
|
||||
total_views = PageView.get_total_views_count(
|
||||
content_type, object_id, hours=hours)
|
||||
content_type, object_id, hours=hours
|
||||
)
|
||||
|
||||
# Normalize views to 0-1 scale
|
||||
# 0 views = 0.0, 100 views = 0.5, 1000+ views = 1.0
|
||||
@@ -431,17 +475,19 @@ def _get_new_parks(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
|
||||
if opening_date and isinstance(opening_date, datetime):
|
||||
opening_date = opening_date.date()
|
||||
|
||||
results.append({
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"id": park.pk,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"content_object": park,
|
||||
"content_type": "park",
|
||||
"id": park.pk,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"park": park.name, # For parks, park field is the park name itself
|
||||
"category": "park",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -460,7 +506,8 @@ def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
|
||||
results = []
|
||||
for ride in new_rides:
|
||||
date_added = getattr(ride, "opening_date", None) or getattr(
|
||||
ride, "created_at", None)
|
||||
ride, "created_at", None
|
||||
)
|
||||
if date_added:
|
||||
if isinstance(date_added, datetime):
|
||||
date_added = date_added.date()
|
||||
@@ -469,22 +516,28 @@ def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
|
||||
if opening_date and isinstance(opening_date, datetime):
|
||||
opening_date = opening_date.date()
|
||||
|
||||
results.append({
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"content_object": ride,
|
||||
"content_type": "ride",
|
||||
"id": ride.pk,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park": ride.park.name if ride.park else "",
|
||||
"category": "ride",
|
||||
"date_added": date_added.isoformat() if date_added else "",
|
||||
"date_opened": opening_date.isoformat() if opening_date else "",
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _format_trending_results(trending_items: List[Dict[str, Any]], current_period_hours: int, previous_period_hours: int) -> List[Dict[str, Any]]:
|
||||
def _format_trending_results(
|
||||
trending_items: List[Dict[str, Any]],
|
||||
current_period_hours: int,
|
||||
previous_period_hours: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format trending results for frontend consumption."""
|
||||
formatted_results = []
|
||||
|
||||
@@ -493,11 +546,13 @@ def _format_trending_results(trending_items: List[Dict[str, Any]], current_perio
|
||||
# Get view change for display
|
||||
content_obj = item["content_object"]
|
||||
ct = ContentType.objects.get_for_model(content_obj)
|
||||
current_views, previous_views, growth_percentage = PageView.get_views_growth(
|
||||
ct,
|
||||
content_obj.id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
current_views, previous_views, growth_percentage = (
|
||||
PageView.get_views_growth(
|
||||
ct,
|
||||
content_obj.id,
|
||||
current_period_hours,
|
||||
previous_period_hours,
|
||||
)
|
||||
)
|
||||
|
||||
# Format exactly as frontend expects
|
||||
@@ -525,7 +580,9 @@ def _format_trending_results(trending_items: List[Dict[str, Any]], current_perio
|
||||
return formatted_results
|
||||
|
||||
|
||||
def _format_new_content_results(new_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _format_new_content_results(
|
||||
new_items: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Format new content results for frontend consumption."""
|
||||
formatted_results = []
|
||||
|
||||
|
||||
429
backend/apps/moderation/filters.py
Normal file
429
backend/apps/moderation/filters.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Moderation Filters
|
||||
|
||||
This module contains Django filter classes for the moderation system,
|
||||
providing comprehensive filtering capabilities for all moderation models.
|
||||
"""
|
||||
|
||||
import django_filters
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from .models import (
|
||||
ModerationReport,
|
||||
ModerationQueue,
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ModerationReportFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationReport model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.STATUS_CHOICES, help_text="Filter by report status"
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.PRIORITY_CHOICES, help_text="Filter by report priority"
|
||||
)
|
||||
|
||||
# Report type filters
|
||||
report_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.REPORT_TYPE_CHOICES, help_text="Filter by report type"
|
||||
)
|
||||
|
||||
# User filters
|
||||
reported_by = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.all(), help_text="Filter by user who made the report"
|
||||
)
|
||||
|
||||
assigned_moderator = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by assigned moderator",
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter reports created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter reports created before this date",
|
||||
)
|
||||
|
||||
resolved_after = django_filters.DateTimeFilter(
|
||||
field_name="resolved_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter reports resolved after this date",
|
||||
)
|
||||
|
||||
resolved_before = django_filters.DateTimeFilter(
|
||||
field_name="resolved_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter reports resolved before this date",
|
||||
)
|
||||
|
||||
# Content type filters
|
||||
content_type = django_filters.CharFilter(
|
||||
field_name="content_type__model",
|
||||
help_text="Filter by content type (e.g., 'park', 'ride', 'review')",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
unassigned = django_filters.BooleanFilter(
|
||||
method="filter_unassigned", help_text="Filter for unassigned reports"
|
||||
)
|
||||
|
||||
overdue = django_filters.BooleanFilter(
|
||||
method="filter_overdue", help_text="Filter for overdue reports based on SLA"
|
||||
)
|
||||
|
||||
has_resolution = django_filters.BooleanFilter(
|
||||
method="filter_has_resolution",
|
||||
help_text="Filter reports with/without resolution",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"status",
|
||||
"priority",
|
||||
"report_type",
|
||||
"reported_by",
|
||||
"assigned_moderator",
|
||||
"content_type",
|
||||
"unassigned",
|
||||
"overdue",
|
||||
"has_resolution",
|
||||
]
|
||||
|
||||
def filter_unassigned(self, queryset, name, value):
|
||||
"""Filter for unassigned reports."""
|
||||
if value:
|
||||
return queryset.filter(assigned_moderator__isnull=True)
|
||||
return queryset.filter(assigned_moderator__isnull=False)
|
||||
|
||||
def filter_overdue(self, queryset, name, value):
|
||||
"""Filter for overdue reports based on SLA."""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
now = timezone.now()
|
||||
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
||||
|
||||
overdue_ids = []
|
||||
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
|
||||
hours_since_created = (now - report.created_at).total_seconds() / 3600
|
||||
if hours_since_created > sla_hours.get(report.priority, 24):
|
||||
overdue_ids.append(report.id)
|
||||
|
||||
return queryset.filter(id__in=overdue_ids)
|
||||
|
||||
def filter_has_resolution(self, queryset, name, value):
|
||||
"""Filter reports with/without resolution."""
|
||||
if value:
|
||||
return queryset.exclude(
|
||||
resolution_action__isnull=True, resolution_action=""
|
||||
)
|
||||
return queryset.filter(
|
||||
Q(resolution_action__isnull=True) | Q(resolution_action="")
|
||||
)
|
||||
|
||||
|
||||
class ModerationQueueFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationQueue model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.STATUS_CHOICES, help_text="Filter by queue item status"
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.PRIORITY_CHOICES,
|
||||
help_text="Filter by queue item priority",
|
||||
)
|
||||
|
||||
# Item type filters
|
||||
item_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.ITEM_TYPE_CHOICES, help_text="Filter by queue item type"
|
||||
)
|
||||
|
||||
# Assignment filters
|
||||
assigned_to = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by assigned moderator",
|
||||
)
|
||||
|
||||
unassigned = django_filters.BooleanFilter(
|
||||
method="filter_unassigned", help_text="Filter for unassigned queue items"
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter items created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter items created before this date",
|
||||
)
|
||||
|
||||
assigned_after = django_filters.DateTimeFilter(
|
||||
field_name="assigned_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter items assigned after this date",
|
||||
)
|
||||
|
||||
assigned_before = django_filters.DateTimeFilter(
|
||||
field_name="assigned_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter items assigned before this date",
|
||||
)
|
||||
|
||||
# Content type filters
|
||||
content_type = django_filters.CharFilter(
|
||||
field_name="content_type__model", help_text="Filter by content type"
|
||||
)
|
||||
|
||||
# Related report filters
|
||||
has_related_report = django_filters.BooleanFilter(
|
||||
method="filter_has_related_report",
|
||||
help_text="Filter items with/without related reports",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationQueue
|
||||
fields = [
|
||||
"status",
|
||||
"priority",
|
||||
"item_type",
|
||||
"assigned_to",
|
||||
"unassigned",
|
||||
"content_type",
|
||||
"has_related_report",
|
||||
]
|
||||
|
||||
def filter_unassigned(self, queryset, name, value):
|
||||
"""Filter for unassigned queue items."""
|
||||
if value:
|
||||
return queryset.filter(assigned_to__isnull=True)
|
||||
return queryset.filter(assigned_to__isnull=False)
|
||||
|
||||
def filter_has_related_report(self, queryset, name, value):
|
||||
"""Filter items with/without related reports."""
|
||||
if value:
|
||||
return queryset.filter(related_report__isnull=False)
|
||||
return queryset.filter(related_report__isnull=True)
|
||||
|
||||
|
||||
class ModerationActionFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationAction model."""
|
||||
|
||||
# Action type filters
|
||||
action_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationAction.ACTION_TYPE_CHOICES, help_text="Filter by action type"
|
||||
)
|
||||
|
||||
# User filters
|
||||
moderator = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by moderator who took the action",
|
||||
)
|
||||
|
||||
target_user = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.all(), help_text="Filter by target user"
|
||||
)
|
||||
|
||||
# Status filters
|
||||
is_active = django_filters.BooleanFilter(help_text="Filter by active status")
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter actions created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter actions created before this date",
|
||||
)
|
||||
|
||||
expires_after = django_filters.DateTimeFilter(
|
||||
field_name="expires_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter actions expiring after this date",
|
||||
)
|
||||
|
||||
expires_before = django_filters.DateTimeFilter(
|
||||
field_name="expires_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter actions expiring before this date",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
expired = django_filters.BooleanFilter(
|
||||
method="filter_expired", help_text="Filter for expired actions"
|
||||
)
|
||||
|
||||
expiring_soon = django_filters.BooleanFilter(
|
||||
method="filter_expiring_soon",
|
||||
help_text="Filter for actions expiring within 24 hours",
|
||||
)
|
||||
|
||||
has_related_report = django_filters.BooleanFilter(
|
||||
method="filter_has_related_report",
|
||||
help_text="Filter actions with/without related reports",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationAction
|
||||
fields = [
|
||||
"action_type",
|
||||
"moderator",
|
||||
"target_user",
|
||||
"is_active",
|
||||
"expired",
|
||||
"expiring_soon",
|
||||
"has_related_report",
|
||||
]
|
||||
|
||||
def filter_expired(self, queryset, name, value):
|
||||
"""Filter for expired actions."""
|
||||
now = timezone.now()
|
||||
if value:
|
||||
return queryset.filter(expires_at__lte=now)
|
||||
return queryset.filter(Q(expires_at__gt=now) | Q(expires_at__isnull=True))
|
||||
|
||||
def filter_expiring_soon(self, queryset, name, value):
|
||||
"""Filter for actions expiring within 24 hours."""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
now = timezone.now()
|
||||
soon = now + timedelta(hours=24)
|
||||
return queryset.filter(expires_at__gt=now, expires_at__lte=soon, is_active=True)
|
||||
|
||||
def filter_has_related_report(self, queryset, name, value):
|
||||
"""Filter actions with/without related reports."""
|
||||
if value:
|
||||
return queryset.filter(related_report__isnull=False)
|
||||
return queryset.filter(related_report__isnull=True)
|
||||
|
||||
|
||||
class BulkOperationFilter(django_filters.FilterSet):
|
||||
"""Filter for BulkOperation model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.STATUS_CHOICES, help_text="Filter by operation status"
|
||||
)
|
||||
|
||||
# Operation type filters
|
||||
operation_type = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.OPERATION_TYPE_CHOICES,
|
||||
help_text="Filter by operation type",
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.PRIORITY_CHOICES, help_text="Filter by operation priority"
|
||||
)
|
||||
|
||||
# User filters
|
||||
created_by = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by user who created the operation",
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations created before this date",
|
||||
)
|
||||
|
||||
started_after = django_filters.DateTimeFilter(
|
||||
field_name="started_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations started after this date",
|
||||
)
|
||||
|
||||
started_before = django_filters.DateTimeFilter(
|
||||
field_name="started_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations started before this date",
|
||||
)
|
||||
|
||||
completed_after = django_filters.DateTimeFilter(
|
||||
field_name="completed_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations completed after this date",
|
||||
)
|
||||
|
||||
completed_before = django_filters.DateTimeFilter(
|
||||
field_name="completed_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations completed before this date",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
can_cancel = django_filters.BooleanFilter(
|
||||
help_text="Filter by cancellation capability"
|
||||
)
|
||||
|
||||
has_failures = django_filters.BooleanFilter(
|
||||
method="filter_has_failures",
|
||||
help_text="Filter operations with/without failures",
|
||||
)
|
||||
|
||||
in_progress = django_filters.BooleanFilter(
|
||||
method="filter_in_progress",
|
||||
help_text="Filter for operations currently in progress",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = BulkOperation
|
||||
fields = [
|
||||
"status",
|
||||
"operation_type",
|
||||
"priority",
|
||||
"created_by",
|
||||
"can_cancel",
|
||||
"has_failures",
|
||||
"in_progress",
|
||||
]
|
||||
|
||||
def filter_has_failures(self, queryset, name, value):
|
||||
"""Filter operations with/without failures."""
|
||||
if value:
|
||||
return queryset.filter(failed_items__gt=0)
|
||||
return queryset.filter(failed_items=0)
|
||||
|
||||
def filter_in_progress(self, queryset, name, value):
|
||||
"""Filter for operations currently in progress."""
|
||||
if value:
|
||||
return queryset.filter(status__in=["PENDING", "RUNNING"])
|
||||
return queryset.exclude(status__in=["PENDING", "RUNNING"])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,782 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 19:16
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
(
|
||||
"moderation",
|
||||
"0003_bulkoperation_bulkoperationevent_moderationaction_and_more",
|
||||
),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="moderationqueue",
|
||||
options={"ordering": ["priority", "created_at"]},
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="moderationqueue",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="moderationqueue",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="bulkoperation",
|
||||
name="moderation__operati_bc84d9_idx",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="moderationaction",
|
||||
name="moderation__action__7d7882_idx",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="moderationqueue",
|
||||
name="moderation__entity__7c66ff_idx",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="moderationqueue",
|
||||
name="moderation__flagged_169834_idx",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="moderationreport",
|
||||
name="moderation__reporte_04923f_idx",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="can_cancel",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Whether this operation can be cancelled"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="description",
|
||||
field=models.TextField(help_text="Description of what this operation does"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="estimated_duration_minutes",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, help_text="Estimated duration in minutes", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="failed_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items that failed"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="operation_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("UPDATE_PARKS", "Update Parks"),
|
||||
("UPDATE_RIDES", "Update Rides"),
|
||||
("IMPORT_DATA", "Import Data"),
|
||||
("EXPORT_DATA", "Export Data"),
|
||||
("MODERATE_CONTENT", "Moderate Content"),
|
||||
("USER_ACTIONS", "User Actions"),
|
||||
("CLEANUP", "Cleanup"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="parameters",
|
||||
field=models.JSONField(
|
||||
default=dict, help_text="Parameters for the operation"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="priority",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="processed_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items processed"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="results",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Results and output from the operation",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="schedule_for",
|
||||
field=models.DateTimeField(
|
||||
blank=True, help_text="When to run this operation", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="total_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of items to process"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="can_cancel",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Whether this operation can be cancelled"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="description",
|
||||
field=models.TextField(help_text="Description of what this operation does"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="estimated_duration_minutes",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, help_text="Estimated duration in minutes", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="failed_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items that failed"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="id",
|
||||
field=models.BigIntegerField(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="operation_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("UPDATE_PARKS", "Update Parks"),
|
||||
("UPDATE_RIDES", "Update Rides"),
|
||||
("IMPORT_DATA", "Import Data"),
|
||||
("EXPORT_DATA", "Export Data"),
|
||||
("MODERATE_CONTENT", "Moderate Content"),
|
||||
("USER_ACTIONS", "User Actions"),
|
||||
("CLEANUP", "Cleanup"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="parameters",
|
||||
field=models.JSONField(
|
||||
default=dict, help_text="Parameters for the operation"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="priority",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="processed_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items processed"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="results",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Results and output from the operation",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="schedule_for",
|
||||
field=models.DateTimeField(
|
||||
blank=True, help_text="When to run this operation", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="total_items",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of items to process"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="action_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("WARNING", "Warning"),
|
||||
("USER_SUSPENSION", "User Suspension"),
|
||||
("USER_BAN", "User Ban"),
|
||||
("CONTENT_REMOVAL", "Content Removal"),
|
||||
("CONTENT_EDIT", "Content Edit"),
|
||||
("CONTENT_RESTRICTION", "Content Restriction"),
|
||||
("ACCOUNT_RESTRICTION", "Account Restriction"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="details",
|
||||
field=models.TextField(help_text="Detailed explanation of the action"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="duration_hours",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Duration in hours for temporary actions",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="expires_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True, help_text="When this action expires", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="is_active",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Whether this action is currently active"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="reason",
|
||||
field=models.CharField(
|
||||
help_text="Brief reason for the action", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="action_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("WARNING", "Warning"),
|
||||
("USER_SUSPENSION", "User Suspension"),
|
||||
("USER_BAN", "User Ban"),
|
||||
("CONTENT_REMOVAL", "Content Removal"),
|
||||
("CONTENT_EDIT", "Content Edit"),
|
||||
("CONTENT_RESTRICTION", "Content Restriction"),
|
||||
("ACCOUNT_RESTRICTION", "Account Restriction"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="details",
|
||||
field=models.TextField(help_text="Detailed explanation of the action"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="duration_hours",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Duration in hours for temporary actions",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="expires_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True, help_text="When this action expires", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="is_active",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Whether this action is currently active"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="reason",
|
||||
field=models.CharField(
|
||||
help_text="Brief reason for the action", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="description",
|
||||
field=models.TextField(
|
||||
help_text="Detailed description of what needs to be done"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="entity_id",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, help_text="ID of the related entity", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="entity_preview",
|
||||
field=models.JSONField(
|
||||
blank=True, default=dict, help_text="Preview data for the entity"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="entity_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Type of entity (park, ride, user, etc.)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="estimated_review_time",
|
||||
field=models.PositiveIntegerField(
|
||||
default=30, help_text="Estimated time in minutes"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="flagged_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="flagged_queue_items",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="item_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("CONTENT_REVIEW", "Content Review"),
|
||||
("USER_REVIEW", "User Review"),
|
||||
("BULK_ACTION", "Bulk Action"),
|
||||
("POLICY_VIOLATION", "Policy Violation"),
|
||||
("APPEAL", "Appeal"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("IN_PROGRESS", "In Progress"),
|
||||
("COMPLETED", "Completed"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="tags",
|
||||
field=models.JSONField(
|
||||
blank=True, default=list, help_text="Tags for categorization"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="title",
|
||||
field=models.CharField(
|
||||
help_text="Brief title for the queue item", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="description",
|
||||
field=models.TextField(
|
||||
help_text="Detailed description of what needs to be done"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="entity_id",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, help_text="ID of the related entity", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="entity_preview",
|
||||
field=models.JSONField(
|
||||
blank=True, default=dict, help_text="Preview data for the entity"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="entity_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Type of entity (park, ride, user, etc.)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="estimated_review_time",
|
||||
field=models.PositiveIntegerField(
|
||||
default=30, help_text="Estimated time in minutes"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="flagged_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="item_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("CONTENT_REVIEW", "Content Review"),
|
||||
("USER_REVIEW", "User Review"),
|
||||
("BULK_ACTION", "Bulk Action"),
|
||||
("POLICY_VIOLATION", "Policy Violation"),
|
||||
("APPEAL", "Appeal"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("IN_PROGRESS", "In Progress"),
|
||||
("COMPLETED", "Completed"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="tags",
|
||||
field=models.JSONField(
|
||||
blank=True, default=list, help_text="Tags for categorization"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="title",
|
||||
field=models.CharField(
|
||||
help_text="Brief title for the queue item", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="description",
|
||||
field=models.TextField(help_text="Detailed description of the issue"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="evidence_urls",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="URLs to evidence (screenshots, etc.)",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="reason",
|
||||
field=models.CharField(
|
||||
help_text="Brief reason for the report", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="report_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("SPAM", "Spam"),
|
||||
("HARASSMENT", "Harassment"),
|
||||
("INAPPROPRIATE_CONTENT", "Inappropriate Content"),
|
||||
("MISINFORMATION", "Misinformation"),
|
||||
("COPYRIGHT", "Copyright Violation"),
|
||||
("PRIVACY", "Privacy Violation"),
|
||||
("HATE_SPEECH", "Hate Speech"),
|
||||
("VIOLENCE", "Violence or Threats"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="reported_entity_id",
|
||||
field=models.PositiveIntegerField(
|
||||
help_text="ID of the entity being reported"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="reported_entity_type",
|
||||
field=models.CharField(
|
||||
help_text="Type of entity being reported (park, ride, user, etc.)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="resolution_action",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default=django.utils.timezone.now,
|
||||
help_text="Action taken to resolve",
|
||||
max_length=100,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="resolution_notes",
|
||||
field=models.TextField(blank=True, help_text="Notes about the resolution"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending Review"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("RESOLVED", "Resolved"),
|
||||
("DISMISSED", "Dismissed"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="description",
|
||||
field=models.TextField(help_text="Detailed description of the issue"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="evidence_urls",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="URLs to evidence (screenshots, etc.)",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="reason",
|
||||
field=models.CharField(
|
||||
help_text="Brief reason for the report", max_length=200
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="report_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("SPAM", "Spam"),
|
||||
("HARASSMENT", "Harassment"),
|
||||
("INAPPROPRIATE_CONTENT", "Inappropriate Content"),
|
||||
("MISINFORMATION", "Misinformation"),
|
||||
("COPYRIGHT", "Copyright Violation"),
|
||||
("PRIVACY", "Privacy Violation"),
|
||||
("HATE_SPEECH", "Hate Speech"),
|
||||
("VIOLENCE", "Violence or Threats"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="reported_entity_id",
|
||||
field=models.PositiveIntegerField(
|
||||
help_text="ID of the entity being reported"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="reported_entity_type",
|
||||
field=models.CharField(
|
||||
help_text="Type of entity being reported (park, ride, user, etc.)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="resolution_action",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default=django.utils.timezone.now,
|
||||
help_text="Action taken to resolve",
|
||||
max_length=100,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="resolution_notes",
|
||||
field=models.TextField(blank=True, help_text="Notes about the resolution"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending Review"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("RESOLVED", "Resolved"),
|
||||
("DISMISSED", "Dismissed"),
|
||||
],
|
||||
default="PENDING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bulkoperation",
|
||||
index=models.Index(
|
||||
fields=["schedule_for"], name="moderation__schedul_350704_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bulkoperation",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="moderation__created_b705f4_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationaction",
|
||||
index=models.Index(
|
||||
fields=["moderator"], name="moderation__moderat_1c19b0_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationaction",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="moderation__created_6378e6_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationqueue",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="moderation__created_fe6dd0_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationreport",
|
||||
index=models.Index(
|
||||
fields=["reported_by"], name="moderation__reporte_81af56_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="moderationreport",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="moderation__created_ae337c_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="moderationqueue",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "moderation_moderationqueueevent" ("assigned_at", "assigned_to_id", "content_type_id", "created_at", "description", "entity_id", "entity_preview", "entity_type", "estimated_review_time", "flagged_by_id", "id", "item_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "related_report_id", "status", "tags", "title", "updated_at") VALUES (NEW."assigned_at", NEW."assigned_to_id", NEW."content_type_id", NEW."created_at", NEW."description", NEW."entity_id", NEW."entity_preview", NEW."entity_type", NEW."estimated_review_time", NEW."flagged_by_id", NEW."id", NEW."item_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."priority", NEW."related_report_id", NEW."status", NEW."tags", NEW."title", NEW."updated_at"); RETURN NULL;',
|
||||
hash="55993d8cb4981feed7b3febde9e87989481a8a34",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_cf9cb",
|
||||
table="moderation_moderationqueue",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="moderationqueue",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "moderation_moderationqueueevent" ("assigned_at", "assigned_to_id", "content_type_id", "created_at", "description", "entity_id", "entity_preview", "entity_type", "estimated_review_time", "flagged_by_id", "id", "item_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "related_report_id", "status", "tags", "title", "updated_at") VALUES (NEW."assigned_at", NEW."assigned_to_id", NEW."content_type_id", NEW."created_at", NEW."description", NEW."entity_id", NEW."entity_preview", NEW."entity_type", NEW."estimated_review_time", NEW."flagged_by_id", NEW."id", NEW."item_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."priority", NEW."related_report_id", NEW."status", NEW."tags", NEW."title", NEW."updated_at"); RETURN NULL;',
|
||||
hash="8da070419fd1efd43bfb272a431392b6244a7739",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_3b3aa",
|
||||
table="moderation_moderationqueue",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,17 @@
|
||||
from typing import Any, Dict, Optional, Type, Union
|
||||
"""
|
||||
Moderation Models
|
||||
|
||||
This module contains models for the ThrillWiki moderation system, including:
|
||||
- EditSubmission: Original content submission and approval workflow
|
||||
- ModerationReport: User reports for content moderation
|
||||
- ModerationQueue: Workflow management for moderation tasks
|
||||
- ModerationAction: Actions taken against users/content
|
||||
- BulkOperation: Administrative bulk operations
|
||||
|
||||
All models use pghistory for change tracking and TrackedModel base class.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -7,12 +20,17 @@ from django.utils import timezone
|
||||
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from datetime import timedelta
|
||||
import pghistory
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
UserType = Union[AbstractBaseUser, AnonymousUser]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Original EditSubmission Model (Preserved)
|
||||
# ============================================================================
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class EditSubmission(TrackedModel):
|
||||
STATUS_CHOICES = [
|
||||
@@ -79,7 +97,7 @@ class EditSubmission(TrackedModel):
|
||||
blank=True, help_text="Notes from the moderator about this submission"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
@@ -103,128 +121,508 @@ class EditSubmission(TrackedModel):
|
||||
|
||||
for field_name, value in data.items():
|
||||
try:
|
||||
if (
|
||||
(field := model_class._meta.get_field(field_name))
|
||||
and isinstance(field, models.ForeignKey)
|
||||
and value is not None
|
||||
):
|
||||
if related_model := field.related_model:
|
||||
resolved_data[field_name] = related_model.objects.get(pk=value)
|
||||
except (FieldDoesNotExist, ObjectDoesNotExist):
|
||||
field = model_class._meta.get_field(field_name)
|
||||
if isinstance(field, models.ForeignKey) and value is not None:
|
||||
try:
|
||||
related_obj = field.related_model.objects.get(pk=value)
|
||||
resolved_data[field_name] = related_obj
|
||||
except ObjectDoesNotExist:
|
||||
raise ValueError(
|
||||
f"Related object {field.related_model.__name__} with pk={value} does not exist"
|
||||
)
|
||||
except FieldDoesNotExist:
|
||||
# Field doesn't exist on model, skip it
|
||||
continue
|
||||
|
||||
return resolved_data
|
||||
|
||||
def _prepare_model_data(
|
||||
self, data: Dict[str, Any], model_class: Type[models.Model]
|
||||
) -> Dict[str, Any]:
|
||||
"""Prepare data for model creation/update by filtering out auto-generated fields"""
|
||||
prepared_data = data.copy()
|
||||
def _get_final_changes(self) -> Dict[str, Any]:
|
||||
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
|
||||
return self.moderator_changes or self.changes
|
||||
|
||||
# Remove fields that are auto-generated or handled by the model's save
|
||||
# method
|
||||
auto_fields = {"created_at", "updated_at", "slug"}
|
||||
for field in auto_fields:
|
||||
prepared_data.pop(field, None)
|
||||
def approve(self, moderator: UserType) -> Optional[models.Model]:
|
||||
"""
|
||||
Approve this submission and apply the changes.
|
||||
|
||||
# Set default values for required fields if not provided
|
||||
for field in model_class._meta.fields:
|
||||
if not field.auto_created and not field.blank and not field.null:
|
||||
if field.name not in prepared_data and field.has_default():
|
||||
prepared_data[field.name] = field.get_default()
|
||||
Args:
|
||||
moderator: The user approving the submission
|
||||
|
||||
return prepared_data
|
||||
Returns:
|
||||
The created or updated model instance
|
||||
|
||||
def _check_duplicate_name(
|
||||
self, model_class: Type[models.Model], name: str
|
||||
) -> Optional[models.Model]:
|
||||
"""Check if an object with the same name already exists"""
|
||||
try:
|
||||
return model_class.objects.filter(name=name).first()
|
||||
except BaseException as e:
|
||||
print(f"Error checking for duplicate name '{name}': {e}")
|
||||
raise e
|
||||
return None
|
||||
Raises:
|
||||
ValueError: If submission cannot be approved
|
||||
ValidationError: If the data is invalid
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot approve submission with status {self.status}")
|
||||
|
||||
def approve(self, user: UserType) -> Optional[models.Model]:
|
||||
"""Approve the submission and apply the changes"""
|
||||
if not (model_class := self.content_type.model_class()):
|
||||
model_class = self.content_type.model_class()
|
||||
if not model_class:
|
||||
raise ValueError("Could not resolve model class")
|
||||
|
||||
final_changes = self._get_final_changes()
|
||||
resolved_changes = self._resolve_foreign_keys(final_changes)
|
||||
|
||||
try:
|
||||
# Use moderator_changes if available, otherwise use original
|
||||
# changes
|
||||
changes_to_apply = (
|
||||
self.moderator_changes
|
||||
if self.moderator_changes is not None
|
||||
else self.changes
|
||||
)
|
||||
|
||||
resolved_data = self._resolve_foreign_keys(changes_to_apply)
|
||||
prepared_data = self._prepare_model_data(resolved_data, model_class)
|
||||
|
||||
# For CREATE submissions, check for duplicates by name
|
||||
if self.submission_type == "CREATE" and "name" in prepared_data:
|
||||
if existing_obj := self._check_duplicate_name(
|
||||
model_class, prepared_data["name"]
|
||||
):
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"A {model_class.__name__} with the name '{
|
||||
prepared_data['name']
|
||||
}' already exists (ID: {existing_obj.pk})"
|
||||
self.save()
|
||||
raise ValueError(self.notes)
|
||||
|
||||
self.status = "APPROVED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
|
||||
if self.submission_type == "CREATE":
|
||||
# Create new object
|
||||
obj = model_class(**prepared_data)
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
obj = model_class(**resolved_changes)
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
# Update object_id after creation
|
||||
self.object_id = getattr(obj, "id", None)
|
||||
else:
|
||||
# Apply changes to existing object
|
||||
if not (obj := self.content_object):
|
||||
raise ValueError("Content object not found")
|
||||
for field, value in prepared_data.items():
|
||||
setattr(obj, field, value)
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
# Update existing object
|
||||
if not self.content_object:
|
||||
raise ValueError("Cannot update: content object not found")
|
||||
|
||||
obj = self.content_object
|
||||
for field_name, value in resolved_changes.items():
|
||||
if hasattr(obj, field_name):
|
||||
setattr(obj, field_name, value)
|
||||
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
self.full_clean()
|
||||
# Mark submission as approved
|
||||
self.status = "APPROVED"
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
return obj
|
||||
|
||||
except Exception as e:
|
||||
if (
|
||||
self.status != "REJECTED"
|
||||
): # Don't override if already rejected due to duplicate
|
||||
self.status = "PENDING" # Reset status if approval failed
|
||||
self.save()
|
||||
raise ValueError(f"Error approving submission: {str(e)}") from e
|
||||
# Mark as rejected on any error
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Approval failed: {str(e)}"
|
||||
self.save()
|
||||
raise
|
||||
|
||||
def reject(self, moderator: UserType, reason: str) -> None:
|
||||
"""
|
||||
Reject this submission.
|
||||
|
||||
Args:
|
||||
moderator: The user rejecting the submission
|
||||
reason: Reason for rejection
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot reject submission with status {self.status}")
|
||||
|
||||
def reject(self, user: UserType) -> None:
|
||||
"""Reject the submission"""
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Rejected: {reason}"
|
||||
self.save()
|
||||
|
||||
def escalate(self, user: UserType) -> None:
|
||||
"""Escalate the submission to admin"""
|
||||
def escalate(self, moderator: UserType, reason: str) -> None:
|
||||
"""
|
||||
Escalate this submission for higher-level review.
|
||||
|
||||
Args:
|
||||
moderator: The user escalating the submission
|
||||
reason: Reason for escalation
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot escalate submission with status {self.status}")
|
||||
|
||||
self.status = "ESCALATED"
|
||||
self.handled_by = user # type: ignore
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Escalated: {reason}"
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def submitted_by(self):
|
||||
"""Alias for user field to maintain compatibility"""
|
||||
return self.user
|
||||
|
||||
@property
|
||||
def submitted_at(self):
|
||||
"""Alias for created_at field to maintain compatibility"""
|
||||
return self.created_at
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# New Moderation System Models
|
||||
# ============================================================================
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationReport(TrackedModel):
|
||||
"""
|
||||
Model for tracking user reports about content, users, or behavior.
|
||||
|
||||
This handles the initial reporting phase where users flag content
|
||||
or behavior that needs moderator attention.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending Review'),
|
||||
('UNDER_REVIEW', 'Under Review'),
|
||||
('RESOLVED', 'Resolved'),
|
||||
('DISMISSED', 'Dismissed'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
REPORT_TYPE_CHOICES = [
|
||||
('SPAM', 'Spam'),
|
||||
('HARASSMENT', 'Harassment'),
|
||||
('INAPPROPRIATE_CONTENT', 'Inappropriate Content'),
|
||||
('MISINFORMATION', 'Misinformation'),
|
||||
('COPYRIGHT', 'Copyright Violation'),
|
||||
('PRIVACY', 'Privacy Violation'),
|
||||
('HATE_SPEECH', 'Hate Speech'),
|
||||
('VIOLENCE', 'Violence or Threats'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Report details
|
||||
report_type = models.CharField(max_length=50, choices=REPORT_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
|
||||
# What is being reported
|
||||
reported_entity_type = models.CharField(
|
||||
max_length=50, help_text="Type of entity being reported (park, ride, user, etc.)")
|
||||
reported_entity_id = models.PositiveIntegerField(
|
||||
help_text="ID of the entity being reported")
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
# Report content
|
||||
reason = models.CharField(max_length=200, help_text="Brief reason for the report")
|
||||
description = models.TextField(help_text="Detailed description of the issue")
|
||||
evidence_urls = models.JSONField(
|
||||
default=list, blank=True, help_text="URLs to evidence (screenshots, etc.)")
|
||||
|
||||
# Users involved
|
||||
reported_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_reports_made'
|
||||
)
|
||||
assigned_moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='assigned_moderation_reports'
|
||||
)
|
||||
|
||||
# Resolution
|
||||
resolution_action = models.CharField(
|
||||
max_length=100, blank=True, help_text="Action taken to resolve")
|
||||
resolution_notes = models.TextField(
|
||||
blank=True, help_text="Notes about the resolution")
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['reported_by']),
|
||||
models.Index(fields=['assigned_moderator']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_report_type_display()} report by {self.reported_by.username}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationQueue(TrackedModel):
|
||||
"""
|
||||
Model for managing moderation workflow and task assignment.
|
||||
|
||||
This represents items in the moderation queue that need attention,
|
||||
separate from the initial reports.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('IN_PROGRESS', 'In Progress'),
|
||||
('COMPLETED', 'Completed'),
|
||||
('CANCELLED', 'Cancelled'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
ITEM_TYPE_CHOICES = [
|
||||
('CONTENT_REVIEW', 'Content Review'),
|
||||
('USER_REVIEW', 'User Review'),
|
||||
('BULK_ACTION', 'Bulk Action'),
|
||||
('POLICY_VIOLATION', 'Policy Violation'),
|
||||
('APPEAL', 'Appeal'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Queue item details
|
||||
item_type = models.CharField(max_length=50, choices=ITEM_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
|
||||
title = models.CharField(max_length=200, help_text="Brief title for the queue item")
|
||||
description = models.TextField(
|
||||
help_text="Detailed description of what needs to be done")
|
||||
|
||||
# What entity this relates to
|
||||
entity_type = models.CharField(
|
||||
max_length=50, blank=True, help_text="Type of entity (park, ride, user, etc.)")
|
||||
entity_id = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="ID of the related entity")
|
||||
entity_preview = models.JSONField(
|
||||
default=dict, blank=True, help_text="Preview data for the entity")
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
# Assignment and timing
|
||||
assigned_to = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='assigned_queue_items'
|
||||
)
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
estimated_review_time = models.PositiveIntegerField(
|
||||
default=30, help_text="Estimated time in minutes")
|
||||
|
||||
# Metadata
|
||||
flagged_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='flagged_queue_items'
|
||||
)
|
||||
tags = models.JSONField(default=list, blank=True,
|
||||
help_text="Tags for categorization")
|
||||
|
||||
# Related objects
|
||||
related_report = models.ForeignKey(
|
||||
ModerationReport,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='queue_items'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['priority', 'created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['assigned_to']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_item_type_display()}: {self.title}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationAction(TrackedModel):
|
||||
"""
|
||||
Model for tracking actions taken against users or content.
|
||||
|
||||
This records what actions moderators have taken, including
|
||||
warnings, suspensions, content removal, etc.
|
||||
"""
|
||||
|
||||
ACTION_TYPE_CHOICES = [
|
||||
('WARNING', 'Warning'),
|
||||
('USER_SUSPENSION', 'User Suspension'),
|
||||
('USER_BAN', 'User Ban'),
|
||||
('CONTENT_REMOVAL', 'Content Removal'),
|
||||
('CONTENT_EDIT', 'Content Edit'),
|
||||
('CONTENT_RESTRICTION', 'Content Restriction'),
|
||||
('ACCOUNT_RESTRICTION', 'Account Restriction'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Action details
|
||||
action_type = models.CharField(max_length=50, choices=ACTION_TYPE_CHOICES)
|
||||
reason = models.CharField(max_length=200, help_text="Brief reason for the action")
|
||||
details = models.TextField(help_text="Detailed explanation of the action")
|
||||
|
||||
# Duration (for temporary actions)
|
||||
duration_hours = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Duration in hours for temporary actions"
|
||||
)
|
||||
expires_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When this action expires")
|
||||
is_active = models.BooleanField(
|
||||
default=True, help_text="Whether this action is currently active")
|
||||
|
||||
# Users involved
|
||||
moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_actions_taken'
|
||||
)
|
||||
target_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_actions_received'
|
||||
)
|
||||
|
||||
# Related objects
|
||||
related_report = models.ForeignKey(
|
||||
ModerationReport,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='actions_taken'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['target_user', 'is_active']),
|
||||
models.Index(fields=['moderator']),
|
||||
models.Index(fields=['expires_at']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Set expiration time if duration is provided
|
||||
if self.duration_hours and not self.expires_at:
|
||||
self.expires_at = timezone.now() + timedelta(hours=self.duration_hours)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class BulkOperation(TrackedModel):
|
||||
"""
|
||||
Model for tracking bulk administrative operations.
|
||||
|
||||
This handles large-scale operations like bulk updates,
|
||||
imports, exports, or mass moderation actions.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('RUNNING', 'Running'),
|
||||
('COMPLETED', 'Completed'),
|
||||
('FAILED', 'Failed'),
|
||||
('CANCELLED', 'Cancelled'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
OPERATION_TYPE_CHOICES = [
|
||||
('UPDATE_PARKS', 'Update Parks'),
|
||||
('UPDATE_RIDES', 'Update Rides'),
|
||||
('IMPORT_DATA', 'Import Data'),
|
||||
('EXPORT_DATA', 'Export Data'),
|
||||
('MODERATE_CONTENT', 'Moderate Content'),
|
||||
('USER_ACTIONS', 'User Actions'),
|
||||
('CLEANUP', 'Cleanup'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Operation details
|
||||
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
description = models.TextField(help_text="Description of what this operation does")
|
||||
|
||||
# Operation parameters and results
|
||||
parameters = models.JSONField(
|
||||
default=dict, help_text="Parameters for the operation")
|
||||
results = models.JSONField(default=dict, blank=True,
|
||||
help_text="Results and output from the operation")
|
||||
|
||||
# Progress tracking
|
||||
total_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of items to process")
|
||||
processed_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items processed")
|
||||
failed_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items that failed")
|
||||
|
||||
# Timing
|
||||
estimated_duration_minutes = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Estimated duration in minutes"
|
||||
)
|
||||
schedule_for = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When to run this operation")
|
||||
|
||||
# Control
|
||||
can_cancel = models.BooleanField(
|
||||
default=True, help_text="Whether this operation can be cancelled")
|
||||
|
||||
# User who created the operation
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bulk_operations_created'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['created_by']),
|
||||
models.Index(fields=['schedule_for']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_operation_type_display()}: {self.description[:50]}"
|
||||
|
||||
@property
|
||||
def progress_percentage(self):
|
||||
"""Calculate progress percentage."""
|
||||
if self.total_items == 0:
|
||||
return 0.0
|
||||
return round((self.processed_items / self.total_items) * 100, 2)
|
||||
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class PhotoSubmission(TrackedModel):
|
||||
@@ -270,7 +668,7 @@ class PhotoSubmission(TrackedModel):
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
@@ -319,7 +717,7 @@ class PhotoSubmission(TrackedModel):
|
||||
self.save()
|
||||
|
||||
def auto_approve(self) -> None:
|
||||
"""Auto-approve submissions from moderators"""
|
||||
"""Auto - approve submissions from moderators"""
|
||||
# Get user role safely
|
||||
user_role = getattr(self.user, "role", None)
|
||||
|
||||
|
||||
318
backend/apps/moderation/permissions.py
Normal file
318
backend/apps/moderation/permissions.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Moderation Permissions
|
||||
|
||||
This module contains custom permission classes for the moderation system,
|
||||
providing role-based access control for moderation operations.
|
||||
"""
|
||||
|
||||
from rest_framework import permissions
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class IsModerator(permissions.BasePermission):
|
||||
"""
|
||||
Permission that only allows moderators to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderator role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role == "MODERATOR"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for moderators."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class IsModeratorOrAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows moderators, admins, and superusers to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderator, admin, or superuser role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for moderators and admins."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class IsAdminOrSuperuser(permissions.BasePermission):
|
||||
"""
|
||||
Permission that only allows admins and superusers to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has admin or superuser role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for admins and superusers."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class CanViewModerationData(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to view moderation data based on their role.
|
||||
|
||||
- Regular users can only view their own reports
|
||||
- Moderators and above can view all moderation data
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated."""
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for viewing moderation data."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Moderators and above can view all data
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
return True
|
||||
|
||||
# Regular users can only view their own reports
|
||||
if hasattr(obj, "reported_by"):
|
||||
return obj.reported_by == request.user
|
||||
|
||||
# For other objects, deny access to regular users
|
||||
return False
|
||||
|
||||
|
||||
class CanModerateContent(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to moderate content based on their role.
|
||||
|
||||
- Only moderators and above can moderate content
|
||||
- Includes additional checks for specific moderation actions
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderation privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for content moderation."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can do everything
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can moderate most content but may have some restrictions
|
||||
if user_role == "ADMIN":
|
||||
# Add any admin-specific restrictions here if needed
|
||||
return True
|
||||
|
||||
# Moderators have basic moderation permissions
|
||||
if user_role == "MODERATOR":
|
||||
# Add any moderator-specific restrictions here if needed
|
||||
# For example, moderators might not be able to moderate admin actions
|
||||
if hasattr(obj, "moderator") and obj.moderator:
|
||||
moderator_role = getattr(obj.moderator, "role", "USER")
|
||||
if moderator_role in ["ADMIN", "SUPERUSER"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanAssignModerationTasks(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to assign moderation tasks to others.
|
||||
|
||||
- Moderators can assign tasks to themselves
|
||||
- Admins can assign tasks to moderators and themselves
|
||||
- Superusers can assign tasks to anyone
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has assignment privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for task assignment."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can assign to anyone
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can assign to moderators and themselves
|
||||
if user_role == "ADMIN":
|
||||
return True
|
||||
|
||||
# Moderators can only assign to themselves
|
||||
if user_role == "MODERATOR":
|
||||
# Check if they're trying to assign to themselves
|
||||
assignee_id = request.data.get("moderator_id") or request.data.get(
|
||||
"assigned_to"
|
||||
)
|
||||
if assignee_id:
|
||||
return str(assignee_id) == str(request.user.id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanPerformBulkOperations(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to perform bulk operations.
|
||||
|
||||
- Only admins and superusers can perform bulk operations
|
||||
- Includes additional safety checks for destructive operations
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has bulk operation privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for bulk operations."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can perform all bulk operations
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can perform most bulk operations
|
||||
if user_role == "ADMIN":
|
||||
# Add any admin-specific restrictions for bulk operations here
|
||||
# For example, admins might not be able to perform certain destructive operations
|
||||
operation_type = getattr(obj, "operation_type", None)
|
||||
if operation_type in ["DELETE_USERS", "PURGE_DATA"]:
|
||||
return False # Only superusers can perform these operations
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsOwnerOrModerator(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows object owners or moderators to access the view.
|
||||
|
||||
- Users can access their own objects
|
||||
- Moderators and above can access any object
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated."""
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for owners or moderators."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Moderators and above can access any object
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
return True
|
||||
|
||||
# Check if user is the owner of the object
|
||||
if hasattr(obj, "reported_by"):
|
||||
return obj.reported_by == request.user
|
||||
elif hasattr(obj, "created_by"):
|
||||
return obj.created_by == request.user
|
||||
elif hasattr(obj, "user"):
|
||||
return obj.user == request.user
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanManageUserRestrictions(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to manage user restrictions and moderation actions.
|
||||
|
||||
- Moderators can create basic restrictions (warnings, temporary suspensions)
|
||||
- Admins can create more severe restrictions (longer suspensions, content removal)
|
||||
- Superusers can create any restriction including permanent bans
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has restriction management privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for managing user restrictions."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can manage any restriction
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Get the action type from request data or object
|
||||
action_type = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
action_type = request.data.get("action_type")
|
||||
elif hasattr(obj, "action_type"):
|
||||
action_type = obj.action_type
|
||||
|
||||
# Admins can manage most restrictions
|
||||
if user_role == "ADMIN":
|
||||
# Admins cannot create permanent bans
|
||||
if action_type == "USER_BAN" and request.data.get("duration_hours") is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Moderators can only manage basic restrictions
|
||||
if user_role == "MODERATOR":
|
||||
allowed_actions = ["WARNING", "CONTENT_REMOVAL", "USER_SUSPENSION"]
|
||||
if action_type not in allowed_actions:
|
||||
return False
|
||||
|
||||
# Moderators can only create temporary suspensions (max 7 days)
|
||||
if action_type == "USER_SUSPENSION":
|
||||
duration_hours = request.data.get("duration_hours", 0)
|
||||
if duration_hours > 168: # 7 days = 168 hours
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -27,14 +27,14 @@ def pending_submissions_for_review(
|
||||
"""
|
||||
queryset = (
|
||||
EditSubmission.objects.filter(status="PENDING")
|
||||
.select_related("submitted_by", "content_type")
|
||||
.select_related("user", "content_type")
|
||||
.prefetch_related("content_object")
|
||||
)
|
||||
|
||||
if content_type:
|
||||
queryset = queryset.filter(content_type__model=content_type.lower())
|
||||
|
||||
return queryset.order_by("submitted_at")[:limit]
|
||||
return queryset.order_by("created_at")[:limit]
|
||||
|
||||
|
||||
def submissions_by_user(
|
||||
@@ -50,14 +50,14 @@ def submissions_by_user(
|
||||
Returns:
|
||||
QuerySet of user's submissions
|
||||
"""
|
||||
queryset = EditSubmission.objects.filter(submitted_by_id=user_id).select_related(
|
||||
queryset = EditSubmission.objects.filter(user_id=user_id).select_related(
|
||||
"content_type", "handled_by"
|
||||
)
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset.order_by("-submitted_at")
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
|
||||
def submissions_handled_by_moderator(
|
||||
@@ -79,7 +79,7 @@ def submissions_handled_by_moderator(
|
||||
EditSubmission.objects.filter(
|
||||
handled_by_id=moderator_id, handled_at__gte=cutoff_date
|
||||
)
|
||||
.select_related("submitted_by", "content_type")
|
||||
.select_related("user", "content_type")
|
||||
.order_by("-handled_at")
|
||||
)
|
||||
|
||||
@@ -97,9 +97,9 @@ def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]:
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
EditSubmission.objects.filter(submitted_at__gte=cutoff_date)
|
||||
.select_related("submitted_by", "content_type", "handled_by")
|
||||
.order_by("-submitted_at")
|
||||
EditSubmission.objects.filter(created_at__gte=cutoff_date)
|
||||
.select_related("user", "content_type", "handled_by")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
|
||||
@@ -118,12 +118,12 @@ def submissions_by_content_type(
|
||||
"""
|
||||
queryset = EditSubmission.objects.filter(
|
||||
content_type__model=content_type.lower()
|
||||
).select_related("submitted_by", "handled_by")
|
||||
).select_related("user", "handled_by")
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset.order_by("-submitted_at")
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
|
||||
def moderation_queue_summary() -> Dict[str, Any]:
|
||||
@@ -172,7 +172,7 @@ def moderation_statistics_summary(
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
base_queryset = EditSubmission.objects.filter(submitted_at__gte=cutoff_date)
|
||||
base_queryset = EditSubmission.objects.filter(created_at__gte=cutoff_date)
|
||||
|
||||
if moderator:
|
||||
handled_queryset = base_queryset.filter(handled_by=moderator)
|
||||
@@ -189,7 +189,7 @@ def moderation_statistics_summary(
|
||||
handled_queryset.exclude(handled_at__isnull=True)
|
||||
.extra(
|
||||
select={
|
||||
"response_hours": "EXTRACT(EPOCH FROM (handled_at - submitted_at)) / 3600"
|
||||
"response_hours": "EXTRACT(EPOCH FROM (handled_at - created_at)) / 3600"
|
||||
}
|
||||
)
|
||||
.values_list("response_hours", flat=True)
|
||||
@@ -228,9 +228,9 @@ def submissions_needing_attention(*, hours: int = 24) -> QuerySet[EditSubmission
|
||||
cutoff_time = timezone.now() - timedelta(hours=hours)
|
||||
|
||||
return (
|
||||
EditSubmission.objects.filter(status="PENDING", submitted_at__lte=cutoff_time)
|
||||
.select_related("submitted_by", "content_type")
|
||||
.order_by("submitted_at")
|
||||
EditSubmission.objects.filter(status="PENDING", created_at__lte=cutoff_time)
|
||||
.select_related("user", "content_type")
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ def top_contributors(*, days: int = 30, limit: int = 10) -> QuerySet[User]:
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
User.objects.filter(edit_submissions__submitted_at__gte=cutoff_date)
|
||||
User.objects.filter(edit_submissions__created_at__gte=cutoff_date)
|
||||
.annotate(submission_count=Count("edit_submissions"))
|
||||
.filter(submission_count__gt=0)
|
||||
.order_by("-submission_count")[:limit]
|
||||
|
||||
735
backend/apps/moderation/serializers.py
Normal file
735
backend/apps/moderation/serializers.py
Normal file
@@ -0,0 +1,735 @@
|
||||
"""
|
||||
Moderation API Serializers
|
||||
|
||||
This module contains DRF serializers for the moderation system, including:
|
||||
- ModerationReport serializers for content reporting
|
||||
- ModerationQueue serializers for moderation workflow
|
||||
- ModerationAction serializers for tracking moderation actions
|
||||
- BulkOperation serializers for administrative bulk operations
|
||||
|
||||
All serializers include comprehensive validation and nested relationships.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from .models import (
|
||||
ModerationReport,
|
||||
ModerationQueue,
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Base Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
"""Basic user information for moderation contexts."""
|
||||
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "display_name", "email", "role"]
|
||||
read_only_fields = ["id", "username", "display_name", "email", "role"]
|
||||
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
class ContentTypeSerializer(serializers.ModelSerializer):
|
||||
"""Content type information for generic foreign keys."""
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ["id", "app_label", "model"]
|
||||
read_only_fields = ["id", "app_label", "model"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Report Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationReportSerializer(serializers.ModelSerializer):
|
||||
"""Full moderation report serializer with all details."""
|
||||
|
||||
reported_by = UserBasicSerializer(read_only=True)
|
||||
assigned_moderator = UserBasicSerializer(read_only=True)
|
||||
content_type = ContentTypeSerializer(read_only=True)
|
||||
|
||||
# Computed fields
|
||||
is_overdue = serializers.SerializerMethodField()
|
||||
time_since_created = serializers.SerializerMethodField()
|
||||
priority_display = serializers.CharField(
|
||||
source="get_priority_display", read_only=True
|
||||
)
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
report_type_display = serializers.CharField(
|
||||
source="get_report_type_display", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"id",
|
||||
"report_type",
|
||||
"report_type_display",
|
||||
"status",
|
||||
"status_display",
|
||||
"priority",
|
||||
"priority_display",
|
||||
"reported_entity_type",
|
||||
"reported_entity_id",
|
||||
"reason",
|
||||
"description",
|
||||
"evidence_urls",
|
||||
"resolved_at",
|
||||
"resolution_notes",
|
||||
"resolution_action",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reported_by",
|
||||
"assigned_moderator",
|
||||
"content_type",
|
||||
"is_overdue",
|
||||
"time_since_created",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reported_by",
|
||||
"content_type",
|
||||
"is_overdue",
|
||||
"time_since_created",
|
||||
"report_type_display",
|
||||
"status_display",
|
||||
"priority_display",
|
||||
]
|
||||
|
||||
def get_is_overdue(self, obj) -> bool:
|
||||
"""Check if report is overdue based on priority."""
|
||||
if obj.status in ["RESOLVED", "DISMISSED"]:
|
||||
return False
|
||||
|
||||
now = timezone.now()
|
||||
hours_since_created = (now - obj.created_at).total_seconds() / 3600
|
||||
|
||||
# Define SLA hours by priority
|
||||
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
||||
|
||||
return hours_since_created > sla_hours.get(obj.priority, 24)
|
||||
|
||||
def get_time_since_created(self, obj) -> str:
|
||||
"""Human-readable time since creation."""
|
||||
now = timezone.now()
|
||||
diff = now - obj.created_at
|
||||
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} days ago"
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hours ago"
|
||||
else:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes ago"
|
||||
|
||||
|
||||
class CreateModerationReportSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating new moderation reports."""
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"report_type",
|
||||
"reported_entity_type",
|
||||
"reported_entity_id",
|
||||
"reason",
|
||||
"description",
|
||||
"evidence_urls",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate the report data."""
|
||||
# Validate entity type
|
||||
valid_entity_types = ["park", "ride", "review", "photo", "user", "comment"]
|
||||
if attrs["reported_entity_type"] not in valid_entity_types:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"reported_entity_type": f'Must be one of: {", ".join(valid_entity_types)}'
|
||||
}
|
||||
)
|
||||
|
||||
# Validate evidence URLs
|
||||
evidence_urls = attrs.get("evidence_urls", [])
|
||||
if not isinstance(evidence_urls, list):
|
||||
raise serializers.ValidationError(
|
||||
{"evidence_urls": "Must be a list of URLs"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new moderation report."""
|
||||
validated_data["reported_by"] = self.context["request"].user
|
||||
validated_data["status"] = "PENDING"
|
||||
validated_data["priority"] = "MEDIUM" # Default priority
|
||||
|
||||
# Set content type based on entity type
|
||||
entity_type = validated_data["reported_entity_type"]
|
||||
app_label_map = {
|
||||
"park": "parks",
|
||||
"ride": "rides",
|
||||
"review": "rides", # Assuming ride reviews
|
||||
"photo": "media",
|
||||
"user": "accounts",
|
||||
"comment": "core",
|
||||
}
|
||||
|
||||
if entity_type in app_label_map:
|
||||
try:
|
||||
content_type = ContentType.objects.get(
|
||||
app_label=app_label_map[entity_type], model=entity_type
|
||||
)
|
||||
validated_data["content_type"] = content_type
|
||||
except ContentType.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class UpdateModerationReportSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating moderation reports."""
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"status",
|
||||
"priority",
|
||||
"assigned_moderator",
|
||||
"resolution_notes",
|
||||
"resolution_action",
|
||||
]
|
||||
|
||||
def validate_status(self, value):
|
||||
"""Validate status transitions."""
|
||||
if self.instance and self.instance.status == "RESOLVED":
|
||||
if value != "RESOLVED":
|
||||
raise serializers.ValidationError(
|
||||
"Cannot change status of resolved report"
|
||||
)
|
||||
return value
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Update moderation report with automatic timestamps."""
|
||||
if "status" in validated_data and validated_data["status"] == "RESOLVED":
|
||||
validated_data["resolved_at"] = timezone.now()
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Queue Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationQueueSerializer(serializers.ModelSerializer):
|
||||
"""Full moderation queue item serializer."""
|
||||
|
||||
assigned_to = UserBasicSerializer(read_only=True)
|
||||
related_report = ModerationReportSerializer(read_only=True)
|
||||
content_type = ContentTypeSerializer(read_only=True)
|
||||
|
||||
# Computed fields
|
||||
is_overdue = serializers.SerializerMethodField()
|
||||
time_in_queue = serializers.SerializerMethodField()
|
||||
estimated_completion = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ModerationQueue
|
||||
fields = [
|
||||
"id",
|
||||
"item_type",
|
||||
"status",
|
||||
"priority",
|
||||
"title",
|
||||
"description",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"entity_preview",
|
||||
"flagged_by",
|
||||
"assigned_at",
|
||||
"estimated_review_time",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"tags",
|
||||
"assigned_to",
|
||||
"related_report",
|
||||
"content_type",
|
||||
"is_overdue",
|
||||
"time_in_queue",
|
||||
"estimated_completion",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"content_type",
|
||||
"is_overdue",
|
||||
"time_in_queue",
|
||||
"estimated_completion",
|
||||
]
|
||||
|
||||
def get_is_overdue(self, obj) -> bool:
|
||||
"""Check if queue item is overdue."""
|
||||
if obj.status == "COMPLETED":
|
||||
return False
|
||||
|
||||
if obj.assigned_at:
|
||||
time_assigned = (timezone.now() - obj.assigned_at).total_seconds() / 60
|
||||
return time_assigned > obj.estimated_review_time
|
||||
|
||||
# If not assigned, check time in queue
|
||||
time_in_queue = (timezone.now() - obj.created_at).total_seconds() / 60
|
||||
return time_in_queue > (obj.estimated_review_time * 2)
|
||||
|
||||
def get_time_in_queue(self, obj) -> int:
|
||||
"""Minutes since item was created."""
|
||||
return int((timezone.now() - obj.created_at).total_seconds() / 60)
|
||||
|
||||
def get_estimated_completion(self, obj) -> str:
|
||||
"""Estimated completion time."""
|
||||
if obj.assigned_at:
|
||||
completion_time = obj.assigned_at + timedelta(
|
||||
minutes=obj.estimated_review_time
|
||||
)
|
||||
else:
|
||||
completion_time = timezone.now() + timedelta(
|
||||
minutes=obj.estimated_review_time
|
||||
)
|
||||
|
||||
return completion_time.isoformat()
|
||||
|
||||
|
||||
class AssignQueueItemSerializer(serializers.Serializer):
|
||||
"""Serializer for assigning queue items to moderators."""
|
||||
|
||||
moderator_id = serializers.IntegerField()
|
||||
|
||||
def validate_moderator_id(self, value):
|
||||
"""Validate that the moderator exists and has appropriate permissions."""
|
||||
try:
|
||||
user = User.objects.get(id=value)
|
||||
user_role = getattr(user, "role", "USER")
|
||||
if user_role not in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
raise serializers.ValidationError(
|
||||
"User must be a moderator, admin, or superuser"
|
||||
)
|
||||
return value
|
||||
except User.DoesNotExist:
|
||||
raise serializers.ValidationError("Moderator not found")
|
||||
|
||||
|
||||
class CompleteQueueItemSerializer(serializers.Serializer):
|
||||
"""Serializer for completing queue items."""
|
||||
|
||||
action = serializers.ChoiceField(
|
||||
choices=[
|
||||
"NO_ACTION",
|
||||
"CONTENT_REMOVED",
|
||||
"CONTENT_EDITED",
|
||||
"USER_WARNING",
|
||||
"USER_SUSPENDED",
|
||||
"USER_BANNED",
|
||||
]
|
||||
)
|
||||
notes = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate completion data."""
|
||||
action = attrs["action"]
|
||||
notes = attrs.get("notes", "")
|
||||
|
||||
# Require notes for certain actions
|
||||
if action in ["USER_WARNING", "USER_SUSPENDED", "USER_BANNED"] and not notes:
|
||||
raise serializers.ValidationError(
|
||||
{"notes": f"Notes are required for action: {action}"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Action Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationActionSerializer(serializers.ModelSerializer):
|
||||
"""Full moderation action serializer."""
|
||||
|
||||
moderator = UserBasicSerializer(read_only=True)
|
||||
target_user = UserBasicSerializer(read_only=True)
|
||||
related_report = ModerationReportSerializer(read_only=True)
|
||||
|
||||
# Computed fields
|
||||
is_expired = serializers.SerializerMethodField()
|
||||
time_remaining = serializers.SerializerMethodField()
|
||||
action_type_display = serializers.CharField(
|
||||
source="get_action_type_display", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationAction
|
||||
fields = [
|
||||
"id",
|
||||
"action_type",
|
||||
"action_type_display",
|
||||
"reason",
|
||||
"details",
|
||||
"duration_hours",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"is_active",
|
||||
"moderator",
|
||||
"target_user",
|
||||
"related_report",
|
||||
"updated_at",
|
||||
"is_expired",
|
||||
"time_remaining",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"moderator",
|
||||
"target_user",
|
||||
"related_report",
|
||||
"is_expired",
|
||||
"time_remaining",
|
||||
"action_type_display",
|
||||
]
|
||||
|
||||
def get_is_expired(self, obj) -> bool:
|
||||
"""Check if action has expired."""
|
||||
if not obj.expires_at:
|
||||
return False
|
||||
return timezone.now() > obj.expires_at
|
||||
|
||||
def get_time_remaining(self, obj) -> str | None:
|
||||
"""Time remaining until expiration."""
|
||||
if not obj.expires_at or not obj.is_active:
|
||||
return None
|
||||
|
||||
now = timezone.now()
|
||||
if now >= obj.expires_at:
|
||||
return "Expired"
|
||||
|
||||
diff = obj.expires_at - now
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} days"
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hours"
|
||||
else:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes"
|
||||
|
||||
|
||||
class CreateModerationActionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating moderation actions."""
|
||||
|
||||
target_user_id = serializers.IntegerField()
|
||||
related_report_id = serializers.IntegerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = ModerationAction
|
||||
fields = [
|
||||
"action_type",
|
||||
"reason",
|
||||
"details",
|
||||
"duration_hours",
|
||||
"target_user_id",
|
||||
"related_report_id",
|
||||
]
|
||||
|
||||
def validate_target_user_id(self, value):
|
||||
"""Validate target user exists."""
|
||||
try:
|
||||
User.objects.get(id=value)
|
||||
return value
|
||||
except User.DoesNotExist:
|
||||
raise serializers.ValidationError("Target user not found")
|
||||
|
||||
def validate_related_report_id(self, value):
|
||||
"""Validate related report exists."""
|
||||
if value:
|
||||
try:
|
||||
ModerationReport.objects.get(id=value)
|
||||
return value
|
||||
except ModerationReport.DoesNotExist:
|
||||
raise serializers.ValidationError("Related report not found")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate action data."""
|
||||
action_type = attrs["action_type"]
|
||||
duration_hours = attrs.get("duration_hours")
|
||||
|
||||
# Validate duration for temporary actions
|
||||
temporary_actions = ["USER_SUSPENSION", "CONTENT_RESTRICTION"]
|
||||
if action_type in temporary_actions and not duration_hours:
|
||||
raise serializers.ValidationError(
|
||||
{"duration_hours": f"Duration is required for {action_type}"}
|
||||
)
|
||||
|
||||
# Validate duration range
|
||||
if duration_hours and (
|
||||
duration_hours < 1 or duration_hours > 8760
|
||||
): # 1 hour to 1 year
|
||||
raise serializers.ValidationError(
|
||||
{"duration_hours": "Duration must be between 1 and 8760 hours (1 year)"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create moderation action with automatic fields."""
|
||||
target_user_id = validated_data.pop("target_user_id")
|
||||
related_report_id = validated_data.pop("related_report_id", None)
|
||||
|
||||
validated_data["moderator"] = self.context["request"].user
|
||||
validated_data["target_user_id"] = target_user_id
|
||||
validated_data["is_active"] = True
|
||||
|
||||
if related_report_id:
|
||||
validated_data["related_report_id"] = related_report_id
|
||||
|
||||
# Set expiration time for temporary actions
|
||||
if validated_data.get("duration_hours"):
|
||||
validated_data["expires_at"] = timezone.now() + timedelta(
|
||||
hours=validated_data["duration_hours"]
|
||||
)
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bulk Operation Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BulkOperationSerializer(serializers.ModelSerializer):
|
||||
"""Full bulk operation serializer."""
|
||||
|
||||
created_by = UserBasicSerializer(read_only=True)
|
||||
|
||||
# Computed fields
|
||||
progress_percentage = serializers.SerializerMethodField()
|
||||
estimated_completion = serializers.SerializerMethodField()
|
||||
operation_type_display = serializers.CharField(
|
||||
source="get_operation_type_display", read_only=True
|
||||
)
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BulkOperation
|
||||
fields = [
|
||||
"id",
|
||||
"operation_type",
|
||||
"operation_type_display",
|
||||
"status",
|
||||
"status_display",
|
||||
"priority",
|
||||
"parameters",
|
||||
"results",
|
||||
"total_items",
|
||||
"processed_items",
|
||||
"failed_items",
|
||||
"created_at",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"estimated_duration_minutes",
|
||||
"can_cancel",
|
||||
"description",
|
||||
"schedule_for",
|
||||
"created_by",
|
||||
"updated_at",
|
||||
"progress_percentage",
|
||||
"estimated_completion",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"progress_percentage",
|
||||
"estimated_completion",
|
||||
"operation_type_display",
|
||||
"status_display",
|
||||
]
|
||||
|
||||
def get_progress_percentage(self, obj) -> float:
|
||||
"""Calculate progress percentage."""
|
||||
if obj.total_items == 0:
|
||||
return 0.0
|
||||
return round((obj.processed_items / obj.total_items) * 100, 2)
|
||||
|
||||
def get_estimated_completion(self, obj) -> str | None:
|
||||
"""Estimate completion time."""
|
||||
if obj.status == "COMPLETED":
|
||||
return obj.completed_at.isoformat() if obj.completed_at else None
|
||||
|
||||
if obj.status == "RUNNING" and obj.started_at:
|
||||
# Calculate based on current progress
|
||||
if obj.processed_items > 0:
|
||||
elapsed_minutes = (timezone.now() - obj.started_at).total_seconds() / 60
|
||||
rate = obj.processed_items / elapsed_minutes
|
||||
remaining_items = obj.total_items - obj.processed_items
|
||||
remaining_minutes = (
|
||||
remaining_items / rate
|
||||
if rate > 0
|
||||
else obj.estimated_duration_minutes
|
||||
)
|
||||
completion_time = timezone.now() + timedelta(minutes=remaining_minutes)
|
||||
return completion_time.isoformat()
|
||||
|
||||
# Use scheduled time or estimated duration
|
||||
if obj.schedule_for:
|
||||
return obj.schedule_for.isoformat()
|
||||
elif obj.estimated_duration_minutes:
|
||||
completion_time = timezone.now() + timedelta(
|
||||
minutes=obj.estimated_duration_minutes
|
||||
)
|
||||
return completion_time.isoformat()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class CreateBulkOperationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating bulk operations."""
|
||||
|
||||
class Meta:
|
||||
model = BulkOperation
|
||||
fields = [
|
||||
"operation_type",
|
||||
"priority",
|
||||
"parameters",
|
||||
"description",
|
||||
"schedule_for",
|
||||
"estimated_duration_minutes",
|
||||
]
|
||||
|
||||
def validate_parameters(self, value):
|
||||
"""Validate operation parameters."""
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Parameters must be a JSON object")
|
||||
|
||||
operation_type = getattr(self, "initial_data", {}).get("operation_type")
|
||||
|
||||
# Validate required parameters by operation type
|
||||
required_params = {
|
||||
"UPDATE_PARKS": ["park_ids", "updates"],
|
||||
"UPDATE_RIDES": ["ride_ids", "updates"],
|
||||
"IMPORT_DATA": ["data_type", "source"],
|
||||
"EXPORT_DATA": ["data_type", "format"],
|
||||
"MODERATE_CONTENT": ["content_type", "action"],
|
||||
"USER_ACTIONS": ["user_ids", "action"],
|
||||
}
|
||||
|
||||
if operation_type in required_params:
|
||||
for param in required_params[operation_type]:
|
||||
if param not in value:
|
||||
raise serializers.ValidationError(
|
||||
f'Parameter "{param}" is required for {operation_type}'
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create bulk operation with automatic fields."""
|
||||
validated_data["created_by"] = self.context["request"].user
|
||||
validated_data["status"] = "PENDING"
|
||||
validated_data["total_items"] = 0
|
||||
validated_data["processed_items"] = 0
|
||||
validated_data["failed_items"] = 0
|
||||
validated_data["can_cancel"] = True
|
||||
|
||||
# Generate unique ID
|
||||
import uuid
|
||||
|
||||
validated_data["id"] = str(uuid.uuid4())[:50]
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Statistics and Summary Serializers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationStatsSerializer(serializers.Serializer):
|
||||
"""Serializer for moderation statistics."""
|
||||
|
||||
# Report stats
|
||||
total_reports = serializers.IntegerField()
|
||||
pending_reports = serializers.IntegerField()
|
||||
resolved_reports = serializers.IntegerField()
|
||||
overdue_reports = serializers.IntegerField()
|
||||
|
||||
# Queue stats
|
||||
queue_size = serializers.IntegerField()
|
||||
assigned_items = serializers.IntegerField()
|
||||
unassigned_items = serializers.IntegerField()
|
||||
|
||||
# Action stats
|
||||
total_actions = serializers.IntegerField()
|
||||
active_actions = serializers.IntegerField()
|
||||
expired_actions = serializers.IntegerField()
|
||||
|
||||
# Bulk operation stats
|
||||
running_operations = serializers.IntegerField()
|
||||
completed_operations = serializers.IntegerField()
|
||||
failed_operations = serializers.IntegerField()
|
||||
|
||||
# Performance metrics
|
||||
average_resolution_time_hours = serializers.FloatField()
|
||||
reports_by_priority = serializers.DictField()
|
||||
reports_by_type = serializers.DictField()
|
||||
|
||||
|
||||
class UserModerationProfileSerializer(serializers.Serializer):
|
||||
"""Serializer for user moderation profile."""
|
||||
|
||||
user = UserBasicSerializer()
|
||||
|
||||
# Report history
|
||||
reports_made = serializers.IntegerField()
|
||||
reports_against = serializers.IntegerField()
|
||||
|
||||
# Action history
|
||||
warnings_received = serializers.IntegerField()
|
||||
suspensions_received = serializers.IntegerField()
|
||||
active_restrictions = serializers.IntegerField()
|
||||
|
||||
# Risk assessment
|
||||
risk_level = serializers.ChoiceField(choices=["LOW", "MEDIUM", "HIGH", "CRITICAL"])
|
||||
risk_factors = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
# Recent activity
|
||||
recent_reports = ModerationReportSerializer(many=True)
|
||||
recent_actions = ModerationActionSerializer(many=True)
|
||||
|
||||
# Account status
|
||||
account_status = serializers.CharField()
|
||||
last_violation_date = serializers.DateTimeField(allow_null=True)
|
||||
next_review_date = serializers.DateTimeField(allow_null=True)
|
||||
@@ -6,10 +6,11 @@ Following Django styleguide pattern for business logic encapsulation.
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import QuerySet
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from .models import EditSubmission
|
||||
from apps.accounts.models import User
|
||||
from .models import EditSubmission, PhotoSubmission, ModerationQueue
|
||||
|
||||
|
||||
class ModerationService:
|
||||
@@ -133,9 +134,9 @@ class ModerationService:
|
||||
submission = EditSubmission(
|
||||
content_object=content_object,
|
||||
changes=changes,
|
||||
submitted_by=submitter,
|
||||
user=submitter,
|
||||
submission_type=submission_type,
|
||||
notes=notes or "",
|
||||
reason=notes or "",
|
||||
)
|
||||
|
||||
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
|
||||
@@ -228,3 +229,415 @@ class ModerationService:
|
||||
from .selectors import moderation_statistics_summary
|
||||
|
||||
return moderation_statistics_summary(days=days, moderator=moderator)
|
||||
|
||||
@staticmethod
|
||||
def _is_moderator_or_above(user: User) -> bool:
|
||||
"""
|
||||
Check if user has moderator privileges or above.
|
||||
|
||||
Args:
|
||||
user: User to check
|
||||
|
||||
Returns:
|
||||
True if user is MODERATOR, ADMIN, or SUPERUSER
|
||||
"""
|
||||
return user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||
|
||||
@staticmethod
|
||||
def create_edit_submission_with_queue(
|
||||
*,
|
||||
content_object: Optional[object],
|
||||
changes: Dict[str, Any],
|
||||
submitter: User,
|
||||
submission_type: str = "EDIT",
|
||||
reason: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an edit submission with automatic queue routing.
|
||||
|
||||
For moderators and above: Creates submission and auto-approves
|
||||
For regular users: Creates submission and adds to moderation queue
|
||||
|
||||
Args:
|
||||
content_object: The object being edited (None for CREATE)
|
||||
changes: Dictionary of field changes
|
||||
submitter: User submitting the changes
|
||||
submission_type: Type of submission ("CREATE" or "EDIT")
|
||||
reason: Reason for the submission
|
||||
source: Source of information
|
||||
|
||||
Returns:
|
||||
Dictionary with submission info and queue status
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create the submission
|
||||
submission = EditSubmission(
|
||||
content_object=content_object,
|
||||
changes=changes,
|
||||
user=submitter,
|
||||
submission_type=submission_type,
|
||||
reason=reason or "",
|
||||
source=source or "",
|
||||
)
|
||||
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
# Check if user is moderator or above
|
||||
if ModerationService._is_moderator_or_above(submitter):
|
||||
# Auto-approve for moderators
|
||||
try:
|
||||
created_object = submission.approve(submitter)
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'auto_approved',
|
||||
'created_object': created_object,
|
||||
'queue_item': None,
|
||||
'message': 'Submission auto-approved for moderator'
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'queue_item': None,
|
||||
'message': f'Auto-approval failed: {str(e)}'
|
||||
}
|
||||
else:
|
||||
# Create queue item for regular users
|
||||
queue_item = ModerationService._create_queue_item_for_submission(
|
||||
submission=submission,
|
||||
submitter=submitter
|
||||
)
|
||||
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'queued',
|
||||
'created_object': None,
|
||||
'queue_item': queue_item,
|
||||
'message': 'Submission added to moderation queue'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_photo_submission_with_queue(
|
||||
*,
|
||||
content_object: object,
|
||||
photo,
|
||||
caption: str = "",
|
||||
date_taken=None,
|
||||
submitter: User,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a photo submission with automatic queue routing.
|
||||
|
||||
For moderators and above: Creates submission and auto-approves
|
||||
For regular users: Creates submission and adds to moderation queue
|
||||
|
||||
Args:
|
||||
content_object: The object the photo is for
|
||||
photo: The photo file
|
||||
caption: Photo caption
|
||||
date_taken: Date the photo was taken
|
||||
submitter: User submitting the photo
|
||||
|
||||
Returns:
|
||||
Dictionary with submission info and queue status
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create the photo submission
|
||||
submission = PhotoSubmission(
|
||||
content_object=content_object,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
date_taken=date_taken,
|
||||
user=submitter,
|
||||
)
|
||||
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
# Check if user is moderator or above
|
||||
if ModerationService._is_moderator_or_above(submitter):
|
||||
# Auto-approve for moderators
|
||||
try:
|
||||
submission.auto_approve()
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'auto_approved',
|
||||
'queue_item': None,
|
||||
'message': 'Photo submission auto-approved for moderator'
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'failed',
|
||||
'queue_item': None,
|
||||
'message': f'Auto-approval failed: {str(e)}'
|
||||
}
|
||||
else:
|
||||
# Create queue item for regular users
|
||||
queue_item = ModerationService._create_queue_item_for_photo_submission(
|
||||
submission=submission,
|
||||
submitter=submitter
|
||||
)
|
||||
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'queued',
|
||||
'queue_item': queue_item,
|
||||
'message': 'Photo submission added to moderation queue'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_submission(
|
||||
*, submission: EditSubmission, submitter: User
|
||||
) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for an edit submission.
|
||||
|
||||
Args:
|
||||
submission: The edit submission
|
||||
submitter: User who made the submission
|
||||
|
||||
Returns:
|
||||
Created ModerationQueue item
|
||||
"""
|
||||
# Determine content type and entity info
|
||||
content_type = submission.content_type
|
||||
entity_type = content_type.model if content_type else "unknown"
|
||||
entity_id = submission.object_id
|
||||
|
||||
# Create preview data
|
||||
entity_preview = {
|
||||
'submission_type': submission.submission_type,
|
||||
'changes_count': len(submission.changes) if submission.changes else 0,
|
||||
'reason': submission.reason[:100] if submission.reason else "",
|
||||
}
|
||||
|
||||
if submission.content_object:
|
||||
entity_preview['object_name'] = str(submission.content_object)
|
||||
|
||||
# Determine title and description
|
||||
action = "creation" if submission.submission_type == "CREATE" else "edit"
|
||||
title = f"{entity_type.title()} {action} by {submitter.username}"
|
||||
|
||||
description = f"Review {action} submission for {entity_type}"
|
||||
if submission.reason:
|
||||
description += f". Reason: {submission.reason}"
|
||||
|
||||
# Create queue item
|
||||
queue_item = ModerationQueue(
|
||||
item_type='CONTENT_REVIEW',
|
||||
title=title,
|
||||
description=description,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_preview=entity_preview,
|
||||
content_type=content_type,
|
||||
flagged_by=submitter,
|
||||
priority='MEDIUM',
|
||||
estimated_review_time=15, # 15 minutes default
|
||||
tags=['edit_submission', submission.submission_type.lower()],
|
||||
)
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_photo_submission(
|
||||
*, submission: PhotoSubmission, submitter: User
|
||||
) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for a photo submission.
|
||||
|
||||
Args:
|
||||
submission: The photo submission
|
||||
submitter: User who made the submission
|
||||
|
||||
Returns:
|
||||
Created ModerationQueue item
|
||||
"""
|
||||
# Determine content type and entity info
|
||||
content_type = submission.content_type
|
||||
entity_type = content_type.model if content_type else "unknown"
|
||||
entity_id = submission.object_id
|
||||
|
||||
# Create preview data
|
||||
entity_preview = {
|
||||
'caption': submission.caption,
|
||||
'date_taken': submission.date_taken.isoformat() if submission.date_taken else None,
|
||||
'photo_url': submission.photo.url if submission.photo else None,
|
||||
}
|
||||
|
||||
if submission.content_object:
|
||||
entity_preview['object_name'] = str(submission.content_object)
|
||||
|
||||
# Create title and description
|
||||
title = f"Photo submission for {entity_type} by {submitter.username}"
|
||||
description = f"Review photo submission for {entity_type}"
|
||||
if submission.caption:
|
||||
description += f". Caption: {submission.caption}"
|
||||
|
||||
# Create queue item
|
||||
queue_item = ModerationQueue(
|
||||
item_type='CONTENT_REVIEW',
|
||||
title=title,
|
||||
description=description,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_preview=entity_preview,
|
||||
content_type=content_type,
|
||||
flagged_by=submitter,
|
||||
priority='LOW', # Photos typically lower priority
|
||||
estimated_review_time=5, # 5 minutes default for photos
|
||||
tags=['photo_submission'],
|
||||
)
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def process_queue_item(
|
||||
*, queue_item_id: int, moderator: User, action: str, notes: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a moderation queue item (approve, reject, etc.).
|
||||
|
||||
Args:
|
||||
queue_item_id: ID of the queue item to process
|
||||
moderator: User processing the item
|
||||
action: Action to take ('approve', 'reject', 'escalate')
|
||||
notes: Optional notes about the action
|
||||
|
||||
Returns:
|
||||
Dictionary with processing results
|
||||
"""
|
||||
with transaction.atomic():
|
||||
queue_item = ModerationQueue.objects.select_for_update().get(
|
||||
id=queue_item_id
|
||||
)
|
||||
|
||||
if queue_item.status != 'PENDING':
|
||||
raise ValueError(f"Queue item {queue_item_id} is not pending")
|
||||
|
||||
# Find related submission
|
||||
if 'edit_submission' in queue_item.tags:
|
||||
# Find EditSubmission
|
||||
submissions = EditSubmission.objects.filter(
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
status='PENDING'
|
||||
).order_by('-created_at')
|
||||
|
||||
if not submissions.exists():
|
||||
raise ValueError(
|
||||
"No pending edit submission found for this queue item")
|
||||
|
||||
submission = submissions.first()
|
||||
|
||||
if action == 'approve':
|
||||
try:
|
||||
created_object = submission.approve(moderator)
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'approved',
|
||||
'created_object': created_object,
|
||||
'message': 'Submission approved successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'message': f'Approval failed: {str(e)}'
|
||||
}
|
||||
elif action == 'reject':
|
||||
submission.reject(moderator, notes or "Rejected by moderator")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'rejected',
|
||||
'created_object': None,
|
||||
'message': 'Submission rejected'
|
||||
}
|
||||
elif action == 'escalate':
|
||||
submission.escalate(moderator, notes or "Escalated for review")
|
||||
queue_item.priority = 'HIGH'
|
||||
queue_item.status = 'PENDING' # Keep in queue but escalated
|
||||
result = {
|
||||
'status': 'escalated',
|
||||
'created_object': None,
|
||||
'message': 'Submission escalated'
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
elif 'photo_submission' in queue_item.tags:
|
||||
# Find PhotoSubmission
|
||||
submissions = PhotoSubmission.objects.filter(
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
status='PENDING'
|
||||
).order_by('-created_at')
|
||||
|
||||
if not submissions.exists():
|
||||
raise ValueError(
|
||||
"No pending photo submission found for this queue item")
|
||||
|
||||
submission = submissions.first()
|
||||
|
||||
if action == 'approve':
|
||||
try:
|
||||
submission.approve(moderator, notes or "")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'approved',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission approved successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'message': f'Photo approval failed: {str(e)}'
|
||||
}
|
||||
elif action == 'reject':
|
||||
submission.reject(moderator, notes or "Rejected by moderator")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'rejected',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission rejected'
|
||||
}
|
||||
elif action == 'escalate':
|
||||
submission.escalate(moderator, notes or "Escalated for review")
|
||||
queue_item.priority = 'HIGH'
|
||||
queue_item.status = 'PENDING' # Keep in queue but escalated
|
||||
result = {
|
||||
'status': 'escalated',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission escalated'
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
else:
|
||||
raise ValueError("Unknown queue item type")
|
||||
|
||||
# Update queue item
|
||||
queue_item.assigned_to = moderator
|
||||
queue_item.assigned_at = timezone.now()
|
||||
if notes:
|
||||
queue_item.description += f"\n\nModerator notes: {notes}"
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
result['queue_item'] = queue_item
|
||||
return result
|
||||
|
||||
@@ -1,58 +1,87 @@
|
||||
from django.urls import path
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse_lazy
|
||||
from . import views
|
||||
"""
|
||||
Moderation URLs
|
||||
|
||||
This module defines URL patterns for the moderation API endpoints.
|
||||
All endpoints are nested under /api/moderation/ and provide comprehensive
|
||||
moderation functionality including reports, queue management, actions, and bulk operations.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
ModerationReportViewSet,
|
||||
ModerationQueueViewSet,
|
||||
ModerationActionViewSet,
|
||||
BulkOperationViewSet,
|
||||
UserModerationViewSet,
|
||||
)
|
||||
|
||||
# Create router and register viewsets
|
||||
router = DefaultRouter()
|
||||
router.register(r"reports", ModerationReportViewSet, basename="moderation-reports")
|
||||
router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
|
||||
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
|
||||
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
|
||||
router.register(r"users", UserModerationViewSet, basename="user-moderation")
|
||||
|
||||
app_name = "moderation"
|
||||
|
||||
|
||||
def redirect_to_dashboard(request):
|
||||
return redirect(reverse_lazy("moderation:dashboard"))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# Root URL redirects to dashboard
|
||||
path("", redirect_to_dashboard),
|
||||
# Dashboard and Submissions
|
||||
path("dashboard/", views.DashboardView.as_view(), name="dashboard"),
|
||||
path("submissions/", views.submission_list, name="submission_list"),
|
||||
# Search endpoints
|
||||
path("search/parks/", views.search_parks, name="search_parks"),
|
||||
path(
|
||||
"search/ride-models/",
|
||||
views.search_ride_models,
|
||||
name="search_ride_models",
|
||||
),
|
||||
# Submission Actions
|
||||
path(
|
||||
"submissions/<int:submission_id>/edit/",
|
||||
views.edit_submission,
|
||||
name="edit_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/approve/",
|
||||
views.approve_submission,
|
||||
name="approve_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/reject/",
|
||||
views.reject_submission,
|
||||
name="reject_submission",
|
||||
),
|
||||
path(
|
||||
"submissions/<int:submission_id>/escalate/",
|
||||
views.escalate_submission,
|
||||
name="escalate_submission",
|
||||
),
|
||||
# Photo Submissions
|
||||
path(
|
||||
"photos/<int:submission_id>/approve/",
|
||||
views.approve_photo,
|
||||
name="approve_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:submission_id>/reject/",
|
||||
views.reject_photo,
|
||||
name="reject_photo",
|
||||
),
|
||||
# Include all router URLs
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
# URL patterns generated by the router:
|
||||
#
|
||||
# Moderation Reports:
|
||||
# GET /api/moderation/reports/ - List all reports
|
||||
# POST /api/moderation/reports/ - Create new report
|
||||
# GET /api/moderation/reports/{id}/ - Get specific report
|
||||
# PUT /api/moderation/reports/{id}/ - Update report
|
||||
# PATCH /api/moderation/reports/{id}/ - Partial update report
|
||||
# DELETE /api/moderation/reports/{id}/ - Delete report
|
||||
# POST /api/moderation/reports/{id}/assign/ - Assign report to moderator
|
||||
# POST /api/moderation/reports/{id}/resolve/ - Resolve report
|
||||
# GET /api/moderation/reports/stats/ - Get report statistics
|
||||
#
|
||||
# Moderation Queue:
|
||||
# GET /api/moderation/queue/ - List queue items
|
||||
# POST /api/moderation/queue/ - Create queue item
|
||||
# GET /api/moderation/queue/{id}/ - Get specific queue item
|
||||
# PUT /api/moderation/queue/{id}/ - Update queue item
|
||||
# PATCH /api/moderation/queue/{id}/ - Partial update queue item
|
||||
# DELETE /api/moderation/queue/{id}/ - Delete queue item
|
||||
# POST /api/moderation/queue/{id}/assign/ - Assign queue item
|
||||
# POST /api/moderation/queue/{id}/unassign/ - Unassign queue item
|
||||
# POST /api/moderation/queue/{id}/complete/ - Complete queue item
|
||||
# GET /api/moderation/queue/my_queue/ - Get current user's queue items
|
||||
#
|
||||
# Moderation Actions:
|
||||
# GET /api/moderation/actions/ - List all actions
|
||||
# POST /api/moderation/actions/ - Create new action
|
||||
# GET /api/moderation/actions/{id}/ - Get specific action
|
||||
# PUT /api/moderation/actions/{id}/ - Update action
|
||||
# PATCH /api/moderation/actions/{id}/ - Partial update action
|
||||
# DELETE /api/moderation/actions/{id}/ - Delete action
|
||||
# POST /api/moderation/actions/{id}/deactivate/ - Deactivate action
|
||||
# GET /api/moderation/actions/active/ - Get active actions
|
||||
# GET /api/moderation/actions/expired/ - Get expired actions
|
||||
#
|
||||
# Bulk Operations:
|
||||
# GET /api/moderation/bulk-operations/ - List bulk operations
|
||||
# POST /api/moderation/bulk-operations/ - Create bulk operation
|
||||
# GET /api/moderation/bulk-operations/{id}/ - Get specific operation
|
||||
# PUT /api/moderation/bulk-operations/{id}/ - Update operation
|
||||
# PATCH /api/moderation/bulk-operations/{id}/ - Partial update operation
|
||||
# DELETE /api/moderation/bulk-operations/{id}/ - Delete operation
|
||||
# POST /api/moderation/bulk-operations/{id}/cancel/ - Cancel operation
|
||||
# POST /api/moderation/bulk-operations/{id}/retry/ - Retry failed operation
|
||||
# GET /api/moderation/bulk-operations/{id}/logs/ - Get operation logs
|
||||
# GET /api/moderation/bulk-operations/running/ - Get running operations
|
||||
#
|
||||
# User Moderation:
|
||||
# GET /api/moderation/users/{id}/ - Get user moderation profile
|
||||
# POST /api/moderation/users/{id}/moderate/ - Take action against user
|
||||
# GET /api/moderation/users/search/ - Search users for moderation
|
||||
# GET /api/moderation/users/stats/ - Get user moderation statistics
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
from django import forms
|
||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||
from autocomplete import AutocompleteWidget
|
||||
from autocomplete.core import register
|
||||
from autocomplete.shortcuts import ModelAutocomplete
|
||||
from autocomplete.widgets import AutocompleteWidget
|
||||
from .models import Park
|
||||
from .models.location import ParkLocation
|
||||
from .querysets import get_base_park_queryset
|
||||
|
||||
|
||||
class ParkAutocomplete(forms.Form):
|
||||
@register
|
||||
class ParkAutocomplete(ModelAutocomplete):
|
||||
"""Autocomplete for searching parks.
|
||||
|
||||
Features:
|
||||
@@ -19,25 +21,6 @@ class ParkAutocomplete(forms.Form):
|
||||
model = Park
|
||||
search_attrs = ["name"] # We'll match on park names
|
||||
|
||||
def get_search_results(self, search):
|
||||
"""Return search results with related data."""
|
||||
return (
|
||||
get_base_park_queryset()
|
||||
.filter(name__icontains=search)
|
||||
.select_related("operator", "property_owner")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def format_result(self, park):
|
||||
"""Format each park result with status and location."""
|
||||
location = park.formatted_location
|
||||
location_text = f" • {location}" if location else ""
|
||||
return {
|
||||
"key": str(park.pk),
|
||||
"label": park.name,
|
||||
"extra": f"{park.get_status_display()}{location_text}",
|
||||
}
|
||||
|
||||
|
||||
class ParkSearchForm(forms.Form):
|
||||
"""Form for searching parks with autocomplete."""
|
||||
|
||||
@@ -35,8 +35,7 @@ class ParkPhoto(TrackedModel):
|
||||
)
|
||||
|
||||
image = CloudflareImagesField(
|
||||
variant="public",
|
||||
help_text="Park photo stored on Cloudflare Images"
|
||||
variant="public", help_text="Park photo stored on Cloudflare Images"
|
||||
)
|
||||
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
@@ -93,7 +92,9 @@ class ParkPhoto(TrackedModel):
|
||||
ParkPhoto.objects.filter(
|
||||
park=self.park,
|
||||
is_primary=True,
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
).exclude(
|
||||
pk=self.pk
|
||||
).update(is_primary=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class Park(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parks_using_as_banner",
|
||||
help_text="Photo to use as banner image for this park"
|
||||
help_text="Photo to use as banner image for this park",
|
||||
)
|
||||
card_image = models.ForeignKey(
|
||||
"ParkPhoto",
|
||||
@@ -71,7 +71,7 @@ class Park(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parks_using_as_card",
|
||||
help_text="Photo to use as card image for this park"
|
||||
help_text="Photo to use as card image for this park",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
@@ -173,7 +173,7 @@ class Park(TrackedModel):
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Generate frontend URL
|
||||
frontend_domain = getattr(settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
frontend_domain = getattr(settings, "FRONTEND_DOMAIN", "https://thrillwiki.com")
|
||||
self.url = f"{frontend_domain}/parks/{self.slug}/"
|
||||
|
||||
# Save the model
|
||||
|
||||
@@ -4,434 +4,45 @@
|
||||
{% block title %}Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block list_actions %}
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
{# Enhanced View Mode Toggle with Modern Design #}
|
||||
<fieldset class="flex items-center space-x-1 bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-xl p-1 shadow-inner">
|
||||
<legend class="sr-only">View mode selection</legend>
|
||||
|
||||
{# Grid View Button #}
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'view_mode' and key != 'search' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#view-mode-indicator"
|
||||
class="group relative p-3 rounded-lg transition-all duration-300 {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-800 shadow-lg text-blue-600 dark:text-blue-400 scale-105{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50{% endif %}"
|
||||
aria-label="Grid view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5 transition-transform duration-200 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}
|
||||
<div class="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20 animate-pulse"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
{# List View Button #}
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'view_mode' and key != 'search' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#view-mode-indicator"
|
||||
class="group relative p-3 rounded-lg transition-all duration-300 {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-800 shadow-lg text-blue-600 dark:text-blue-400 scale-105{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50{% endif %}"
|
||||
aria-label="List view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'list' %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5 transition-transform duration-200 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
{% if request.GET.view_mode == 'list' %}
|
||||
<div class="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20 animate-pulse"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
{# View Mode Loading Indicator #}
|
||||
<div id="view-mode-indicator" class="htmx-indicator">
|
||||
<div class="flex items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">Switching view...</span>
|
||||
</div>
|
||||
</div>
|
||||
{# Simple View Toggle #}
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded-md text-sm font-medium transition-all {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-600 shadow-sm text-blue-600 dark:text-blue-400{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded-md text-sm font-medium transition-all {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-600 shadow-sm text-blue-600 dark:text-blue-400{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Enhanced Add Park Button #}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}"
|
||||
class="group inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl shadow-lg text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-300 transform hover:scale-105 hover:shadow-xl"
|
||||
data-testid="add-park-button">
|
||||
<svg class="w-5 h-5 mr-2 transition-transform duration-200 group-hover:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
Add Park
|
||||
<div class="absolute inset-0 rounded-xl bg-gradient-to-r from-white/20 to-white/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block filter_section %}
|
||||
<!-- DEBUG: park_list.html filter_section block is being rendered - timestamp: 2025-08-21 -->
|
||||
<div class="mb-6" x-data="parkListManager()" x-init="init()">
|
||||
{# Enhanced Search Section #}
|
||||
<div class="relative mb-8">
|
||||
<div class="w-full relative"
|
||||
x-data="{ query: '', selectedId: null }"
|
||||
@search-selected.window="
|
||||
query = $event.detail;
|
||||
selectedId = $event.target.value;
|
||||
$refs.filterForm.querySelector('input[name=search]').value = query;
|
||||
$refs.filterForm.submit();
|
||||
query = '';
|
||||
">
|
||||
<form hx-get="{% url 'parks:suggest_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-indicator="#search-indicator"
|
||||
x-ref="searchForm">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search"
|
||||
name="search"
|
||||
placeholder="Search parks by name, location, or description..."
|
||||
class="w-full pl-10 pr-12 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 focus:ring-blue-500 transition-colors duration-200"
|
||||
aria-label="Search parks"
|
||||
aria-controls="search-results"
|
||||
:aria-expanded="query !== ''"
|
||||
x-model="query"
|
||||
@keydown.escape="query = ''"
|
||||
@focus="$event.target.select()">
|
||||
|
||||
<!-- Clear search button -->
|
||||
<button type="button"
|
||||
x-show="query"
|
||||
@click="query = ''; $refs.searchForm.querySelector('input').value = ''; $refs.filterForm.submit();"
|
||||
class="absolute inset-y-0 right-8 flex items-center pr-1 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="search-indicator"
|
||||
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
|
||||
role="status"
|
||||
aria-label="Loading search results">
|
||||
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Searching...</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="search-results"
|
||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-600"
|
||||
role="listbox"
|
||||
x-show="query"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95">
|
||||
<!-- Search suggestions will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Filter Chips Section #}
|
||||
<div id="active-filters-section"
|
||||
x-show="hasActiveFilters"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Active Filters</h3>
|
||||
<button type="button"
|
||||
@click="clearAllFilters()"
|
||||
class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium focus:outline-none focus:underline">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2" id="filter-chips-container">
|
||||
<!-- Filter chips will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter Panel #}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Filters</h3>
|
||||
<button type="button"
|
||||
x-data="{ collapsed: false }"
|
||||
@click="collapsed = !collapsed; toggleFilterCollapse()"
|
||||
class="lg:hidden text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium focus:outline-none">
|
||||
<span x-text="collapsed ? 'Show Filters' : 'Hide Filters'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="filter-form"
|
||||
x-ref="filterForm"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change, submit"
|
||||
hx-indicator="#main-loading-indicator"
|
||||
class="mt-4"
|
||||
@htmx:beforeRequest="onFilterRequest()"
|
||||
@htmx:afterRequest="onFilterResponse($event)">
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% include "core/search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Main Loading Indicator #}
|
||||
<div id="main-loading-indicator" class="htmx-indicator fixed top-4 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white px-6 py-3 rounded-lg shadow-lg flex items-center backdrop-blur-sm">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Updating results...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function parkListManager() {
|
||||
return {
|
||||
hasActiveFilters: false,
|
||||
filterCollapsed: false,
|
||||
lastResultCount: 0,
|
||||
|
||||
init() {
|
||||
this.updateActiveFilters();
|
||||
this.setupFilterChips();
|
||||
|
||||
// Listen for form changes to update filter chips
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.closest('#filter-form')) {
|
||||
setTimeout(() => this.updateActiveFilters(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for HTMX responses to update result counts
|
||||
document.addEventListener('htmx:afterSwap', (e) => {
|
||||
if (e.detail.target.id === 'results-container') {
|
||||
this.updateResultInfo();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateActiveFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const activeFilters = [];
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (this.isFilterActive(input)) {
|
||||
activeFilters.push(this.createFilterChip(input));
|
||||
}
|
||||
});
|
||||
|
||||
this.hasActiveFilters = activeFilters.length > 0;
|
||||
this.renderFilterChips(activeFilters);
|
||||
},
|
||||
|
||||
isFilterActive(input) {
|
||||
if (!input.name || input.type === 'hidden') return false;
|
||||
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
return input.checked;
|
||||
}
|
||||
|
||||
return input.value && input.value !== '' && input.value !== 'all' && input.value !== '0';
|
||||
},
|
||||
|
||||
createFilterChip(input) {
|
||||
let label = input.name;
|
||||
let value = input.value;
|
||||
|
||||
// Get human readable label from associated label element
|
||||
const labelElement = document.querySelector(`label[for="${input.id}"]`);
|
||||
if (labelElement) {
|
||||
label = labelElement.textContent.trim();
|
||||
}
|
||||
|
||||
// Format value for display
|
||||
if (input.type === 'checkbox') {
|
||||
value = 'Yes';
|
||||
} else if (input.tagName === 'SELECT') {
|
||||
const selectedOption = input.querySelector(`option[value="${input.value}"]`);
|
||||
if (selectedOption) {
|
||||
value = selectedOption.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: input.name,
|
||||
label: label,
|
||||
value: value,
|
||||
displayText: `${label}: ${value}`
|
||||
};
|
||||
},
|
||||
|
||||
renderFilterChips(chips) {
|
||||
const container = document.getElementById('filter-chips-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = chips.map(chip => `
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
|
||||
${chip.displayText}
|
||||
<button type="button"
|
||||
onclick="removeFilter('${chip.name}')"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100 focus:outline-none"
|
||||
aria-label="Remove ${chip.label} filter">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
this.hasActiveFilters = false;
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
toggleFilterCollapse() {
|
||||
this.filterCollapsed = !this.filterCollapsed;
|
||||
},
|
||||
|
||||
onFilterRequest() {
|
||||
// Add loading state to results
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.style.opacity = '0.6';
|
||||
resultsContainer.style.pointerEvents = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
onFilterResponse(event) {
|
||||
// Remove loading state
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.style.opacity = '1';
|
||||
resultsContainer.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
// Update active filters after response
|
||||
setTimeout(() => this.updateActiveFilters(), 100);
|
||||
},
|
||||
|
||||
updateResultInfo() {
|
||||
// This would update any result count information
|
||||
// Implementation depends on how results are structured
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function to remove individual filters
|
||||
function removeFilter(filterName) {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const input = form.querySelector(`[name="${filterName}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
htmx.trigger(form, 'submit');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block results_list %}
|
||||
<div id="park-results"
|
||||
class="overflow-hidden transition-all duration-300"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
|
||||
{# Enhanced Results Header with Modern Design #}
|
||||
<div class="bg-gradient-to-r from-gray-50 to-white dark:from-gray-800 dark:to-gray-700 border-b border-gray-200/50 dark:border-gray-600/50 px-6 py-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="text-xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
|
||||
Parks
|
||||
{% if parks %}
|
||||
<span class="text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
({{ parks|length }}{% if parks.has_other_pages %} of {{ parks.paginator.count }}{% endif %} found)
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{# Enhanced Results Status Indicator #}
|
||||
{% if request.GET.search or request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-blue-100 to-purple-100 text-blue-800 dark:from-blue-900/30 dark:to-purple-900/30 dark:text-blue-300 border border-blue-200 dark:border-blue-700/50">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z"/>
|
||||
</svg>
|
||||
Filtered Results
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Enhanced Sort Options #}
|
||||
<div class="flex items-center space-x-3">
|
||||
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
|
||||
<div class="relative">
|
||||
<select id="sort-select"
|
||||
name="ordering"
|
||||
form="filter-form"
|
||||
class="appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 pl-3 pr-10 py-2 transition-colors duration-200"
|
||||
onchange="document.getElementById('filter-form').submit()">
|
||||
<option value="">Default</option>
|
||||
<option value="name" {% if request.GET.ordering == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
||||
<option value="-name" {% if request.GET.ordering == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
||||
<option value="-average_rating" {% if request.GET.ordering == '-average_rating' %}selected{% endif %}>Highest Rated</option>
|
||||
<option value="-coaster_count" {% if request.GET.ordering == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
|
||||
<option value="-ride_count" {% if request.GET.ordering == '-ride_count' %}selected{% endif %}>Most Rides</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Results Content with Adaptive Grid #}
|
||||
{# Results Content with Adaptive Grid #}
|
||||
<div class="p-6">
|
||||
{% if parks %}
|
||||
{# Enhanced Responsive Grid Container #}
|
||||
@@ -550,4 +161,4 @@ document.addEventListener('htmx:afterSwap', function(event) {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@ from apps.core.mixins import HTMXFilterableMixin
|
||||
from .models.location import ParkLocation
|
||||
from .models.media import ParkPhoto
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.moderation.services import ModerationService
|
||||
from apps.moderation.mixins import (
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
@@ -501,88 +502,85 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
self.normalize_coordinates(form)
|
||||
changes = self.prepare_changes_data(form.cleaned_data)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
submission_type="CREATE",
|
||||
# Use the new queue routing service
|
||||
result = ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None, # None for CREATE
|
||||
changes=changes,
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=self.request.POST.get("reason", ""),
|
||||
source=self.request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
if (
|
||||
hasattr(self.request.user, "role")
|
||||
and getattr(self.request.user, "role", None) in ALLOWED_ROLES
|
||||
):
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.object_id = self.object.id
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
if result['status'] == 'auto_approved':
|
||||
# Moderator submission was auto-approved
|
||||
self.object = result['created_object']
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
park=self.object,
|
||||
defaults={
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", "USA"),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
},
|
||||
)
|
||||
park_location.set_coordinates(
|
||||
form.cleaned_data["latitude"],
|
||||
form.cleaned_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get(
|
||||
"longitude"
|
||||
):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
park=self.object,
|
||||
defaults={
|
||||
"street_address": form.cleaned_data.get(
|
||||
"street_address", ""
|
||||
),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", "USA"),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
},
|
||||
)
|
||||
park_location.set_coordinates(
|
||||
form.cleaned_data["latitude"],
|
||||
form.cleaned_data["longitude"],
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
park=self.object,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully created {self.object.name}. "
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error creating park: {
|
||||
str(e)
|
||||
}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully created {self.object.name}. "
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
elif result['status'] == 'queued':
|
||||
# Regular user submission was queued
|
||||
messages.success(
|
||||
self.request,
|
||||
"Your park submission has been sent for review. "
|
||||
"You will be notified when it is approved.",
|
||||
)
|
||||
# Redirect to parks list since we don't have an object yet
|
||||
return HttpResponseRedirect(reverse("parks:park_list"))
|
||||
|
||||
elif result['status'] == 'failed':
|
||||
# Auto-approval failed
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error creating park: {result['message']}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Fallback error case
|
||||
messages.error(
|
||||
self.request,
|
||||
"Your park submission has been sent for review. "
|
||||
"You will be notified when it is approved.",
|
||||
"An unexpected error occurred. Please try again.",
|
||||
)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
messages.error(self.request, f"{field}: {error}")
|
||||
return super().form_invalid(form)
|
||||
return self.form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
@@ -633,125 +631,129 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
self.normalize_coordinates(form)
|
||||
changes = self.prepare_changes_data(form.cleaned_data)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
submission_type="EDIT",
|
||||
# Use the new queue routing service
|
||||
result = ModerationService.create_edit_submission_with_queue(
|
||||
content_object=self.object,
|
||||
changes=changes,
|
||||
submitter=self.request.user,
|
||||
submission_type="EDIT",
|
||||
reason=self.request.POST.get("reason", ""),
|
||||
source=self.request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
if (
|
||||
hasattr(self.request.user, "role")
|
||||
and getattr(self.request.user, "role", None) in ALLOWED_ROLES
|
||||
):
|
||||
if result['status'] == 'auto_approved':
|
||||
# Moderator submission was auto-approved
|
||||
# The object was already updated by the service
|
||||
self.object = result['created_object']
|
||||
|
||||
location_data = {
|
||||
"name": self.object.name,
|
||||
"location_type": "park",
|
||||
"latitude": form.cleaned_data.get("latitude"),
|
||||
"longitude": form.cleaned_data.get("longitude"),
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", ""),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
}
|
||||
|
||||
# Create or update ParkLocation
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
park_location = self.object.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
if key in ["latitude", "longitude"] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
|
||||
location_data = {
|
||||
"name": self.object.name,
|
||||
"location_type": "park",
|
||||
"latitude": form.cleaned_data.get("latitude"),
|
||||
"longitude": form.cleaned_data.get("longitude"),
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", ""),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
# Handle coordinates if provided
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
park_location.set_coordinates(
|
||||
float(location_data["latitude"]),
|
||||
float(location_data["longitude"]),
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
coordinates_data = {
|
||||
"latitude": float(location_data["latitude"]),
|
||||
"longitude": float(location_data["longitude"]),
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {
|
||||
k: v
|
||||
for k, v in location_data.items()
|
||||
if k not in ["latitude", "longitude"]
|
||||
}
|
||||
creation_data.setdefault("country", "USA")
|
||||
|
||||
# Create or update ParkLocation
|
||||
try:
|
||||
park_location = self.object.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
if key in ["latitude", "longitude"] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=self.object, **creation_data
|
||||
)
|
||||
|
||||
# Handle coordinates if provided
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
park_location.set_coordinates(
|
||||
float(location_data["latitude"]),
|
||||
float(location_data["longitude"]),
|
||||
)
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data["latitude"],
|
||||
coordinates_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
coordinates_data = {
|
||||
"latitude": float(location_data["latitude"]),
|
||||
"longitude": float(location_data["longitude"]),
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {
|
||||
k: v
|
||||
for k, v in location_data.items()
|
||||
if k not in ["latitude", "longitude"]
|
||||
}
|
||||
creation_data.setdefault("country", "USA")
|
||||
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=self.object, **creation_data
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data["latitude"],
|
||||
coordinates_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully updated {self.object.name}. "
|
||||
f"Added {uploaded_count} new photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error updating park: {
|
||||
str(e)
|
||||
}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully updated {self.object.name}. "
|
||||
f"Added {uploaded_count} new photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
elif result['status'] == 'queued':
|
||||
# Regular user submission was queued
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Your changes to {self.object.name} have been sent for review. "
|
||||
"You will be notified when they are approved.",
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
)
|
||||
|
||||
elif result['status'] == 'failed':
|
||||
# Auto-approval failed
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error updating park: {result['message']}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Fallback error case
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Your changes to {self.object.name} have been sent for review. "
|
||||
"You will be notified when they are approved.",
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
"An unexpected error occurred. Please try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||
messages.error(self.request, REQUIRED_FIELDS_ERROR)
|
||||
|
||||
@@ -346,9 +346,9 @@ class RideForm(forms.ModelForm):
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields[
|
||||
"manufacturer_search"
|
||||
].initial = self.instance.manufacturer.name
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
|
||||
@@ -346,9 +346,9 @@ class RideForm(forms.ModelForm):
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields[
|
||||
"manufacturer_search"
|
||||
].initial = self.instance.manufacturer.name
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
|
||||
@@ -6,8 +6,8 @@ from django.utils.text import slugify
|
||||
|
||||
def populate_ride_model_slugs(apps, schema_editor):
|
||||
"""Populate unique slugs for existing RideModel records."""
|
||||
RideModel = apps.get_model('rides', 'RideModel')
|
||||
Company = apps.get_model('rides', 'Company')
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
Company = apps.get_model("rides", "Company")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate base slug from manufacturer name + model name
|
||||
@@ -25,13 +25,13 @@ def populate_ride_model_slugs(apps, schema_editor):
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = slug
|
||||
ride_model.save(update_fields=['slug'])
|
||||
ride_model.save(update_fields=["slug"])
|
||||
|
||||
|
||||
def reverse_populate_ride_model_slugs(apps, schema_editor):
|
||||
"""Reverse operation - clear slugs (not really needed but for completeness)."""
|
||||
RideModel = apps.get_model('rides', 'RideModel')
|
||||
RideModel.objects.all().update(slug='')
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
RideModel.objects.all().update(slug="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.utils.text import slugify
|
||||
|
||||
def update_ride_model_slugs(apps, schema_editor):
|
||||
"""Update RideModel slugs to be just the model name, not manufacturer + name."""
|
||||
RideModel = apps.get_model('rides', 'RideModel')
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate new slug from just the name
|
||||
@@ -15,22 +15,25 @@ def update_ride_model_slugs(apps, schema_editor):
|
||||
# Ensure uniqueness within the same manufacturer
|
||||
counter = 1
|
||||
base_slug = new_slug
|
||||
while RideModel.objects.filter(
|
||||
manufacturer=ride_model.manufacturer,
|
||||
slug=new_slug
|
||||
).exclude(pk=ride_model.pk).exists():
|
||||
while (
|
||||
RideModel.objects.filter(
|
||||
manufacturer=ride_model.manufacturer, slug=new_slug
|
||||
)
|
||||
.exclude(pk=ride_model.pk)
|
||||
.exists()
|
||||
):
|
||||
new_slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = new_slug
|
||||
ride_model.save(update_fields=['slug'])
|
||||
ride_model.save(update_fields=["slug"])
|
||||
print(f"Updated {ride_model.name}: {ride_model.slug}")
|
||||
|
||||
|
||||
def reverse_ride_model_slugs(apps, schema_editor):
|
||||
"""Reverse the slug update by regenerating the old format."""
|
||||
RideModel = apps.get_model('rides', 'RideModel')
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate old-style slug with manufacturer + name
|
||||
@@ -41,19 +44,21 @@ def reverse_ride_model_slugs(apps, schema_editor):
|
||||
# Ensure uniqueness globally (old way)
|
||||
counter = 1
|
||||
base_slug = old_slug
|
||||
while RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists():
|
||||
while (
|
||||
RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists()
|
||||
):
|
||||
old_slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = old_slug
|
||||
ride_model.save(update_fields=['slug'])
|
||||
ride_model.save(update_fields=["slug"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rides', '0013_fix_ride_model_slugs'),
|
||||
("rides", "0013_fix_ride_model_slugs"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -49,12 +49,13 @@ class Company(TrackedModel):
|
||||
# OPERATOR and PROPERTY_OWNER are for parks domain and handled separately
|
||||
if self.roles:
|
||||
frontend_domain = getattr(
|
||||
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
primary_role = self.roles[0] # Use first role as primary
|
||||
|
||||
if primary_role == 'MANUFACTURER':
|
||||
if primary_role == "MANUFACTURER":
|
||||
self.url = f"{frontend_domain}/rides/manufacturers/{self.slug}/"
|
||||
elif primary_role == 'DESIGNER':
|
||||
elif primary_role == "DESIGNER":
|
||||
self.url = f"{frontend_domain}/rides/designers/{self.slug}/"
|
||||
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain, not here
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ class RidePhoto(TrackedModel):
|
||||
)
|
||||
|
||||
image = CloudflareImagesField(
|
||||
variant="public",
|
||||
help_text="Ride photo stored on Cloudflare Images"
|
||||
variant="public", help_text="Ride photo stored on Cloudflare Images"
|
||||
)
|
||||
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
@@ -111,7 +110,9 @@ class RidePhoto(TrackedModel):
|
||||
RidePhoto.objects.filter(
|
||||
ride=self.ride,
|
||||
is_primary=True,
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
).exclude(
|
||||
pk=self.pk
|
||||
).update(is_primary=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ class RideModel(TrackedModel):
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=255, help_text="Name of the ride model")
|
||||
slug = models.SlugField(max_length=255,
|
||||
help_text="URL-friendly identifier (unique within manufacturer)")
|
||||
slug = models.SlugField(
|
||||
max_length=255, help_text="URL-friendly identifier (unique within manufacturer)"
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -40,115 +41,133 @@ class RideModel(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
help_text="Primary manufacturer of this ride model"
|
||||
help_text="Primary manufacturer of this ride model",
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Detailed description of the ride model")
|
||||
blank=True, help_text="Detailed description of the ride model"
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default="",
|
||||
blank=True,
|
||||
help_text="Primary category classification"
|
||||
help_text="Primary category classification",
|
||||
)
|
||||
|
||||
# Technical specifications
|
||||
typical_height_range_min_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
help_text="Minimum typical height in feet for this model"
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical height in feet for this model",
|
||||
)
|
||||
typical_height_range_max_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
help_text="Maximum typical height in feet for this model"
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical height in feet for this model",
|
||||
)
|
||||
typical_speed_range_min_mph = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True,
|
||||
help_text="Minimum typical speed in mph for this model"
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical speed in mph for this model",
|
||||
)
|
||||
typical_speed_range_max_mph = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True,
|
||||
help_text="Maximum typical speed in mph for this model"
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical speed in mph for this model",
|
||||
)
|
||||
typical_capacity_range_min = models.PositiveIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Minimum typical hourly capacity for this model"
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical hourly capacity for this model",
|
||||
)
|
||||
typical_capacity_range_max = models.PositiveIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Maximum typical hourly capacity for this model"
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical hourly capacity for this model",
|
||||
)
|
||||
|
||||
# Design characteristics
|
||||
track_type = models.CharField(
|
||||
max_length=100, blank=True,
|
||||
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)"
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)",
|
||||
)
|
||||
support_structure = models.CharField(
|
||||
max_length=100, blank=True,
|
||||
help_text="Type of support structure (e.g., steel, wooden, hybrid)"
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of support structure (e.g., steel, wooden, hybrid)",
|
||||
)
|
||||
train_configuration = models.CharField(
|
||||
max_length=200, blank=True,
|
||||
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)"
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)",
|
||||
)
|
||||
restraint_system = models.CharField(
|
||||
max_length=100, blank=True,
|
||||
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)"
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)",
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = models.PositiveIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Year of first installation of this model"
|
||||
null=True, blank=True, help_text="Year of first installation of this model"
|
||||
)
|
||||
last_installation_year = models.PositiveIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Year of last installation of this model (if discontinued)"
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Year of last installation of this model (if discontinued)",
|
||||
)
|
||||
is_discontinued = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this model is no longer being manufactured"
|
||||
default=False, help_text="Whether this model is no longer being manufactured"
|
||||
)
|
||||
total_installations = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Total number of installations worldwide (auto-calculated)"
|
||||
default=0, help_text="Total number of installations worldwide (auto-calculated)"
|
||||
)
|
||||
|
||||
# Design features
|
||||
notable_features = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notable design features or innovations (JSON or comma-separated)"
|
||||
help_text="Notable design features or innovations (JSON or comma-separated)",
|
||||
)
|
||||
target_market = models.CharField(
|
||||
max_length=50, blank=True,
|
||||
max_length=50,
|
||||
blank=True,
|
||||
choices=[
|
||||
('FAMILY', 'Family'),
|
||||
('THRILL', 'Thrill'),
|
||||
('EXTREME', 'Extreme'),
|
||||
('KIDDIE', 'Kiddie'),
|
||||
('ALL_AGES', 'All Ages'),
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
help_text="Primary target market for this ride model"
|
||||
help_text="Primary target market for this ride model",
|
||||
)
|
||||
|
||||
# Media
|
||||
primary_image = models.ForeignKey(
|
||||
'RideModelPhoto',
|
||||
"RideModelPhoto",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='ride_models_as_primary',
|
||||
help_text="Primary promotional image for this ride model"
|
||||
related_name="ride_models_as_primary",
|
||||
help_text="Primary promotional image for this ride model",
|
||||
)
|
||||
|
||||
# SEO and metadata
|
||||
meta_title = models.CharField(
|
||||
max_length=60, blank=True,
|
||||
help_text="SEO meta title (auto-generated if blank)"
|
||||
max_length=60, blank=True, help_text="SEO meta title (auto-generated if blank)"
|
||||
)
|
||||
meta_description = models.CharField(
|
||||
max_length=160, blank=True,
|
||||
help_text="SEO meta description (auto-generated if blank)"
|
||||
max_length=160,
|
||||
blank=True,
|
||||
help_text="SEO meta description (auto-generated if blank)",
|
||||
)
|
||||
|
||||
# Frontend URL
|
||||
@@ -156,17 +175,18 @@ class RideModel(TrackedModel):
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["manufacturer__name", "name"]
|
||||
unique_together = [
|
||||
["manufacturer", "name"],
|
||||
["manufacturer", "slug"]
|
||||
]
|
||||
unique_together = [["manufacturer", "name"], ["manufacturer", "slug"]]
|
||||
constraints = [
|
||||
# Height range validation
|
||||
models.CheckConstraint(
|
||||
name="ride_model_height_range_logical",
|
||||
condition=models.Q(typical_height_range_min_ft__isnull=True)
|
||||
| models.Q(typical_height_range_max_ft__isnull=True)
|
||||
| models.Q(typical_height_range_min_ft__lte=models.F("typical_height_range_max_ft")),
|
||||
| models.Q(
|
||||
typical_height_range_min_ft__lte=models.F(
|
||||
"typical_height_range_max_ft"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum height cannot exceed maximum height",
|
||||
),
|
||||
# Speed range validation
|
||||
@@ -174,7 +194,11 @@ class RideModel(TrackedModel):
|
||||
name="ride_model_speed_range_logical",
|
||||
condition=models.Q(typical_speed_range_min_mph__isnull=True)
|
||||
| models.Q(typical_speed_range_max_mph__isnull=True)
|
||||
| models.Q(typical_speed_range_min_mph__lte=models.F("typical_speed_range_max_mph")),
|
||||
| models.Q(
|
||||
typical_speed_range_min_mph__lte=models.F(
|
||||
"typical_speed_range_max_mph"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum speed cannot exceed maximum speed",
|
||||
),
|
||||
# Capacity range validation
|
||||
@@ -182,7 +206,11 @@ class RideModel(TrackedModel):
|
||||
name="ride_model_capacity_range_logical",
|
||||
condition=models.Q(typical_capacity_range_min__isnull=True)
|
||||
| models.Q(typical_capacity_range_max__isnull=True)
|
||||
| models.Q(typical_capacity_range_min__lte=models.F("typical_capacity_range_max")),
|
||||
| models.Q(
|
||||
typical_capacity_range_min__lte=models.F(
|
||||
"typical_capacity_range_max"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum capacity cannot exceed maximum capacity",
|
||||
),
|
||||
# Installation years validation
|
||||
@@ -190,7 +218,9 @@ class RideModel(TrackedModel):
|
||||
name="ride_model_installation_years_logical",
|
||||
condition=models.Q(first_installation_year__isnull=True)
|
||||
| models.Q(last_installation_year__isnull=True)
|
||||
| models.Q(first_installation_year__lte=models.F("last_installation_year")),
|
||||
| models.Q(
|
||||
first_installation_year__lte=models.F("last_installation_year")
|
||||
),
|
||||
violation_error_message="First installation year cannot be after last installation year",
|
||||
),
|
||||
]
|
||||
@@ -205,16 +235,18 @@ class RideModel(TrackedModel):
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
from django.utils.text import slugify
|
||||
|
||||
# Only use the ride model name for the slug, not manufacturer
|
||||
base_slug = slugify(self.name)
|
||||
self.slug = base_slug
|
||||
|
||||
# Ensure uniqueness within the same manufacturer
|
||||
counter = 1
|
||||
while RideModel.objects.filter(
|
||||
manufacturer=self.manufacturer,
|
||||
slug=self.slug
|
||||
).exclude(pk=self.pk).exists():
|
||||
while (
|
||||
RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug)
|
||||
.exclude(pk=self.pk)
|
||||
.exists()
|
||||
):
|
||||
self.slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
@@ -222,14 +254,16 @@ class RideModel(TrackedModel):
|
||||
if not self.meta_title:
|
||||
self.meta_title = str(self)[:60]
|
||||
if not self.meta_description:
|
||||
desc = f"{self} - {self.description[:100]}" if self.description else str(
|
||||
self)
|
||||
desc = (
|
||||
f"{self} - {self.description[:100]}" if self.description else str(self)
|
||||
)
|
||||
self.meta_description = desc[:160]
|
||||
|
||||
# Generate frontend URL
|
||||
if self.manufacturer:
|
||||
frontend_domain = getattr(
|
||||
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
@@ -238,9 +272,10 @@ class RideModel(TrackedModel):
|
||||
"""Update the total installations count based on actual ride instances."""
|
||||
# Import here to avoid circular import
|
||||
from django.apps import apps
|
||||
Ride = apps.get_model('rides', 'Ride')
|
||||
|
||||
Ride = apps.get_model("rides", "Ride")
|
||||
self.total_installations = Ride.objects.filter(ride_model=self).count()
|
||||
self.save(update_fields=['total_installations'])
|
||||
self.save(update_fields=["total_installations"])
|
||||
|
||||
@property
|
||||
def installation_years_range(self) -> str:
|
||||
@@ -248,7 +283,11 @@ class RideModel(TrackedModel):
|
||||
if self.first_installation_year and self.last_installation_year:
|
||||
return f"{self.first_installation_year}-{self.last_installation_year}"
|
||||
elif self.first_installation_year:
|
||||
return f"{self.first_installation_year}-present" if not self.is_discontinued else f"{self.first_installation_year}+"
|
||||
return (
|
||||
f"{self.first_installation_year}-present"
|
||||
if not self.is_discontinued
|
||||
else f"{self.first_installation_year}+"
|
||||
)
|
||||
return "Unknown"
|
||||
|
||||
@property
|
||||
@@ -282,13 +321,12 @@ class RideModelVariant(TrackedModel):
|
||||
"""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="variants"
|
||||
RideModel, on_delete=models.CASCADE, related_name="variants"
|
||||
)
|
||||
name = models.CharField(max_length=255, help_text="Name of this variant")
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Description of variant differences")
|
||||
blank=True, help_text="Description of variant differences"
|
||||
)
|
||||
|
||||
# Variant-specific specifications
|
||||
min_height_ft = models.DecimalField(
|
||||
@@ -306,8 +344,7 @@ class RideModelVariant(TrackedModel):
|
||||
|
||||
# Distinguishing features
|
||||
distinguishing_features = models.TextField(
|
||||
blank=True,
|
||||
help_text="What makes this variant unique from the base model"
|
||||
blank=True, help_text="What makes this variant unique from the base model"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
@@ -323,13 +360,10 @@ class RideModelPhoto(TrackedModel):
|
||||
"""Photos associated with ride models for catalog/promotional purposes."""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="photos"
|
||||
RideModel, on_delete=models.CASCADE, related_name="photos"
|
||||
)
|
||||
image = models.ImageField(
|
||||
upload_to="ride_models/photos/",
|
||||
help_text="Photo of the ride model"
|
||||
upload_to="ride_models/photos/", help_text="Photo of the ride model"
|
||||
)
|
||||
caption = models.CharField(max_length=500, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
@@ -338,18 +372,17 @@ class RideModelPhoto(TrackedModel):
|
||||
photo_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('PROMOTIONAL', 'Promotional'),
|
||||
('TECHNICAL', 'Technical Drawing'),
|
||||
('INSTALLATION', 'Installation Example'),
|
||||
('RENDERING', '3D Rendering'),
|
||||
('CATALOG', 'Catalog Image'),
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default='PROMOTIONAL'
|
||||
default="PROMOTIONAL",
|
||||
)
|
||||
|
||||
is_primary = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this is the primary photo for the ride model"
|
||||
default=False, help_text="Whether this is the primary photo for the ride model"
|
||||
)
|
||||
|
||||
# Attribution
|
||||
@@ -367,8 +400,7 @@ class RideModelPhoto(TrackedModel):
|
||||
# Ensure only one primary photo per ride model
|
||||
if self.is_primary:
|
||||
RideModelPhoto.objects.filter(
|
||||
ride_model=self.ride_model,
|
||||
is_primary=True
|
||||
ride_model=self.ride_model, is_primary=True
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -381,32 +413,33 @@ class RideModelTechnicalSpec(TrackedModel):
|
||||
"""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="technical_specs"
|
||||
RideModel, on_delete=models.CASCADE, related_name="technical_specs"
|
||||
)
|
||||
|
||||
spec_category = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('DIMENSIONS', 'Dimensions'),
|
||||
('PERFORMANCE', 'Performance'),
|
||||
('CAPACITY', 'Capacity'),
|
||||
('SAFETY', 'Safety Features'),
|
||||
('ELECTRICAL', 'Electrical Requirements'),
|
||||
('FOUNDATION', 'Foundation Requirements'),
|
||||
('MAINTENANCE', 'Maintenance'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
)
|
||||
|
||||
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
|
||||
spec_value = models.CharField(
|
||||
max_length=255, help_text="Value of the specification")
|
||||
spec_unit = models.CharField(max_length=20, blank=True,
|
||||
help_text="Unit of measurement")
|
||||
max_length=255, help_text="Value of the specification"
|
||||
)
|
||||
spec_unit = models.CharField(
|
||||
max_length=20, blank=True, help_text="Unit of measurement"
|
||||
)
|
||||
notes = models.TextField(
|
||||
blank=True, help_text="Additional notes about this specification")
|
||||
blank=True, help_text="Additional notes about this specification"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["spec_category", "spec_name"]
|
||||
@@ -510,7 +543,7 @@ class Ride(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rides_using_as_banner",
|
||||
help_text="Photo to use as banner image for this ride"
|
||||
help_text="Photo to use as banner image for this ride",
|
||||
)
|
||||
card_image = models.ForeignKey(
|
||||
"RidePhoto",
|
||||
@@ -518,13 +551,14 @@ class Ride(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rides_using_as_card",
|
||||
help_text="Photo to use as card image for this ride"
|
||||
help_text="Photo to use as card image for this ride",
|
||||
)
|
||||
|
||||
# Frontend URL
|
||||
url = models.URLField(blank=True, help_text="Frontend URL for this ride")
|
||||
park_url = models.URLField(
|
||||
blank=True, help_text="Frontend URL for this ride's park")
|
||||
blank=True, help_text="Frontend URL for this ride's park"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["name"]
|
||||
@@ -596,7 +630,8 @@ class Ride(TrackedModel):
|
||||
# Generate frontend URLs
|
||||
if self.park:
|
||||
frontend_domain = getattr(
|
||||
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/"
|
||||
self.park_url = f"{frontend_domain}/parks/{self.park.slug}/"
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class RideRankingService:
|
||||
processed = 0
|
||||
|
||||
for i, ride_a in enumerate(rides):
|
||||
for ride_b in rides[i + 1:]:
|
||||
for ride_b in rides[i + 1 :]:
|
||||
comparison = self._calculate_pairwise_comparison(ride_a, ride_b)
|
||||
if comparison:
|
||||
# Store both directions for easy lookup
|
||||
|
||||
@@ -14,6 +14,7 @@ from .services.search import RideSearchService
|
||||
from apps.parks.models import Park
|
||||
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.moderation.services import ModerationService
|
||||
from .models.rankings import RideRanking, RankingSnapshot
|
||||
from .services.ranking_service import RideRankingService
|
||||
|
||||
@@ -102,35 +103,38 @@ class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
|
||||
# Check for new manufacturer
|
||||
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
||||
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New manufacturer suggested during ride creation: {manufacturer_name}",
|
||||
)
|
||||
|
||||
# Check for new designer
|
||||
designer_name = form.cleaned_data.get("designer_search")
|
||||
if designer_name and not form.cleaned_data.get("designer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New designer suggested during ride creation: {designer_name}",
|
||||
)
|
||||
|
||||
# Check for new ride model
|
||||
ride_model_name = form.cleaned_data.get("ride_model_search")
|
||||
manufacturer = form.cleaned_data.get("manufacturer")
|
||||
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(RideModel),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={
|
||||
"name": ride_model_name,
|
||||
"manufacturer": manufacturer.id,
|
||||
},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New ride model suggested during ride creation: {ride_model_name}",
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
@@ -180,35 +184,38 @@ class RideUpdateView(
|
||||
# Check for new manufacturer
|
||||
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
||||
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New manufacturer suggested during ride update: {manufacturer_name}",
|
||||
)
|
||||
|
||||
# Check for new designer
|
||||
designer_name = form.cleaned_data.get("designer_search")
|
||||
if designer_name and not form.cleaned_data.get("designer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New designer suggested during ride update: {designer_name}",
|
||||
)
|
||||
|
||||
# Check for new ride model
|
||||
ride_model_name = form.cleaned_data.get("ride_model_search")
|
||||
manufacturer = form.cleaned_data.get("manufacturer")
|
||||
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(RideModel),
|
||||
submission_type="CREATE",
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={
|
||||
"name": ride_model_name,
|
||||
"manufacturer": manufacturer.id,
|
||||
},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New ride model suggested during ride update: {ride_model_name}",
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
@@ -12,58 +12,52 @@ import os
|
||||
from celery import Celery
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.django.local')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.local")
|
||||
|
||||
app = Celery('thrillwiki')
|
||||
app = Celery("thrillwiki")
|
||||
|
||||
# Get Redis URL from environment variable with fallback
|
||||
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/1')
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/1")
|
||||
|
||||
# Celery Configuration - set directly without loading from Django settings first
|
||||
app.conf.update(
|
||||
# Broker settings
|
||||
broker_url=REDIS_URL,
|
||||
result_backend=REDIS_URL,
|
||||
|
||||
# Task settings
|
||||
task_serializer='json',
|
||||
accept_content=['json'],
|
||||
result_serializer='json',
|
||||
timezone='America/New_York',
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="America/New_York",
|
||||
enable_utc=True,
|
||||
|
||||
# Worker settings
|
||||
worker_prefetch_multiplier=1,
|
||||
task_acks_late=True,
|
||||
worker_max_tasks_per_child=1000,
|
||||
|
||||
# Task routing
|
||||
task_routes={
|
||||
'apps.core.tasks.trending.*': {'queue': 'trending'},
|
||||
'apps.core.tasks.analytics.*': {'queue': 'analytics'},
|
||||
'apps.core.tasks.cache.*': {'queue': 'cache'},
|
||||
"apps.core.tasks.trending.*": {"queue": "trending"},
|
||||
"apps.core.tasks.analytics.*": {"queue": "analytics"},
|
||||
"apps.core.tasks.cache.*": {"queue": "cache"},
|
||||
},
|
||||
|
||||
# Beat schedule for periodic tasks
|
||||
beat_schedule={
|
||||
'calculate-trending-content': {
|
||||
'task': 'apps.core.tasks.trending.calculate_trending_content',
|
||||
'schedule': 300.0, # Every 5 minutes
|
||||
"calculate-trending-content": {
|
||||
"task": "apps.core.tasks.trending.calculate_trending_content",
|
||||
"schedule": 300.0, # Every 5 minutes
|
||||
},
|
||||
'warm-trending-cache': {
|
||||
'task': 'apps.core.tasks.trending.warm_trending_cache',
|
||||
'schedule': 900.0, # Every 15 minutes
|
||||
"warm-trending-cache": {
|
||||
"task": "apps.core.tasks.trending.warm_trending_cache",
|
||||
"schedule": 900.0, # Every 15 minutes
|
||||
},
|
||||
'cleanup-old-analytics': {
|
||||
'task': 'apps.core.tasks.analytics.cleanup_old_analytics',
|
||||
'schedule': 86400.0, # Daily
|
||||
"cleanup-old-analytics": {
|
||||
"task": "apps.core.tasks.analytics.cleanup_old_analytics",
|
||||
"schedule": 86400.0, # Daily
|
||||
},
|
||||
},
|
||||
|
||||
# Task result settings
|
||||
result_expires=3600, # 1 hour
|
||||
task_ignore_result=False,
|
||||
|
||||
# Error handling
|
||||
task_reject_on_worker_lost=True,
|
||||
task_soft_time_limit=300, # 5 minutes
|
||||
@@ -77,4 +71,4 @@ app.autodiscover_tasks()
|
||||
@app.task(bind=True)
|
||||
def debug_task(self):
|
||||
"""Debug task for testing Celery setup."""
|
||||
print(f'Request: {self.request!r}')
|
||||
print(f"Request: {self.request!r}")
|
||||
|
||||
@@ -17,7 +17,6 @@ DATABASE_URL = config("DATABASE_URL")
|
||||
CACHE_URL = config("CACHE_URL", default="locmem://")
|
||||
EMAIL_URL = config("EMAIL_URL", default="console://")
|
||||
REDIS_URL = config("REDIS_URL", default="redis://127.0.0.1:6379/1")
|
||||
CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool)
|
||||
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default=[])
|
||||
API_RATE_LIMIT_PER_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60)
|
||||
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000)
|
||||
@@ -47,8 +46,9 @@ SECRET_KEY = config("SECRET_KEY")
|
||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS")
|
||||
|
||||
# CSRF trusted origins
|
||||
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS",
|
||||
default=[]) # type: ignore[arg-type]
|
||||
CSRF_TRUSTED_ORIGINS = config(
|
||||
"CSRF_TRUSTED_ORIGINS", default=[]
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
# Application definition
|
||||
DJANGO_APPS = [
|
||||
@@ -88,6 +88,7 @@ THIRD_PARTY_APPS = [
|
||||
"health_check.contrib.redis",
|
||||
"django_celery_beat", # Celery beat scheduler
|
||||
"django_celery_results", # Celery result backend
|
||||
"django_extensions", # Django Extensions for enhanced development tools
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
@@ -269,9 +270,9 @@ AUTH_USER_MODEL = "accounts.User"
|
||||
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = False
|
||||
|
||||
# Tailwind configuration
|
||||
TAILWIND_CLI_CONFIG_FILE = BASE_DIR / "tailwind.config.js"
|
||||
TAILWIND_CLI_SRC_CSS = BASE_DIR / "static" / "css" / "src" / "input.css"
|
||||
TAILWIND_CLI_DIST_CSS = BASE_DIR / "static" / "css" / "tailwind.css"
|
||||
TAILWIND_CLI_CONFIG_FILE = "tailwind.config.js"
|
||||
TAILWIND_CLI_SRC_CSS = "static/css/src/input.css"
|
||||
TAILWIND_CLI_DIST_CSS = "css/tailwind.css"
|
||||
|
||||
# Test runner
|
||||
TEST_RUNNER = "django.test.runner.DiscoverRunner"
|
||||
@@ -323,17 +324,19 @@ REST_FRAMEWORK = {
|
||||
}
|
||||
|
||||
# CORS Settings for API
|
||||
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS",
|
||||
default=[]) # type: ignore[arg-type]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = config(
|
||||
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool) # type: ignore[arg-type]
|
||||
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
|
||||
API_RATE_LIMIT_PER_MINUTE = config(
|
||||
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int) # type: ignore[arg-type]
|
||||
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int
|
||||
) # type: ignore[arg-type]
|
||||
API_RATE_LIMIT_PER_HOUR = config(
|
||||
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int) # type: ignore[arg-type]
|
||||
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int
|
||||
) # type: ignore[arg-type]
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "ThrillWiki API",
|
||||
"DESCRIPTION": "Comprehensive theme park and ride information API",
|
||||
|
||||
@@ -4,30 +4,7 @@ Local development settings for thrillwiki project.
|
||||
|
||||
from ..settings import database
|
||||
import logging
|
||||
from .base import (
|
||||
BASE_DIR,
|
||||
INSTALLED_APPS,
|
||||
MIDDLEWARE,
|
||||
STATIC_ROOT,
|
||||
STATIC_URL,
|
||||
ROOT_URLCONF,
|
||||
AUTH_PASSWORD_VALIDATORS,
|
||||
AUTH_USER_MODEL,
|
||||
TEMPLATES,
|
||||
SECRET_KEY,
|
||||
SPECTACULAR_SETTINGS,
|
||||
REST_FRAMEWORK,
|
||||
)
|
||||
|
||||
SECRET_KEY = SECRET_KEY
|
||||
SPECTACULAR_SETTINGS = SPECTACULAR_SETTINGS
|
||||
REST_FRAMEWORK = REST_FRAMEWORK
|
||||
TEMPLATES = TEMPLATES
|
||||
ROOT_URLCONF = ROOT_URLCONF
|
||||
AUTH_PASSWORD_VALIDATORS = AUTH_PASSWORD_VALIDATORS
|
||||
AUTH_USER_MODEL = AUTH_USER_MODEL
|
||||
STATIC_ROOT = STATIC_ROOT
|
||||
STATIC_URL = STATIC_URL
|
||||
from .base import *
|
||||
|
||||
# Import database configuration
|
||||
DATABASES = database.DATABASES
|
||||
|
||||
@@ -7,449 +7,426 @@
|
||||
--font-family-sans: Poppins, sans-serif;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center px-6 py-2.5 border border-transparent rounded-full shadow-md text-sm font-medium text-white bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center px-6 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full shadow-md text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
|
||||
}
|
||||
/* [Previous styles remain unchanged until mobile menu section...] */
|
||||
|
||||
/* Mobile Menu */
|
||||
#mobileMenu {
|
||||
@apply overflow-hidden transition-all duration-300 ease-in-out opacity-0 max-h-0;
|
||||
}
|
||||
|
||||
#mobileMenu.show {
|
||||
@apply max-h-[300px] opacity-100;
|
||||
}
|
||||
|
||||
#mobileMenu .space-y-4 {
|
||||
@apply pb-6;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
@apply flex items-center justify-center px-6 py-3 text-gray-700 transition-all border border-transparent rounded-lg dark:text-gray-200 hover:bg-primary/10 dark:hover:bg-primary/20 hover:text-primary dark:hover:text-primary hover:border-primary/20 dark:hover:border-primary/30;
|
||||
}
|
||||
|
||||
.mobile-nav-link i {
|
||||
@apply text-xl transition-colors;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.mobile-nav-link i {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-nav-link.primary {
|
||||
@apply text-white bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90;
|
||||
}
|
||||
|
||||
.mobile-nav-link.primary i {
|
||||
@apply mr-3 text-white;
|
||||
}
|
||||
|
||||
.mobile-nav-link.secondary {
|
||||
@apply text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600;
|
||||
}
|
||||
|
||||
.mobile-nav-link.secondary i {
|
||||
@apply mr-3 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
#theme-toggle+.theme-toggle-btn i::before {
|
||||
content: "\f186";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
#theme-toggle:checked+.theme-toggle-btn i::before {
|
||||
content: "\f185";
|
||||
@apply text-yellow-400;
|
||||
}
|
||||
|
||||
/* Navigation Components */
|
||||
.nav-link {
|
||||
@apply flex items-center text-gray-700 transition-all dark:text-gray-200;
|
||||
}
|
||||
|
||||
/* Extra small screens (540px and below) */
|
||||
@media (max-width: 540px) {
|
||||
.nav-link {
|
||||
@apply px-2 py-2 text-sm;
|
||||
}
|
||||
.nav-link i {
|
||||
@apply mr-1 text-base;
|
||||
}
|
||||
.nav-link span {
|
||||
@apply text-sm;
|
||||
}
|
||||
.site-logo {
|
||||
@apply px-1 text-lg;
|
||||
}
|
||||
.nav-container {
|
||||
@apply px-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small screens (541px to 767px) */
|
||||
@media (min-width: 541px) and (max-width: 767px) {
|
||||
.nav-link {
|
||||
@apply px-3 py-2;
|
||||
}
|
||||
.nav-link i {
|
||||
@apply mr-2;
|
||||
}
|
||||
.site-logo {
|
||||
@apply px-2 text-xl;
|
||||
}
|
||||
.nav-container {
|
||||
@apply px-4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium screens and up */
|
||||
@media (min-width: 768px) {
|
||||
.nav-link {
|
||||
@apply px-6 py-2.5 rounded-lg font-medium border border-transparent hover:border-primary/20 dark:hover:border-primary/30;
|
||||
}
|
||||
.nav-link i {
|
||||
@apply mr-3 text-lg;
|
||||
}
|
||||
.site-logo {
|
||||
@apply px-3 text-2xl;
|
||||
}
|
||||
.nav-container {
|
||||
@apply px-6;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@apply text-primary dark:text-primary bg-primary/10 dark:bg-primary/20;
|
||||
}
|
||||
|
||||
.nav-link i {
|
||||
@apply text-gray-500 transition-colors dark:text-gray-400;
|
||||
}
|
||||
|
||||
.nav-link:hover i {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#mobileMenu {
|
||||
@apply !hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu Items */
|
||||
.menu-item {
|
||||
@apply flex items-center w-full px-4 py-3 text-sm text-gray-700 transition-all dark:text-gray-200 hover:bg-primary/10 dark:hover:bg-primary/20 hover:text-primary dark:hover:text-primary first:rounded-t-lg last:rounded-b-lg;
|
||||
}
|
||||
|
||||
.menu-item i {
|
||||
@apply mr-3 text-base text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* Form Components */
|
||||
.form-input {
|
||||
@apply w-full px-4 py-3 text-gray-900 transition-all border border-gray-200 rounded-lg shadow-xs dark:border-gray-700 bg-white/70 dark:bg-gray-800/70 backdrop-blur-xs dark:text-white focus:ring-3 focus:ring-primary/50 focus:border-primary;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
@apply mt-2 space-y-1 text-sm text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply mt-2 text-sm text-red-600 dark:text-red-400;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-3 py-1 text-sm font-medium rounded-full;
|
||||
}
|
||||
|
||||
.status-operating {
|
||||
@apply text-green-800 bg-green-100 dark:bg-green-700 dark:text-green-50;
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
@apply text-red-800 bg-red-100 dark:bg-red-700 dark:text-red-50;
|
||||
}
|
||||
|
||||
.status-construction {
|
||||
@apply text-yellow-800 bg-yellow-100 dark:bg-yellow-600 dark:text-yellow-50;
|
||||
}
|
||||
|
||||
/* Auth Components */
|
||||
.auth-card {
|
||||
@apply w-full max-w-md p-8 mx-auto border shadow-xl bg-white/90 dark:bg-gray-800/90 rounded-2xl backdrop-blur-xs border-gray-200/50 dark:border-gray-700/50;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
@apply mb-8 text-2xl font-bold text-center text-transparent bg-gradient-to-r from-primary to-secondary bg-clip-text;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
@apply relative my-6 text-center;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
@apply absolute top-1/2 w-1/3 border-t border-gray-200 dark:border-gray-700 content-[''];
|
||||
}
|
||||
|
||||
.auth-divider::before {
|
||||
@apply left-0;
|
||||
}
|
||||
|
||||
.auth-divider::after {
|
||||
@apply right-0;
|
||||
}
|
||||
|
||||
.auth-divider span {
|
||||
@apply px-4 text-sm text-gray-500 dark:text-gray-400 bg-white/90 dark:bg-gray-800/90;
|
||||
}
|
||||
|
||||
/* Social Login Buttons */
|
||||
.btn-social {
|
||||
@apply w-full flex items-center justify-center px-6 py-3 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xs text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-[1.02] transition-all mb-3;
|
||||
}
|
||||
|
||||
.btn-discord {
|
||||
@apply text-gray-700 bg-white border-gray-200 hover:bg-gray-50 shadow-gray-200/50 dark:shadow-gray-900/50;
|
||||
}
|
||||
|
||||
.btn-google {
|
||||
@apply text-gray-700 bg-white border-gray-200 hover:bg-gray-50 shadow-gray-200/50 dark:shadow-gray-900/50;
|
||||
}
|
||||
|
||||
/* Alert Components */
|
||||
.alert {
|
||||
@apply p-4 mb-4 shadow-lg rounded-xl backdrop-blur-xs;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
@apply text-green-800 border border-green-200 bg-green-100/90 dark:bg-green-800/30 dark:text-green-100 dark:border-green-700;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
@apply text-red-800 border border-red-200 bg-red-100/90 dark:bg-red-800/30 dark:text-red-100 dark:border-red-700;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
@apply text-yellow-800 border border-yellow-200 bg-yellow-100/90 dark:bg-yellow-800/30 dark:text-yellow-100 dark:border-yellow-700;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
@apply text-blue-800 border border-blue-200 bg-blue-100/90 dark:bg-blue-800/30 dark:text-blue-100 dark:border-blue-700;
|
||||
}
|
||||
|
||||
/* Layout Components */
|
||||
.card {
|
||||
@apply p-6 bg-white border rounded-lg shadow-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply transition-transform transform hover:-translate-y-1;
|
||||
}
|
||||
|
||||
.grid-cards {
|
||||
@apply grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
|
||||
}
|
||||
|
||||
/* Adaptive Grid System - White Space Solutions */
|
||||
.grid-adaptive {
|
||||
@apply grid gap-6;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.grid-adaptive-sm {
|
||||
@apply grid gap-4;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.grid-adaptive-lg {
|
||||
@apply grid gap-8;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
/* Stats Grid - Always Even Layout */
|
||||
.grid-stats {
|
||||
@apply grid gap-4;
|
||||
/* Default: Force 2+3 layout for small screens */
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-stats-wide {
|
||||
@apply grid gap-4;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
/* Enhanced Responsive Grid */
|
||||
.grid-responsive {
|
||||
@apply grid grid-cols-1 gap-6;
|
||||
@apply sm:grid-cols-2;
|
||||
@apply md:grid-cols-3;
|
||||
@apply lg:grid-cols-4;
|
||||
@apply xl:grid-cols-5;
|
||||
@apply 2xl:grid-cols-6;
|
||||
}
|
||||
|
||||
.grid-responsive-cards {
|
||||
@apply grid grid-cols-1 gap-6;
|
||||
@apply md:grid-cols-2;
|
||||
@apply lg:grid-cols-3;
|
||||
@apply xl:grid-cols-4;
|
||||
@apply 2xl:grid-cols-5;
|
||||
}
|
||||
|
||||
/* Tablet-specific optimizations for 768px breakpoint */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.grid-adaptive-sm {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
/* Force 2+3 even layout for tablets */
|
||||
.grid-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Content-aware grid adjustments */
|
||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
.grid-adaptive-lg {
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
}
|
||||
/* Force 3+2 even layout for intermediate sizes */
|
||||
.grid-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
.grid-adaptive-lg {
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
}
|
||||
/* Force 5-column even layout for large screens */
|
||||
.grid-stats {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Priority Card - Operator/Owner Full-Width Responsive Behavior */
|
||||
.card-stats-priority {
|
||||
/* Full width by default (small screens) */
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Medium screens - still full width for emphasis */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.card-stats-priority {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large screens - normal grid behavior */
|
||||
@media (min-width: 1024px) {
|
||||
.card-stats-priority {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
}
|
||||
.grid-adaptive-lg {
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.heading-1 {
|
||||
@apply mb-6 text-3xl font-bold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.heading-2 {
|
||||
@apply mb-4 text-2xl font-bold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
}
|
||||
|
||||
/* Turnstile Widget */
|
||||
.turnstile {
|
||||
@apply flex items-center justify-center my-4;
|
||||
}
|
||||
|
||||
/* Layout Optimization - Phase 1 Critical Fixes */
|
||||
/* Optimized Padding System */
|
||||
.p-compact {
|
||||
@apply p-5; /* 20px - replaces excessive p-6 (24px) */
|
||||
}
|
||||
|
||||
.p-optimized {
|
||||
@apply p-4; /* 16px - replaces p-6 (24px) for 33% reduction */
|
||||
}
|
||||
|
||||
.p-minimal {
|
||||
@apply p-3; /* 12px - replaces p-2 (8px) for consistency */
|
||||
}
|
||||
|
||||
/* Consistent Card Heights */
|
||||
.card-standard {
|
||||
@apply min-h-[120px];
|
||||
}
|
||||
|
||||
.card-large {
|
||||
@apply min-h-[200px];
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
@apply min-h-[80px];
|
||||
}
|
||||
|
||||
/* Mobile Responsive Padding Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.p-compact {
|
||||
@apply p-4; /* 16px on mobile */
|
||||
}
|
||||
.p-optimized {
|
||||
@apply p-3.5; /* 14px on mobile */
|
||||
}
|
||||
.p-minimal {
|
||||
@apply p-2.5; /* 10px on mobile */
|
||||
}
|
||||
|
||||
.card-standard {
|
||||
@apply min-h-[100px];
|
||||
}
|
||||
|
||||
.card-large {
|
||||
@apply min-h-[160px];
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
@apply min-h-[80px];
|
||||
}
|
||||
}
|
||||
/* Base Component Styles */
|
||||
.site-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-logo:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Navigation Styles */
|
||||
.nav-container {
|
||||
padding: 1rem 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-primary);
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.nav-link i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-link span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Dark mode form styles */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-input {
|
||||
background-color: #374151;
|
||||
border-color: #4b5563;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--color-primary), #3730a3);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #3730a3, #312e81);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 12px -2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Dark mode button styles */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-secondary {
|
||||
border-color: #4b5563;
|
||||
color: #e5e7eb;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu Styles */
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.menu-item i {
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Dark mode menu styles */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.menu-item {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #4b5563;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme Toggle Styles */
|
||||
.theme-toggle-btn {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.theme-toggle-btn i::before {
|
||||
content: "\f185"; /* sun icon */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.theme-toggle-btn i::before {
|
||||
content: "\f186"; /* moon icon */
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Menu Styles */
|
||||
#mobileMenu {
|
||||
display: none;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#mobileMenu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#mobileMenu {
|
||||
border-top-color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid Adaptive Styles */
|
||||
.grid-adaptive-sm {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.grid-adaptive {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid-adaptive {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Card Styles */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
background: #1f2937;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert Styles */
|
||||
.alert {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 50;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--color-primary);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.bg-gradient-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary), #3730a3);
|
||||
}
|
||||
|
||||
.bg-gradient-secondary {
|
||||
background: linear-gradient(135deg, var(--color-secondary), #be185d);
|
||||
}
|
||||
|
||||
/* Responsive Utilities */
|
||||
@media (max-width: 1023px) {
|
||||
.lg\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:flex {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.lg\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus States */
|
||||
.focus-ring {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.focus-ring:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
12
backend/static/js/alpine.min.js
vendored
12
backend/static/js/alpine.min.js
vendored
File diff suppressed because one or more lines are too long
5
backend/static/js/cdn.min.js.1
Normal file
5
backend/static/js/cdn.min.js.1
Normal file
File diff suppressed because one or more lines are too long
5
backend/static/js/cdn.min.js.2
Normal file
5
backend/static/js/cdn.min.js.2
Normal file
File diff suppressed because one or more lines are too long
@@ -1,909 +0,0 @@
|
||||
{# Enhanced filter form with modern UI/UX design - timestamp: 2025-08-21 #}
|
||||
{% load static %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<!-- Mobile Filter Toggle Button -->
|
||||
<button class="mobile-nav-toggle md:hidden fixed bottom-6 right-6 z-50 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-4 focus:ring-blue-500/50"
|
||||
onclick="toggleMobileFilters()"
|
||||
aria-label="Toggle filters"
|
||||
data-tooltip="Show/Hide Filters">
|
||||
<svg class="w-6 h-6 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="filter-container" x-data="filterManager()" x-init="init()">
|
||||
{# Quick Filter Presets - Enhanced Design #}
|
||||
<div class="mb-8 bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-blue-500 to-purple-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Quick Filters</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks instantly with preset filters</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Live filtering</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('disney')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('disney') ? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg ring-2 ring-blue-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Show Disney Parks">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('disney')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Disney Parks</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.disney" x-text="`${quickFilterCounts.disney} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-400/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('coasters')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('coasters') ? 'bg-gradient-to-br from-green-500 to-green-600 text-white shadow-lg ring-2 ring-green-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-green-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Parks with Roller Coasters">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-red-400 rounded-full animate-ping" x-show="isQuickFilterActive('coasters')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">With Coasters</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.coasters" x-text="`${quickFilterCounts.coasters} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-green-400/10 to-emerald-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('top_rated')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('top_rated') ? 'bg-gradient-to-br from-yellow-500 to-yellow-600 text-white shadow-lg ring-2 ring-yellow-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-yellow-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Highly Rated Parks (4+ stars)">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('top_rated')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Top Rated</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.top_rated" x-text="`${quickFilterCounts.top_rated} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-yellow-400/10 to-orange-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('major_parks')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('major_parks') ? 'bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-lg ring-2 ring-purple-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-purple-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Major Theme Parks (10+ rides)">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-purple-400 rounded-full animate-ping" x-show="isQuickFilterActive('major_parks')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Major Parks</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.major_parks" x-text="`${quickFilterCounts.major_parks} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-purple-400/10 to-pink-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Mobile Filter Toggle - Enhanced Design #}
|
||||
<div class="lg:hidden mb-6">
|
||||
<button type="button"
|
||||
@click="showMobileFilters = !showMobileFilters"
|
||||
class="w-full group relative overflow-hidden rounded-xl p-4 bg-gradient-to-r from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-blue-500/10 dark:bg-blue-400/10 rounded-lg">
|
||||
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-white">Filters</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Customize your search</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 rounded-full">
|
||||
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300" x-text="activeFilterCount + ' active'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-6 h-6 text-gray-400 transition-transform duration-300" :class="showMobileFilters ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Main Filter Form - Enhanced Design #}
|
||||
<form hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="change delay:300ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML transition:true"
|
||||
class="space-y-8"
|
||||
:class="showMobileFilters || window.innerWidth >= 1024 ? 'block' : 'hidden'"
|
||||
x-show.transition="showMobileFilters || window.innerWidth >= 1024">
|
||||
|
||||
{# Search Input Section - Enhanced Design #}
|
||||
<div class="bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-850 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-green-500 to-blue-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Search & Discover</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks by name, location, or features</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-xl blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative">
|
||||
<label for="search" class="sr-only">Search Parks</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
x-model="searchQuery"
|
||||
placeholder="Search by park name, location, or features..."
|
||||
class="w-full px-6 py-4 pl-14 pr-12 text-lg border-2 border-gray-200 dark:border-gray-600 rounded-xl bg-white/80 dark:bg-gray-800/80 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-300 ease-out backdrop-blur-sm hover:border-gray-300 dark:hover:border-gray-500 group-focus-within:shadow-xl">
|
||||
|
||||
{# Search Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<div class="p-1 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<svg class="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Clear Button #}
|
||||
<div class="absolute inset-y-0 right-0 pr-4 flex items-center" x-show="searchQuery" x-transition>
|
||||
<button type="button"
|
||||
@click="searchQuery = ''"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
title="Clear search">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Loading Indicator #}
|
||||
<div class="absolute inset-y-0 right-0 pr-4 flex items-center" x-show="isLoading" x-transition>
|
||||
<div class="animate-spin h-5 w-5 text-blue-500">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Search Suggestions/Hints #}
|
||||
<div class="mt-3 flex flex-wrap gap-2" x-show="!searchQuery">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Popular searches:</div>
|
||||
<button type="button" @click="searchQuery = 'Disney'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Disney</button>
|
||||
<button type="button" @click="searchQuery = 'roller coaster'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Roller Coaster</button>
|
||||
<button type="button" @click="searchQuery = 'California'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">California</button>
|
||||
<button type="button" @click="searchQuery = 'water park'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Water Park</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Loading Indicator #}
|
||||
<div id="loading-indicator" class="htmx-indicator fixed top-4 right-4 z-50">
|
||||
<div class="bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-3 rounded-xl shadow-2xl border border-blue-500/20 backdrop-blur-sm">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="animate-spin h-5 w-5 text-white">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">Updating results...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Filters Summary - Enhanced #}
|
||||
<div x-show="activeFilterCount > 0"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 transform scale-100 translate-y-0"
|
||||
class="bg-gradient-to-r from-blue-50 via-indigo-50 to-purple-50 dark:from-blue-900/20 dark:via-indigo-900/20 dark:to-purple-900/20 rounded-xl p-5 shadow-lg border border-blue-200/50 dark:border-blue-700/50 backdrop-blur-sm">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-800/30 rounded-lg">
|
||||
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-blue-900 dark:text-blue-100">Active Filters</h3>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300" x-text="`${activeFilterCount} filter${activeFilterCount !== 1 ? 's' : ''} applied`"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
@click="clearAllFilters()"
|
||||
class="px-4 py-2 text-sm font-semibold text-blue-700 dark:text-blue-300 bg-white/80 dark:bg-gray-800/80 hover:bg-blue-100 dark:hover:bg-blue-800/50 rounded-lg border border-blue-200 dark:border-blue-600 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 hover:shadow-md"
|
||||
aria-label="Clear all filters">
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Filter Groups #}
|
||||
<div class="bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-850 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm">
|
||||
<div class="p-6" x-data="{ expanded: true }">
|
||||
{# Enhanced Group Header #}
|
||||
<button type="button"
|
||||
@click="expanded = !expanded"
|
||||
class="w-full group flex justify-between items-center text-left p-3 -m-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200"
|
||||
:aria-expanded="expanded"
|
||||
aria-controls="filter-group-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-purple-500 to-pink-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">Advanced Filters</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Refine your search with detailed criteria</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">Advanced</span>
|
||||
</div>
|
||||
<svg class="w-6 h-6 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 transform transition-all duration-300"
|
||||
:class="{'rotate-180': !expanded}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{# Enhanced Group Content #}
|
||||
<div id="filter-group-1"
|
||||
class="mt-6 space-y-6"
|
||||
x-show="expanded"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 max-h-0"
|
||||
x-transition:enter-end="opacity-100 max-h-screen"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 max-h-screen"
|
||||
x-transition:leave-end="opacity-0 max-h-0">
|
||||
{% for field in filter.form %}
|
||||
<div class="filter-field group">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="flex items-center justify-between text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
{# Enhanced Field Icons Based on Type #}
|
||||
<div class="p-2 bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-900/30 dark:to-purple-900/30 rounded-lg border border-blue-200/50 dark:border-blue-700/50">
|
||||
{% if field.name == 'search' or field.field.widget.input_type == 'search' %}
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{% elif 'location' in field.name or 'city' in field.name or 'state' in field.name or 'country' in field.name %}
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{% elif 'date' in field.name or field.field.widget.input_type == 'date' %}
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% elif 'operator' in field.name or 'company' in field.name %}
|
||||
<svg class="w-4 h-4 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{% elif 'rating' in field.name or 'star' in field.name %}
|
||||
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||||
</svg>
|
||||
{% elif 'count' in field.name or field.field.widget.input_type == 'number' %}
|
||||
<svg class="w-4 h-4 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
{% elif field.field.widget.input_type == 'checkbox' %}
|
||||
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<svg class="w-4 h-4 text-pink-600 dark:text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span>{{ field.label }}</span>
|
||||
{% if field.help_text %}
|
||||
<button type="button"
|
||||
class="text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 focus:outline-none transition-colors duration-200"
|
||||
@click="$tooltip('{{ field.help_text|escapejs }}', $event)"
|
||||
aria-label="Help for {{ field.label }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Field Clear Button #}
|
||||
<button type="button"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 dark:hover:text-red-400 focus:outline-none transition-all duration-200 text-xs"
|
||||
@click="clearField('{{ field.id_for_label }}')"
|
||||
aria-label="Clear {{ field.label }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'search' %}
|
||||
<div class="relative group/input">
|
||||
<input type="{{ field.field.widget.input_type }}"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
placeholder="{{ field.field.widget.attrs.placeholder|default:'' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{% if field.name == 'search' or field.field.widget.input_type == 'search' %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{% elif 'location' in field.name or 'city' in field.name %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'number' %}
|
||||
<div class="relative group/input">
|
||||
<input type="number"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
min="{{ field.field.widget.attrs.min|default:'' }}"
|
||||
max="{{ field.field.widget.attrs.max|default:'' }}"
|
||||
placeholder="{{ field.field.widget.attrs.placeholder|default:'Enter number...' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'checkbox' %}
|
||||
<div class="relative group/checkbox">
|
||||
<div class="flex items-center p-4 bg-gradient-to-r from-gray-50 to-white dark:from-gray-700/50 dark:to-gray-800/50 rounded-lg border-2 border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-gradient-to-r hover:from-blue-50 hover:to-purple-50 dark:hover:from-blue-900/20 dark:hover:to-purple-900/20 transition-all duration-200 cursor-pointer">
|
||||
<div class="relative flex items-center">
|
||||
<input type="checkbox"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
{% if field.value %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-2 focus:ring-blue-500/20 border-2 border-gray-300 dark:border-gray-500 dark:bg-gray-600 rounded transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<div class="absolute inset-0 rounded bg-blue-500 opacity-0 group-hover/checkbox:opacity-10 transition-opacity duration-200"></div>
|
||||
</div>
|
||||
<label for="{{ field.id_for_label }}" class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{# Checkbox Icon #}
|
||||
<div class="ml-auto text-gray-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<div class="relative group/select">
|
||||
<select name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
class="block w-full pl-12 pr-10 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/select:shadow-md appearance-none">
|
||||
{% for choice in field.field.choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == field.value %}selected{% endif %}>{{ choice.1 }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{% if 'operator' in field.name %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Right Dropdown Arrow #}
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg class="h-5 w-5 text-gray-400 group-hover/select:text-gray-600 dark:group-hover/select:text-gray-300 transition-colors duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'date' %}
|
||||
<div class="relative group/input">
|
||||
<input type="date"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="relative group/input">
|
||||
{{ field|add_class:"block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md" }}
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Mobile Apply Button #}
|
||||
<div class="lg:hidden">
|
||||
<button type="submit"
|
||||
class="w-full group relative overflow-hidden bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-6 py-4 rounded-xl font-semibold text-lg shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-300 transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
:disabled="isLoading">
|
||||
<div class="relative z-10 flex items-center justify-center">
|
||||
<span x-show="!isLoading" class="flex items-center space-x-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
<span>Apply Filters</span>
|
||||
</span>
|
||||
<span x-show="isLoading" class="flex items-center space-x-3">
|
||||
<div class="animate-spin h-5 w-5 text-white">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">Applying Filters...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Enhanced Scripts #}
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('filterManager', () => ({
|
||||
mobileFiltersOpen: false,
|
||||
activeFilterCount: 0,
|
||||
isLoading: false,
|
||||
quickFilterCounts: {
|
||||
disney: 0,
|
||||
coasters: 0,
|
||||
top_rated: 0,
|
||||
major_parks: 0
|
||||
},
|
||||
|
||||
init() {
|
||||
this.updateActiveFilterCount();
|
||||
this.loadQuickFilterCounts();
|
||||
|
||||
// Listen for HTMX events
|
||||
document.addEventListener('htmx:beforeRequest', () => {
|
||||
this.isLoading = true;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:responseError', () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
toggleMobileFilters() {
|
||||
this.mobileFiltersOpen = !this.mobileFiltersOpen;
|
||||
},
|
||||
|
||||
updateActiveFilterCount() {
|
||||
// Count active filters from form inputs
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
let count = 0;
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value && input.value !== '' && input.value !== 'all') {
|
||||
// Skip hidden fields and empty values
|
||||
if (input.type !== 'hidden' && input.value !== '0') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.activeFilterCount = count;
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear all form inputs
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger form submission
|
||||
this.activeFilterCount = 0;
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
applyQuickFilter(filterType) {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear existing filters first
|
||||
this.clearAllFilters();
|
||||
|
||||
// Apply specific filter based on type
|
||||
switch(filterType) {
|
||||
case 'disney':
|
||||
const parkTypeField = form.querySelector('[name="park_type"]');
|
||||
if (parkTypeField) parkTypeField.value = 'disney';
|
||||
break;
|
||||
|
||||
case 'coasters':
|
||||
const coastersField = form.querySelector('[name="has_coasters"]');
|
||||
if (coastersField) coastersField.checked = true;
|
||||
break;
|
||||
|
||||
case 'top_rated':
|
||||
const ratingField = form.querySelector('[name="min_rating"]');
|
||||
if (ratingField) ratingField.value = '4';
|
||||
break;
|
||||
|
||||
case 'major_parks':
|
||||
const bigParksField = form.querySelector('[name="big_parks_only"]');
|
||||
if (bigParksField) bigParksField.checked = true;
|
||||
break;
|
||||
}
|
||||
|
||||
this.updateActiveFilterCount();
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
isQuickFilterActive(filterType) {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return false;
|
||||
|
||||
switch(filterType) {
|
||||
case 'disney':
|
||||
const parkTypeField = form.querySelector('[name="park_type"]');
|
||||
return parkTypeField && parkTypeField.value === 'disney';
|
||||
|
||||
case 'coasters':
|
||||
const coastersField = form.querySelector('[name="has_coasters"]');
|
||||
return coastersField && coastersField.checked;
|
||||
|
||||
case 'top_rated':
|
||||
const ratingField = form.querySelector('[name="min_rating"]');
|
||||
return ratingField && ratingField.value === '4';
|
||||
|
||||
case 'major_parks':
|
||||
const bigParksField = form.querySelector('[name="big_parks_only"]');
|
||||
return bigParksField && bigParksField.checked;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
loadQuickFilterCounts() {
|
||||
// This would typically fetch from an API endpoint
|
||||
// For now, set some placeholder values
|
||||
this.quickFilterCounts = {
|
||||
disney: 12,
|
||||
coasters: 156,
|
||||
top_rated: 89,
|
||||
major_parks: 78
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
// Tooltip directive
|
||||
Alpine.directive('tooltip', (el, { expression }) => {
|
||||
el._tooltip = expression;
|
||||
});
|
||||
|
||||
// Global tooltip function
|
||||
window.$tooltip = function(text, event) {
|
||||
// Simple tooltip implementation - could be enhanced with a library
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'fixed z-50 bg-gray-900 text-white text-sm rounded py-1 px-2 pointer-events-none';
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.left = event.pageX + 10 + 'px';
|
||||
tooltip.style.top = event.pageY - 30 + 'px';
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.parentNode.removeChild(tooltip);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Global field clearing function
|
||||
window.clearField = function(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field) return;
|
||||
|
||||
if (field.type === 'checkbox' || field.type === 'radio') {
|
||||
field.checked = false;
|
||||
} else {
|
||||
field.value = '';
|
||||
}
|
||||
|
||||
// Trigger change event to update filters
|
||||
const form = field.closest('form');
|
||||
if (form) {
|
||||
htmx.trigger(form, 'change');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Enhanced debouncing for text inputs
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target.type === 'text' || e.target.type === 'search') {
|
||||
clearTimeout(e.target._debounceTimer);
|
||||
e.target._debounceTimer = setTimeout(() => {
|
||||
htmx.trigger(e.target.form, 'change');
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Mobile filter toggle functionality
|
||||
function toggleMobileFilters() {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
const backdrop = document.querySelector('.filter-backdrop');
|
||||
|
||||
if (filterPanel) {
|
||||
filterPanel.classList.toggle('is-open');
|
||||
|
||||
// Create backdrop if it doesn't exist
|
||||
if (!backdrop) {
|
||||
const newBackdrop = document.createElement('div');
|
||||
newBackdrop.className = 'filter-backdrop fixed inset-0 bg-black/50 z-40 md:hidden';
|
||||
newBackdrop.onclick = toggleMobileFilters;
|
||||
document.body.appendChild(newBackdrop);
|
||||
} else {
|
||||
backdrop.style.display = filterPanel.classList.contains('is-open') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.classList.toggle('overflow-hidden', filterPanel.classList.contains('is-open'));
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced toast notification system
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toast-container') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification transform transition-all duration-300 ease-out translate-x-full opacity-0 mb-4 p-4 rounded-lg shadow-lg flex items-center space-x-3 ${getToastClasses(type)}`;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="flex-shrink-0">
|
||||
${getToastIcon(type)}
|
||||
</div>
|
||||
<div class="flex-1 text-sm font-medium">${message}</div>
|
||||
<button onclick="removeToast(this.parentElement)" class="flex-shrink-0 ml-4 text-current opacity-70 hover:opacity-100 transition-opacity">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-x-full', 'opacity-0');
|
||||
}, 100);
|
||||
|
||||
// Auto remove
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(toast), duration);
|
||||
}
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed top-4 right-4 z-50 max-w-sm w-full';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
toast.classList.add('translate-x-full', 'opacity-0');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
|
||||
function getToastClasses(type) {
|
||||
const classes = {
|
||||
success: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800',
|
||||
error: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800',
|
||||
info: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800'
|
||||
};
|
||||
return classes[type] || classes.info;
|
||||
}
|
||||
|
||||
function getToastIcon(type) {
|
||||
const icons = {
|
||||
success: `<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
error: `<svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
warning: `<svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
info: `<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>`
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
|
||||
// Enhanced filter manager with additional functionality
|
||||
function enhancedFilterManager() {
|
||||
return {
|
||||
init() {
|
||||
this.setupKeyboardShortcuts();
|
||||
this.setupIntersectionObserver();
|
||||
this.setupTouchGestures();
|
||||
},
|
||||
|
||||
setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Escape to close mobile filters
|
||||
if (e.key === 'Escape') {
|
||||
const filterPanel = document.querySelector('.filter-panel.is-open');
|
||||
if (filterPanel) {
|
||||
toggleMobileFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + K to focus search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
showToast('Search focused - start typing!', 'info', 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupIntersectionObserver() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('fade-in-scale');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
// Observe park cards for scroll animations
|
||||
document.querySelectorAll('.park-card').forEach(card => {
|
||||
observer.observe(card);
|
||||
});
|
||||
},
|
||||
|
||||
setupTouchGestures() {
|
||||
let startY = 0;
|
||||
let currentY = 0;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
currentY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
const deltaY = startY - currentY;
|
||||
|
||||
// Swipe up to show filters (mobile)
|
||||
if (deltaY > 50 && window.innerWidth < 768) {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
if (filterPanel && !filterPanel.classList.contains('is-open')) {
|
||||
toggleMobileFilters();
|
||||
showToast('Filters opened', 'info', 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize enhanced functionality when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const enhanced = enhancedFilterManager();
|
||||
enhanced.init();
|
||||
|
||||
// Show welcome message
|
||||
setTimeout(() => {
|
||||
showToast('Welcome! Use Ctrl+K to focus search or swipe up for filters.', 'info', 4000);
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
@@ -1,28 +1,310 @@
|
||||
<form hx-get="{% url 'search:search' %}" hx-target="#search-results" hx-swap="outerHTML" class="space-y-4">
|
||||
{% for field in filters.form %}
|
||||
<div class="flex flex-col">
|
||||
<label for="{{ field.id_for_label }}" class="text-sm font-medium text-gray-700">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ field }}
|
||||
{# Modern Filter Interface - timestamp: 2025-08-29 #}
|
||||
{% load static %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="space-y-6">
|
||||
{# Search Section #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
Search
|
||||
</h3>
|
||||
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="input changed delay:500ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="space-y-4">
|
||||
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
name="search"
|
||||
value="{{ request.GET.search|default:'' }}"
|
||||
placeholder="Search parks by name, location..."
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<p class="text-sm text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Filters Section #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z"/>
|
||||
</svg>
|
||||
Filters
|
||||
</h3>
|
||||
|
||||
<form id="filter-form"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="change delay:300ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="space-y-6">
|
||||
|
||||
{# Preserve search term #}
|
||||
{% if request.GET.search %}
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% endif %}
|
||||
|
||||
{# Status Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">All Status</option>
|
||||
<option value="OPERATING" {% if request.GET.status == 'OPERATING' %}selected{% endif %}>Operating</option>
|
||||
<option value="CLOSED_TEMP" {% if request.GET.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
|
||||
<option value="CLOSED_PERM" {% if request.GET.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||
<option value="UNDER_CONSTRUCTION" {% if request.GET.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
|
||||
<option value="DEMOLISHED" {% if request.GET.status == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
|
||||
<option value="RELOCATED" {% if request.GET.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Operator Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Operator
|
||||
</label>
|
||||
<select name="operator"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">All Operators</option>
|
||||
<option value="cedar-fair" {% if request.GET.operator == 'cedar-fair' %}selected{% endif %}>Cedar Fair</option>
|
||||
<option value="six-flags" {% if request.GET.operator == 'six-flags' %}selected{% endif %}>Six Flags</option>
|
||||
<option value="disney" {% if request.GET.operator == 'disney' %}selected{% endif %}>Disney</option>
|
||||
<option value="universal" {% if request.GET.operator == 'universal' %}selected{% endif %}>Universal</option>
|
||||
<option value="busch-gardens" {% if request.GET.operator == 'busch-gardens' %}selected{% endif %}>Busch Gardens</option>
|
||||
<option value="knott's" {% if request.GET.operator == 'knotts' %}selected{% endif %}>Knott's</option>
|
||||
<option value="herschend" {% if request.GET.operator == 'herschend' %}selected{% endif %}>Herschend</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Rating Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Rating
|
||||
</label>
|
||||
<select name="min_rating"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">Any Rating</option>
|
||||
<option value="1" {% if request.GET.min_rating == '1' %}selected{% endif %}>1+ Stars</option>
|
||||
<option value="2" {% if request.GET.min_rating == '2' %}selected{% endif %}>2+ Stars</option>
|
||||
<option value="3" {% if request.GET.min_rating == '3' %}selected{% endif %}>3+ Stars</option>
|
||||
<option value="4" {% if request.GET.min_rating == '4' %}selected{% endif %}>4+ Stars</option>
|
||||
<option value="5" {% if request.GET.min_rating == '5' %}selected{% endif %}>5+ Stars</option>
|
||||
<option value="6" {% if request.GET.min_rating == '6' %}selected{% endif %}>6+ Stars</option>
|
||||
<option value="7" {% if request.GET.min_rating == '7' %}selected{% endif %}>7+ Stars</option>
|
||||
<option value="8" {% if request.GET.min_rating == '8' %}selected{% endif %}>8+ Stars</option>
|
||||
<option value="9" {% if request.GET.min_rating == '9' %}selected{% endif %}>9+ Stars</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Ride Count Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Rides
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_rides"
|
||||
value="{{ request.GET.min_rides|default:'' }}"
|
||||
min="0"
|
||||
placeholder="Any number"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Coaster Count Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Coasters
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_coasters"
|
||||
value="{{ request.GET.min_coasters|default:'' }}"
|
||||
min="0"
|
||||
placeholder="Any number"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Opening Year Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Opening Year
|
||||
</label>
|
||||
<input type="number"
|
||||
name="opening_year"
|
||||
value="{{ request.GET.opening_year|default:'' }}"
|
||||
min="1800"
|
||||
max="2030"
|
||||
placeholder="Any year"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Size Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Size (acres)
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_size"
|
||||
value="{{ request.GET.min_size|default:'' }}"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="Any size"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Special Features #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Special Features
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="has_roller_coasters"
|
||||
value="true"
|
||||
{% if request.GET.has_roller_coasters %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Roller Coasters</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="has_water_rides"
|
||||
value="true"
|
||||
{% if request.GET.has_water_rides %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Water Rides</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="family_friendly"
|
||||
value="true"
|
||||
{% if request.GET.family_friendly %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Family Friendly</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sort & View Options #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
|
||||
</svg>
|
||||
Sort By
|
||||
</h3>
|
||||
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="change"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true">
|
||||
|
||||
{# Preserve all current filters #}
|
||||
{% for key, value in request.GET.items %}
|
||||
{% if key != 'sort' and key != 'view_mode' %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<select name="sort"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="name" {% if request.GET.sort == 'name' or not request.GET.sort %}selected{% endif %}>Name A-Z</option>
|
||||
<option value="-name" {% if request.GET.sort == '-name' %}selected{% endif %}>Name Z-A</option>
|
||||
<option value="-average_rating" {% if request.GET.sort == '-average_rating' %}selected{% endif %}>Highest Rated</option>
|
||||
<option value="average_rating" {% if request.GET.sort == 'average_rating' %}selected{% endif %}>Lowest Rated</option>
|
||||
<option value="-opening_date" {% if request.GET.sort == '-opening_date' %}selected{% endif %}>Newest</option>
|
||||
<option value="opening_date" {% if request.GET.sort == 'opening_date' %}selected{% endif %}>Oldest</option>
|
||||
<option value="-ride_count" {% if request.GET.sort == '-ride_count' %}selected{% endif %}>Most Rides</option>
|
||||
<option value="-coaster_count" {% if request.GET.sort == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
|
||||
<option value="-size" {% if request.GET.sort == '-size' %}selected{% endif %}>Largest</option>
|
||||
<option value="size" {% if request.GET.sort == 'size' %}selected{% endif %}>Smallest</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Clear Filters Button #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<button type="button"
|
||||
onclick="clearAllFilters()"
|
||||
class="w-full px-4 py-3 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white font-medium rounded-lg transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Loading Indicator #}
|
||||
<div id="loading-indicator" class="htmx-indicator fixed top-4 right-4 z-50">
|
||||
<div class="bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function clearAllFilters() {
|
||||
// Navigate to the base URL without any query parameters
|
||||
window.location.href = "{% url 'parks:park_list' %}";
|
||||
}
|
||||
|
||||
// Enhanced form handling
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-submit forms on change with debouncing
|
||||
const forms = document.querySelectorAll('#filter-form, form[hx-trigger*="change"]');
|
||||
forms.forEach(form => {
|
||||
let timeout;
|
||||
form.addEventListener('change', function(e) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
htmx.trigger(form, 'submit');
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Apply Filters
|
||||
</button>
|
||||
{% if applied_filters %}
|
||||
<a href="{% url 'search:search' %}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-xs text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Clear Filters
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
// Handle search input with longer delay
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
htmx.trigger(e.target.closest('form'), 'submit');
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,19 @@
|
||||
{# Enhanced Filters Sidebar with Sticky Positioning #}
|
||||
<aside class="xl:col-span-1 order-2 xl:order-1">
|
||||
<div class="sticky top-4 z-30 space-y-6">
|
||||
{# Mobile Filter Toggle Container #}
|
||||
{# Desktop Filter Content Container #}
|
||||
<div id="desktop-filter-panel" class="hidden xl:block">
|
||||
<div class="xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto xl:scrollbar-thin xl:scrollbar-thumb-gray-300 dark:xl:scrollbar-thumb-gray-600 xl:scrollbar-track-transparent">
|
||||
{% include "core/search/filters.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# Enhanced Results Section with Better Responsiveness #}
|
||||
<main class="xl:col-span-3 order-1 xl:order-2">
|
||||
<div id="results-container" class="space-y-6">
|
||||
{# Mobile Filter Toggle and Panel - Moved to top for better UX #}
|
||||
<div class="xl:hidden">
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg">
|
||||
<button type="button"
|
||||
@@ -29,24 +41,19 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# Mobile Filter Panel - Adjacent to toggle for proper JavaScript functionality #}
|
||||
<div id="mobile-filter-panel" class="hidden border-t border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="p-4">
|
||||
{% block filter_section %}
|
||||
<!-- Mobile filter content -->
|
||||
{% include "core/search/filters.html" %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter Content Container #}
|
||||
<div id="mobile-filter-panel" class="hidden xl:block">
|
||||
<div class="xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto xl:scrollbar-thin xl:scrollbar-thumb-gray-300 dark:xl:scrollbar-thumb-gray-600 xl:scrollbar-track-transparent">
|
||||
{% block filter_section %}
|
||||
<!-- DEBUG: base filtered_list.html filter_section block - timestamp: 2025-08-21 -->
|
||||
{% include "core/search/components/filter_form.html" %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# Enhanced Results Section with Better Responsiveness #}
|
||||
<main class="xl:col-span-3 order-1 xl:order-2">
|
||||
<div id="results-container" class="space-y-6">
|
||||
{# Enhanced Header Section #}
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg">
|
||||
<div class="p-4 lg:p-6">
|
||||
@@ -180,4 +187,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,100 +1,117 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Search Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<!-- Filters Sidebar -->
|
||||
<div class="lg:w-1/4">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Filter Parks</h2>
|
||||
{% include "search/filters.html" %}
|
||||
<!-- Top Filter Bar -->
|
||||
{% include "core/search/filters.html" %}
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="search-results">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Search Results
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ results.count|default:0 }} found)</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="lg:w-3/4" id="search-results">
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">
|
||||
Search Results
|
||||
<span class="text-sm font-normal text-gray-500">({{ results.count }} found)</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 rounded-lg overflow-hidden">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No Image
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if park.formatted_location %}
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400 flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{{ park.formatted_location }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
{{ park.average_rating }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600">
|
||||
{% if park.formatted_location %}
|
||||
<p>{{ park.formatted_location }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} dark:bg-opacity-30">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ park.average_rating }} ★
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{{ park.ride_count }} Ride{{ park.ride_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{{ park.ride_count }} Rides
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{{ park.coaster_count }} Coasters
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
|
||||
{{ park.description }}
|
||||
</p>
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||
{{ park.coaster_count }} Coaster{{ park.coaster_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ park.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if park.opening_date %}
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
Opened: {{ park.opening_date|date:"Y" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-6 text-center text-gray-500">
|
||||
No parks found matching your criteria.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No parks found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria or filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# Include required scripts #}
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://unpkg.com/unpoly@3/unpoly.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -107,13 +107,7 @@ urlpatterns = [
|
||||
if HAS_AUTOCOMPLETE and autocomplete_urls:
|
||||
urlpatterns.insert(
|
||||
2,
|
||||
path(
|
||||
"ac/",
|
||||
include(
|
||||
(autocomplete_urls[0], autocomplete_urls[1]),
|
||||
namespace=autocomplete_urls[2],
|
||||
),
|
||||
),
|
||||
path("ac/", include(autocomplete_urls)),
|
||||
)
|
||||
|
||||
# Add API Documentation URLs if available
|
||||
|
||||
106
backend/uv.lock
generated
106
backend/uv.lock
generated
@@ -354,55 +354,55 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.5"
|
||||
version = "7.10.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662, upload-time = "2025-08-23T14:42:44.78Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/08/4166ecfb60ba011444f38a5a6107814b80c34c717bc7a23be0d22e92ca09/coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c", size = 217106, upload-time = "2025-08-23T14:41:15.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d7/b71022408adbf040a680b8c64bf6ead3be37b553e5844f7465643979f7ca/coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44", size = 217353, upload-time = "2025-08-23T14:41:16.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/68/21e0d254dbf8972bb8dd95e3fe7038f4be037ff04ba47d6d1b12b37510ba/coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc", size = 248350, upload-time = "2025-08-23T14:41:18.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/65/28752c3a896566ec93e0219fc4f47ff71bd2b745f51554c93e8dcb659796/coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869", size = 250955, upload-time = "2025-08-23T14:41:19.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/eb/ca6b7967f57f6fef31da8749ea20417790bb6723593c8cd98a987be20423/coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f", size = 252230, upload-time = "2025-08-23T14:41:20.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/29/17a411b2a2a18f8b8c952aa01c00f9284a1fbc677c68a0003b772ea89104/coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5", size = 250387, upload-time = "2025-08-23T14:41:22.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/89/97a9e271188c2fbb3db82235c33980bcbc733da7da6065afbaa1d685a169/coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c", size = 248280, upload-time = "2025-08-23T14:41:24.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c6/0ad7d0137257553eb4706b4ad6180bec0a1b6a648b092c5bbda48d0e5b2c/coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2", size = 249894, upload-time = "2025-08-23T14:41:26.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/56/fb3aba936addb4c9e5ea14f5979393f1c2466b4c89d10591fd05f2d6b2aa/coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4", size = 219536, upload-time = "2025-08-23T14:41:27.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/54/baacb8f2f74431e3b175a9a2881feaa8feb6e2f187a0e7e3046f3c7742b2/coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b", size = 220330, upload-time = "2025-08-23T14:41:29.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8a/82a3788f8e31dee51d350835b23d480548ea8621f3effd7c3ba3f7e5c006/coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84", size = 218961, upload-time = "2025-08-23T14:41:30.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/a1/590154e6eae07beee3b111cc1f907c30da6fc8ce0a83ef756c72f3c7c748/coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7", size = 217819, upload-time = "2025-08-23T14:41:31.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ff/436ffa3cfc7741f0973c5c89405307fe39b78dcf201565b934e6616fc4ad/coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b", size = 218040, upload-time = "2025-08-23T14:41:33.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/ca/5787fb3d7820e66273913affe8209c534ca11241eb34ee8c4fd2aaa9dd87/coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae", size = 259374, upload-time = "2025-08-23T14:41:34.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/89/21af956843896adc2e64fc075eae3c1cadb97ee0a6960733e65e696f32dd/coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760", size = 261551, upload-time = "2025-08-23T14:41:36.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/96/390a69244ab837e0ac137989277879a084c786cf036c3c4a3b9637d43a89/coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235", size = 263776, upload-time = "2025-08-23T14:41:38.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/32/cfd6ae1da0a521723349f3129b2455832fc27d3f8882c07e5b6fefdd0da2/coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5", size = 261326, upload-time = "2025-08-23T14:41:40.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/c4/bf8d459fb4ce2201e9243ce6c015936ad283a668774430a3755f467b39d1/coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db", size = 259090, upload-time = "2025-08-23T14:41:42.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/5d/a234f7409896468e5539d42234016045e4015e857488b0b5b5f3f3fa5f2b/coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e", size = 260217, upload-time = "2025-08-23T14:41:43.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/87560f036099f46c2ddd235be6476dd5c1d6be6bb57569a9348d43eeecea/coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee", size = 220194, upload-time = "2025-08-23T14:41:45.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a8/04a482594fdd83dc677d4a6c7e2d62135fff5a1573059806b8383fad9071/coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14", size = 221258, upload-time = "2025-08-23T14:41:46.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ad/7da28594ab66fe2bc720f1bc9b131e62e9b4c6e39f044d9a48d18429cc21/coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff", size = 219521, upload-time = "2025-08-23T14:41:47.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7f/c8b6e4e664b8a95254c35a6c8dd0bf4db201ec681c169aae2f1256e05c85/coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031", size = 217090, upload-time = "2025-08-23T14:41:49.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/74/3ee14ede30a6e10a94a104d1d0522d5fb909a7c7cac2643d2a79891ff3b9/coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3", size = 217365, upload-time = "2025-08-23T14:41:50.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/5f/06ac21bf87dfb7620d1f870dfa3c2cae1186ccbcdc50b8b36e27a0d52f50/coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031", size = 248413, upload-time = "2025-08-23T14:41:52.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/bc/cc5bed6e985d3a14228539631573f3863be6a2587381e8bc5fdf786377a1/coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2", size = 250943, upload-time = "2025-08-23T14:41:53.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/43/6a9fc323c2c75cd80b18d58db4a25dc8487f86dd9070f9592e43e3967363/coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762", size = 252301, upload-time = "2025-08-23T14:41:56.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/7c/3e791b8845f4cd515275743e3775adb86273576596dc9f02dca37357b4f2/coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae", size = 250302, upload-time = "2025-08-23T14:41:58.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/bc/5099c1e1cb0c9ac6491b281babea6ebbf999d949bf4aa8cdf4f2b53505e8/coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262", size = 248237, upload-time = "2025-08-23T14:41:59.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/51/d346eb750a0b2f1e77f391498b753ea906fde69cc11e4b38dca28c10c88c/coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99", size = 249726, upload-time = "2025-08-23T14:42:01.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/85/eebcaa0edafe427e93286b94f56ea7e1280f2c49da0a776a6f37e04481f9/coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde", size = 219825, upload-time = "2025-08-23T14:42:03.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f7/6d43e037820742603f1e855feb23463979bf40bd27d0cde1f761dcc66a3e/coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13", size = 220618, upload-time = "2025-08-23T14:42:05.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/b0/ed9432e41424c51509d1da603b0393404b828906236fb87e2c8482a93468/coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9", size = 219199, upload-time = "2025-08-23T14:42:06.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/54/5a7ecfa77910f22b659c820f67c16fc1e149ed132ad7117f0364679a8fa9/coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508", size = 217833, upload-time = "2025-08-23T14:42:08.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/0e/25672d917cc57857d40edf38f0b867fb9627115294e4f92c8fcbbc18598d/coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357", size = 218048, upload-time = "2025-08-23T14:42:10.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7c/0b2b4f1c6f71885d4d4b2b8608dcfc79057adb7da4143eb17d6260389e42/coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b", size = 259549, upload-time = "2025-08-23T14:42:11.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/73/abb8dab1609abec7308d83c6aec547944070526578ee6c833d2da9a0ad42/coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4", size = 261715, upload-time = "2025-08-23T14:42:13.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d1/abf31de21ec92731445606b8d5e6fa5144653c2788758fcf1f47adb7159a/coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba", size = 263969, upload-time = "2025-08-23T14:42:15.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/b3/ef274927f4ebede96056173b620db649cc9cb746c61ffc467946b9d0bc67/coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842", size = 261408, upload-time = "2025-08-23T14:42:16.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/fc/83ca2812be616d69b4cdd4e0c62a7bc526d56875e68fd0f79d47c7923584/coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874", size = 259168, upload-time = "2025-08-23T14:42:18.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/4f/e0779e5716f72d5c9962e709d09815d02b3b54724e38567308304c3fc9df/coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732", size = 260317, upload-time = "2025-08-23T14:42:20.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/fe/4247e732f2234bb5eb9984a0888a70980d681f03cbf433ba7b48f08ca5d5/coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df", size = 220600, upload-time = "2025-08-23T14:42:22.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a0/f294cff6d1034b87839987e5b6ac7385bec599c44d08e0857ac7f164ad0c/coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f", size = 221714, upload-time = "2025-08-23T14:42:23.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/18/fa1afdc60b5528d17416df440bcbd8fd12da12bfea9da5b6ae0f7a37d0f7/coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2", size = 219735, upload-time = "2025-08-23T14:42:25.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736, upload-time = "2025-08-23T14:42:43.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1328,11 +1328,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pbs-installer"
|
||||
version = "2025.8.27"
|
||||
version = "2025.8.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/e3/3dc8ee142299bac2329b78d93754f9711d692f233771adbe1a3e4deafafb/pbs_installer-2025.8.27.tar.gz", hash = "sha256:606430ca10940f9600a1a7f20b2a4a0ea62d8e327dcaf8a7b9acf2a2a6a39cb4", size = 59170, upload-time = "2025-08-27T00:59:35.336Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/f1/81217cee0b777bb454b7d3189b99a0369517e3e67f71dac88cadbfee5cfc/pbs_installer-2025.8.28.tar.gz", hash = "sha256:3accb1a184a048e657323c17d1c48b2969e49501b165e7200a520af7022d9bb0", size = 59192, upload-time = "2025-08-28T22:02:39.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/0b/a286029828fe6ddf0fa0a4a8a46b7d90c7d6ac26a3237f4c211df9143e92/pbs_installer-2025.8.27-py3-none-any.whl", hash = "sha256:145ed15f222af5157f5d4512a75041bc3c32784d4939d678231d41b15c0f16be", size = 60847, upload-time = "2025-08-27T00:59:33.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/55/dcc53d096e69cdb9f6a6f51bde721f66caf4e0ebea650d517049a4349b3f/pbs_installer-2025.8.28-py3-none-any.whl", hash = "sha256:a42cd2532dbac5dc836db41cf1bc5ed26ef275214e8e30e35aff961883997048", size = 60908, upload-time = "2025-08-28T22:02:38.605Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2351,11 +2351,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
version = "4.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
c# Active Context
|
||||
|
||||
## Current Focus
|
||||
- **COMPLETED: Comprehensive User Model with Settings Endpoints**: Successfully implemented comprehensive user model with extensive settings endpoints covering all aspects of user account management
|
||||
- **COMPLETED: RideModel API Directory Structure Reorganization**: Successfully reorganized API directory structure to match nested URL organization with mandatory nested file structure
|
||||
- **COMPLETED: RideModel API Reorganization**: Successfully reorganized RideModel endpoints from separate top-level `/api/v1/ride-models/` to nested `/api/v1/rides/manufacturers/<manufacturerSlug>/<ridemodelSlug>/` structure
|
||||
- **COMPLETED: django-cloudflare-images Integration**: Successfully implemented complete Cloudflare Images integration across rides and parks models with full API support including banner/card image settings
|
||||
@@ -12,7 +13,18 @@ c# Active Context
|
||||
- **COMPLETED: Manual Trigger Endpoint for Trending Content**: Successfully implemented admin-only POST endpoint to manually trigger trending content calculations
|
||||
- **COMPLETED: URL Fields in Trending and New Content Endpoints**: Successfully added url fields to all trending and new content API responses for frontend navigation
|
||||
- **COMPLETED: Park URL Optimization**: Successfully optimized park URL usage to use `ride.park.url` instead of redundant `ride.park_url` field for better data consistency
|
||||
- **COMPLETED: Reviews Latest Endpoint**: Successfully implemented `/api/v1/reviews/latest/` endpoint that combines park and ride reviews with comprehensive user information including avatars
|
||||
- **COMPLETED: User Deletion with Submission Preservation**: Successfully implemented comprehensive user deletion system that preserves all user submissions while removing the user account
|
||||
- **Features Implemented**:
|
||||
- **Comprehensive User Model**: Extended User model with 20+ new fields for preferences, privacy, security, and notification settings
|
||||
- **User Settings Endpoints**: 15+ new API endpoints covering all user settings categories with full CRUD operations
|
||||
- **User Profile Management**: Complete profile endpoints with account and profile information updates
|
||||
- **Notification Settings**: Detailed notification preferences with email, push, and in-app notification controls
|
||||
- **Privacy Settings**: Comprehensive privacy controls for profile visibility and data sharing
|
||||
- **Security Settings**: Two-factor authentication, login notifications, session management
|
||||
- **User Statistics**: Ride credits, contributions, activity tracking, and achievements system
|
||||
- **Top Lists Management**: Create, read, update, delete user top lists with full CRUD operations
|
||||
- **Account Deletion**: Self-service account deletion with email verification and submission preservation
|
||||
- **RideModel API Directory Structure**: Moved files from `backend/apps/api/v1/ride_models/` to `backend/apps/api/v1/rides/manufacturers/` to match nested URL organization
|
||||
- **RideModel API Reorganization**: Nested endpoints under rides/manufacturers, manufacturer-scoped slugs, integrated with ride creation/editing, removed top-level endpoint
|
||||
- **Cloudflare Images**: Model field updates, API serializer enhancements, image variants, transformations, upload examples, comprehensive documentation
|
||||
@@ -21,8 +33,40 @@ c# Active Context
|
||||
- **Comprehensive Rides Filtering**: 25+ filter parameters, enhanced filter options endpoint, roller coaster specific filters, range filters, boolean filters, multiple value support, comprehensive ordering options
|
||||
- **Celery Integration**: Asynchronous trending content calculation, Redis broker configuration, real database-driven responses replacing mock data
|
||||
- **Manual Trigger Endpoint**: Admin-only POST /api/v1/trending/calculate/ endpoint with task ID responses and proper error handling
|
||||
- **Reviews Latest Endpoint**: Combined park and ride reviews feed, user avatar integration, content snippets, smart truncation, comprehensive user information, public access
|
||||
|
||||
## Recent Changes
|
||||
**Comprehensive User Model with Settings Endpoints - COMPLETED:**
|
||||
- **Extended User Model**: Added 20+ new fields to User model including privacy settings, notification preferences, security settings, and detailed user preferences
|
||||
- **Database Migrations**: Successfully applied migrations for new User model fields with proper defaults
|
||||
- **Comprehensive Serializers**: Created complete serializer classes for all user settings categories:
|
||||
- `CompleteUserSerializer` - Full user profile with all settings
|
||||
- `UserPreferencesSerializer` - Theme and basic preferences
|
||||
- `NotificationSettingsSerializer` - Detailed email, push, and in-app notification controls
|
||||
- `PrivacySettingsSerializer` - Profile visibility and data sharing controls
|
||||
- `SecuritySettingsSerializer` - Two-factor auth, login notifications, session management
|
||||
- `UserStatisticsSerializer` - Ride credits, contributions, activity, achievements
|
||||
- `TopListSerializer` - User top lists with full CRUD operations
|
||||
- **API Endpoints Implemented**: 15+ new endpoints covering all user settings:
|
||||
- **Profile**: GET/PATCH `/api/v1/accounts/profile/`, PATCH `/api/v1/accounts/profile/account/`, PATCH `/api/v1/accounts/profile/update/`
|
||||
- **Preferences**: GET/PATCH `/api/v1/accounts/preferences/`, PATCH `/api/v1/accounts/preferences/theme/`, PATCH `/api/v1/accounts/preferences/update/`
|
||||
- **Notifications**: GET/PATCH `/api/v1/accounts/settings/notifications/`, PATCH `/api/v1/accounts/settings/notifications/update/`
|
||||
- **Privacy**: GET/PATCH `/api/v1/accounts/settings/privacy/`, PATCH `/api/v1/accounts/settings/privacy/update/`
|
||||
- **Security**: GET/PATCH `/api/v1/accounts/settings/security/`, PATCH `/api/v1/accounts/settings/security/update/`
|
||||
- **Statistics**: GET `/api/v1/accounts/statistics/`
|
||||
- **Top Lists**: GET/POST `/api/v1/accounts/top-lists/`, PATCH/DELETE `/api/v1/accounts/top-lists/{list_id}/`, POST `/api/v1/accounts/top-lists/create/`
|
||||
- **Account Deletion**: POST `/api/v1/accounts/delete-account/request/`, POST `/api/v1/accounts/delete-account/verify/`, POST `/api/v1/accounts/delete-account/cancel/`
|
||||
- **Files Created/Modified**:
|
||||
- `backend/apps/accounts/models.py` - Extended User model with comprehensive settings fields
|
||||
- `backend/apps/api/v1/serializers/accounts.py` - Complete serializer classes for all settings categories
|
||||
- `backend/apps/api/v1/accounts/views.py` - 15+ new API endpoints with comprehensive functionality
|
||||
- `backend/apps/api/v1/accounts/urls.py` - URL patterns for all new endpoints
|
||||
- `docs/frontend.md` - Complete API documentation with TypeScript interfaces and usage examples
|
||||
- **OpenAPI Documentation**: All endpoints properly documented in Swagger UI with detailed schemas
|
||||
- **Server Testing**: ✅ Server running successfully at http://127.0.0.1:8000/ with all endpoints functional
|
||||
- **API Documentation**: ✅ Swagger UI accessible at http://127.0.0.1:8000/api/docs/ showing all user settings endpoints
|
||||
- **Schema Validation**: ✅ All endpoints generating proper OpenAPI schemas with detailed notification settings structure
|
||||
|
||||
**RideModel API Directory Structure Reorganization - COMPLETED:**
|
||||
- **Reorganized**: API directory structure from `backend/apps/api/v1/ride_models/` to `backend/apps/api/v1/rides/manufacturers/`
|
||||
- **Files Moved**:
|
||||
@@ -131,6 +175,27 @@ c# Active Context
|
||||
- **Response**: Returns task IDs and estimated completion times for both triggered tasks
|
||||
- **Error Handling**: Proper error responses for failed task triggers and unauthorized access
|
||||
|
||||
**Reviews Latest Endpoint - COMPLETED:**
|
||||
- **Implemented**: Public endpoint to get latest reviews from both parks and rides
|
||||
- **Files Created/Modified**:
|
||||
- `backend/apps/api/v1/serializers/reviews.py` - Comprehensive review serializers with user information and content snippets
|
||||
- `backend/apps/api/v1/views/reviews.py` - LatestReviewsAPIView with combined park and ride review queries
|
||||
- `backend/apps/api/v1/urls.py` - Added URL routing for reviews/latest endpoint
|
||||
- `docs/frontend.md` - Updated with comprehensive endpoint documentation and usage examples
|
||||
- **Endpoint**: GET `/api/v1/reviews/latest/` - Returns combined feed of latest reviews from parks and rides
|
||||
- **Features**:
|
||||
- Combines ParkReview and RideReview models into unified chronological feed
|
||||
- User information with avatar URLs (falls back to default avatar)
|
||||
- Smart content snippet truncation at word boundaries (150 char limit)
|
||||
- Comprehensive subject information (park/ride names, slugs, URLs)
|
||||
- For ride reviews: includes parent park information
|
||||
- Configurable limit parameter (default: 20, max: 100)
|
||||
- Only shows published reviews (is_published=True)
|
||||
- Optimized database queries with select_related for performance
|
||||
- **Permissions**: Public access (AllowAny permission class)
|
||||
- **Response Format**: JSON with count and results array containing review objects
|
||||
- **Error Handling**: Parameter validation with fallback to defaults
|
||||
|
||||
**Technical Implementation:**
|
||||
- **Stats Endpoint**: GET `/api/v1/stats/` - Returns comprehensive platform statistics
|
||||
- **Maps Endpoints**:
|
||||
@@ -178,6 +243,13 @@ c# Active Context
|
||||
- `backend/apps/api/v1/serializers/maps.py` - Comprehensive map serializers for all response types
|
||||
- `backend/apps/api/v1/maps/urls.py` - Map URL routing configuration
|
||||
|
||||
### Comprehensive User Model Files
|
||||
- `backend/apps/accounts/models.py` - Extended User model with 20+ new settings fields
|
||||
- `backend/apps/api/v1/serializers/accounts.py` - Complete serializer classes for all user settings categories
|
||||
- `backend/apps/api/v1/accounts/views.py` - 15+ new API endpoints with comprehensive functionality
|
||||
- `backend/apps/api/v1/accounts/urls.py` - URL patterns for all new user settings endpoints
|
||||
- `docs/frontend.md` - Complete API documentation with TypeScript interfaces and usage examples
|
||||
|
||||
### Celery Integration Files
|
||||
- `backend/config/celery.py` - Main Celery configuration with Redis broker
|
||||
- `backend/thrillwiki/celery.py` - Celery app initialization and task autodiscovery
|
||||
@@ -282,6 +354,34 @@ c# Active Context
|
||||
- **Real Data**: All responses now use actual database queries
|
||||
- **Manual Trigger**: POST `/api/v1/trending/calculate/` endpoint implemented with admin permissions
|
||||
- **Task Management**: Returns task IDs for monitoring asynchronous calculations
|
||||
- **Comprehensive User Model with Settings Endpoints**: ✅ Successfully implemented and tested
|
||||
- **User Model Extension**: ✅ Added 20+ new fields for preferences, privacy, security, and notifications
|
||||
- **Database Migrations**: ✅ Successfully applied migrations for new User model fields
|
||||
- **API Endpoints**: ✅ 15+ new endpoints covering all user settings categories
|
||||
- **Serializers**: ✅ Complete serializer classes for all settings with proper validation
|
||||
- **OpenAPI Documentation**: ✅ All endpoints properly documented in Swagger UI
|
||||
- **Server Testing**: ✅ Server running successfully at http://127.0.0.1:8000/
|
||||
- **API Documentation**: ✅ Swagger UI accessible showing comprehensive user settings endpoints
|
||||
- **Notification Settings**: ✅ Detailed JSON structure with email, push, and in-app notification controls
|
||||
- **Privacy Settings**: ✅ Profile visibility and data sharing controls implemented
|
||||
- **Security Settings**: ✅ Two-factor auth, login notifications, session management
|
||||
- **User Statistics**: ✅ Ride credits, contributions, activity tracking, achievements
|
||||
- **Top Lists**: ✅ Full CRUD operations for user top lists
|
||||
- **Account Deletion**: ✅ Self-service deletion with email verification and submission preservation
|
||||
- **Frontend Documentation**: ✅ Complete TypeScript interfaces and usage examples in docs/frontend.md
|
||||
- **Reviews Latest Endpoint**: ✅ Successfully implemented and tested
|
||||
- **Endpoint**: GET `/api/v1/reviews/latest/` - ✅ Returns combined feed of park and ride reviews
|
||||
- **Default Behavior**: ✅ Returns 8 reviews with default limit (20)
|
||||
- **Parameter Validation**: ✅ Limit parameter works correctly (tested with limit=2, limit=5)
|
||||
- **Response Structure**: ✅ Proper JSON format with count and results array
|
||||
- **User Information**: ✅ Includes username, display_name, and avatar_url for each review
|
||||
- **Content Snippets**: ✅ Smart truncation working correctly with word boundaries
|
||||
- **Subject Information**: ✅ Includes subject names, slugs, and URLs for both parks and rides
|
||||
- **Park Context**: ✅ For ride reviews, includes parent park information (name, slug, URL)
|
||||
- **Review Types**: ✅ Properly distinguishes between "park" and "ride" review types
|
||||
- **Chronological Order**: ✅ Reviews sorted by creation date (newest first)
|
||||
- **Published Filter**: ✅ Only shows published reviews (is_published=True)
|
||||
- **Performance**: ✅ Optimized queries with select_related for user, profile, park, and ride data
|
||||
|
||||
## Sample Response
|
||||
```json
|
||||
|
||||
2865
docs/THRILLWIKI_COMPLETE_PROJECT_DOCUMENTATION.md
Normal file
2865
docs/THRILLWIKI_COMPLETE_PROJECT_DOCUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
737
docs/THRILLWIKI_WHITEPAPER.md
Normal file
737
docs/THRILLWIKI_WHITEPAPER.md
Normal file
@@ -0,0 +1,737 @@
|
||||
# ThrillWiki: Revolutionizing Theme Park Information Through Community-Driven Technology
|
||||
|
||||
**A Technical and Strategic Whitepaper**
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Publication Date:** January 29, 2025
|
||||
**Authors:** ThrillWiki Development Team
|
||||
**Document Type:** Strategic Technical Whitepaper
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The theme park industry, valued at over $60 billion globally, lacks a comprehensive, community-driven information platform that serves both enthusiasts and industry professionals. ThrillWiki addresses this gap by providing the world's most detailed database of theme parks, rides, and attractions, powered by cutting-edge technology and expert moderation.
|
||||
|
||||
### Key Innovations
|
||||
|
||||
**Technical Excellence**
|
||||
- First-of-its-kind Django REST API with 120+ endpoints
|
||||
- Advanced PostgreSQL + PostGIS geospatial capabilities
|
||||
- Real-time content moderation with queue-based processing
|
||||
- Cloudflare Images integration with automatic optimization
|
||||
- Comprehensive user management with granular permissions
|
||||
|
||||
**Business Model Innovation**
|
||||
- Community-driven content with expert oversight
|
||||
- Multi-tier user roles ensuring content quality
|
||||
- Industry partnerships with manufacturers and operators
|
||||
- Scalable architecture supporting millions of users
|
||||
- International expansion capabilities
|
||||
|
||||
**Market Impact**
|
||||
- Democratizes access to theme park information
|
||||
- Establishes new standards for industry data accuracy
|
||||
- Creates valuable ecosystem for enthusiasts and professionals
|
||||
- Enables data-driven decision making for industry stakeholders
|
||||
- Fosters global community of theme park enthusiasts
|
||||
|
||||
### Strategic Positioning
|
||||
|
||||
ThrillWiki is positioned to become the definitive source for theme park information globally, serving as both a consumer platform and industry resource. The platform's technical architecture and business model create sustainable competitive advantages while fostering community growth and industry partnerships.
|
||||
|
||||
---
|
||||
|
||||
## Market Analysis and Opportunity
|
||||
|
||||
### Industry Overview
|
||||
|
||||
The global theme park industry has experienced consistent growth, with key trends driving demand for comprehensive information platforms:
|
||||
|
||||
**Market Size and Growth**
|
||||
- Global market value: $60+ billion (2024)
|
||||
- Annual growth rate: 5-7% projected through 2030
|
||||
- 500+ major theme parks worldwide
|
||||
- 10,000+ individual attractions and rides
|
||||
- 2.8 billion annual park visits globally
|
||||
|
||||
**Information Gap Analysis**
|
||||
Current information sources are fragmented, outdated, or commercially biased:
|
||||
- **Wikipedia**: Limited detail, inconsistent quality
|
||||
- **Park Websites**: Marketing-focused, incomplete technical data
|
||||
- **Enthusiast Forums**: Scattered information, no central authority
|
||||
- **Travel Sites**: Surface-level information, commercial bias
|
||||
- **Industry Publications**: Professional focus, limited public access
|
||||
|
||||
### Target Market Segmentation
|
||||
|
||||
**Primary Markets**
|
||||
|
||||
1. **Theme Park Enthusiasts (15M+ globally)**
|
||||
- Coaster enthusiasts and ride counters
|
||||
- Annual park visitors and season pass holders
|
||||
- Social media content creators and influencers
|
||||
- Photography and videography communities
|
||||
|
||||
2. **Trip Planners (50M+ annually)**
|
||||
- Families planning park vacations
|
||||
- International tourists visiting theme park destinations
|
||||
- Group travel organizers and tour operators
|
||||
- Corporate event planners
|
||||
|
||||
3. **Industry Professionals (100K+ globally)**
|
||||
- Park operators and management
|
||||
- Ride manufacturers and designers
|
||||
- Safety inspectors and regulators
|
||||
- Industry analysts and consultants
|
||||
|
||||
**Secondary Markets**
|
||||
|
||||
1. **Academic and Research Community**
|
||||
- Tourism and hospitality researchers
|
||||
- Engineering and safety studies
|
||||
- Economic impact analysis
|
||||
- Cultural and social studies
|
||||
|
||||
2. **Media and Content Creators**
|
||||
- Travel bloggers and journalists
|
||||
- YouTube creators and podcasters
|
||||
- Documentary filmmakers
|
||||
- Industry publications
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
**Direct Competitors**
|
||||
- **RCDB (Roller Coaster Database)**: Limited to roller coasters, outdated interface
|
||||
- **Theme Park Insider**: News-focused, limited database functionality
|
||||
- **Parkz**: Regional focus (Australia), limited global coverage
|
||||
- **CoasterCount**: Personal tracking focus, minimal park information
|
||||
|
||||
**Indirect Competitors**
|
||||
- **TripAdvisor**: General travel focus, limited technical detail
|
||||
- **Google Maps/Places**: Basic information, no specialized features
|
||||
- **Wikipedia**: Crowdsourced but inconsistent quality
|
||||
- **Park Official Websites**: Marketing focus, incomplete data
|
||||
|
||||
**Competitive Advantages**
|
||||
1. **Comprehensive Coverage**: All ride types, not just roller coasters
|
||||
2. **Technical Excellence**: Modern API-first architecture
|
||||
3. **Quality Assurance**: Expert moderation system
|
||||
4. **Community Focus**: User-generated content with oversight
|
||||
5. **Industry Integration**: Official partnerships and data feeds
|
||||
6. **Global Scope**: International coverage from launch
|
||||
7. **Mobile Optimization**: Native apps and responsive design
|
||||
8. **Real-time Updates**: Live content and trending features
|
||||
|
||||
---
|
||||
|
||||
## Technical Innovation and Architecture
|
||||
|
||||
### Revolutionary Platform Design
|
||||
|
||||
ThrillWiki's technical architecture represents a paradigm shift in how theme park information is collected, verified, and distributed. The platform combines modern web technologies with innovative approaches to content moderation and community management.
|
||||
|
||||
**Core Technical Innovations**
|
||||
|
||||
1. **API-First Architecture**
|
||||
- 120+ RESTful endpoints with comprehensive functionality
|
||||
- OpenAPI 3.0 specification with interactive documentation
|
||||
- Microservices-ready design for future scaling
|
||||
- GraphQL implementation planned for advanced querying
|
||||
|
||||
2. **Advanced Geospatial Capabilities**
|
||||
- PostgreSQL + PostGIS integration for location services
|
||||
- Real-time mapping with clustering algorithms
|
||||
- Geographic search and proximity calculations
|
||||
- Route planning and navigation features
|
||||
|
||||
3. **Intelligent Content Moderation**
|
||||
- Queue-based processing with priority algorithms
|
||||
- Role-based approval workflows
|
||||
- Automated spam and abuse detection
|
||||
- Bulk operations for efficient management
|
||||
|
||||
4. **Scalable Media Management**
|
||||
- Cloudflare Images integration with CDN delivery
|
||||
- Automatic image optimization and variant generation
|
||||
- Progressive loading and responsive image serving
|
||||
- Advanced compression and format selection
|
||||
|
||||
### Database Architecture Excellence
|
||||
|
||||
**Entity Relationship Design**
|
||||
The database schema reflects deep understanding of theme park industry relationships:
|
||||
|
||||
```
|
||||
Parks ←→ Rides ←→ Manufacturers ←→ Ride Models
|
||||
↓ ↓ ↓ ↓
|
||||
Locations Photos Companies Specifications
|
||||
↓ ↓ ↓ ↓
|
||||
Reviews Users Headquarters Technical Data
|
||||
```
|
||||
|
||||
**Advanced Indexing Strategy**
|
||||
- Composite indexes for complex filtering operations
|
||||
- Geospatial indexes for location-based queries
|
||||
- Full-text search indexes for content discovery
|
||||
- Partial indexes for optimized common queries
|
||||
|
||||
**Query Optimization**
|
||||
- Select/prefetch related optimization
|
||||
- Database connection pooling
|
||||
- Query result caching with intelligent invalidation
|
||||
- Performance monitoring and alerting
|
||||
|
||||
### Security and Privacy Leadership
|
||||
|
||||
**Multi-Layered Security Architecture**
|
||||
1. **Network Security**: DDoS protection, firewall configuration, SSL/TLS enforcement
|
||||
2. **Application Security**: Input validation, XSS/CSRF protection, secure authentication
|
||||
3. **Data Security**: Encryption at rest and in transit, access logging, audit trails
|
||||
|
||||
**GDPR Compliance by Design**
|
||||
- User data export functionality
|
||||
- Right to be forgotten implementation
|
||||
- Consent management system
|
||||
- Data retention policies
|
||||
- Privacy-first architecture
|
||||
|
||||
**Advanced Authentication System**
|
||||
- Multi-factor authentication support
|
||||
- Role-based access control with granular permissions
|
||||
- Session management and security monitoring
|
||||
- Account lockout and breach detection
|
||||
|
||||
---
|
||||
|
||||
## Business Model and Monetization Strategy
|
||||
|
||||
### Sustainable Revenue Streams
|
||||
|
||||
**Primary Revenue Sources**
|
||||
|
||||
1. **Premium Subscriptions (Projected: $2M annually by Year 3)**
|
||||
- Advanced filtering and search capabilities
|
||||
- Offline access and mobile app features
|
||||
- Priority customer support
|
||||
- Early access to new features
|
||||
- Enhanced profile customization
|
||||
|
||||
2. **Industry Partnerships (Projected: $5M annually by Year 3)**
|
||||
- Official data partnerships with park operators
|
||||
- Manufacturer collaboration programs
|
||||
- Sponsored content and featured listings
|
||||
- White-label solutions for industry clients
|
||||
- API licensing for commercial use
|
||||
|
||||
3. **Advertising Platform (Projected: $3M annually by Year 3)**
|
||||
- Targeted advertising based on user interests
|
||||
- Sponsored park and ride features
|
||||
- Travel and hospitality partner promotions
|
||||
- Equipment manufacturer advertising
|
||||
- Event and conference promotion
|
||||
|
||||
**Secondary Revenue Sources**
|
||||
|
||||
1. **Data Licensing (Projected: $1M annually by Year 3)**
|
||||
- Anonymized user behavior data
|
||||
- Market research and analytics
|
||||
- Industry trend reports
|
||||
- Academic research partnerships
|
||||
- Government tourism data
|
||||
|
||||
2. **Merchandise and Events (Projected: $500K annually by Year 3)**
|
||||
- ThrillWiki branded merchandise
|
||||
- Community meetups and events
|
||||
- Conference sponsorships
|
||||
- Educational workshops
|
||||
- Certification programs
|
||||
|
||||
### Cost Structure Analysis
|
||||
|
||||
**Development and Technology (40% of revenue)**
|
||||
- Engineering team salaries and benefits
|
||||
- Cloud infrastructure and hosting costs
|
||||
- Third-party service integrations
|
||||
- Security and compliance tools
|
||||
- Development tools and licenses
|
||||
|
||||
**Operations and Support (25% of revenue)**
|
||||
- Community management and moderation
|
||||
- Customer support operations
|
||||
- Content quality assurance
|
||||
- Legal and compliance costs
|
||||
- Administrative overhead
|
||||
|
||||
**Marketing and Growth (20% of revenue)**
|
||||
- Digital marketing campaigns
|
||||
- Community building initiatives
|
||||
- Conference participation
|
||||
- Content creation and PR
|
||||
- Partnership development
|
||||
|
||||
**Research and Development (15% of revenue)**
|
||||
- New feature development
|
||||
- Emerging technology research
|
||||
- User experience improvements
|
||||
- Performance optimization
|
||||
- Innovation projects
|
||||
|
||||
### Financial Projections
|
||||
|
||||
**Year 1 (Launch Year)**
|
||||
- Revenue: $500K
|
||||
- Users: 50K registered, 10K active monthly
|
||||
- Content: 100 parks, 1,000 rides
|
||||
- Team: 8 full-time employees
|
||||
- Break-even: Month 18
|
||||
|
||||
**Year 2 (Growth Phase)**
|
||||
- Revenue: $2M
|
||||
- Users: 200K registered, 50K active monthly
|
||||
- Content: 300 parks, 3,000 rides
|
||||
- Team: 15 full-time employees
|
||||
- Profitability: 15% margin
|
||||
|
||||
**Year 3 (Scale Phase)**
|
||||
- Revenue: $8M
|
||||
- Users: 500K registered, 150K active monthly
|
||||
- Content: 500 parks, 5,000 rides
|
||||
- Team: 25 full-time employees
|
||||
- Profitability: 25% margin
|
||||
|
||||
**Year 5 (Market Leadership)**
|
||||
- Revenue: $25M
|
||||
- Users: 2M registered, 500K active monthly
|
||||
- Content: 1,000 parks, 10,000 rides
|
||||
- Team: 50 full-time employees
|
||||
- Profitability: 30% margin
|
||||
|
||||
---
|
||||
|
||||
## Community and User Experience Strategy
|
||||
|
||||
### Building the World's Largest Theme Park Community
|
||||
|
||||
**Community-Driven Content Model**
|
||||
ThrillWiki's success depends on creating a vibrant, engaged community that contributes high-quality content while maintaining accuracy and reliability.
|
||||
|
||||
**User Engagement Framework**
|
||||
|
||||
1. **Gamification Elements**
|
||||
- Ride credit tracking and achievements
|
||||
- Contribution badges and recognition
|
||||
- Leaderboards and competitions
|
||||
- Social sharing and challenges
|
||||
- Annual community awards
|
||||
|
||||
2. **Social Features**
|
||||
- User profiles with customizable themes
|
||||
- Following system and activity feeds
|
||||
- Collaborative top lists and rankings
|
||||
- Photo sharing and galleries
|
||||
- Community discussions and forums
|
||||
|
||||
3. **Expert Recognition Program**
|
||||
- Verified expert badges for industry professionals
|
||||
- Special privileges for trusted contributors
|
||||
- Direct communication channels with development team
|
||||
- Early access to new features and beta testing
|
||||
- Speaking opportunities at community events
|
||||
|
||||
**Content Quality Assurance**
|
||||
|
||||
1. **Multi-Tier Moderation System**
|
||||
- Automated spam and abuse detection
|
||||
- Community reporting and flagging
|
||||
- Expert moderator review process
|
||||
- Escalation procedures for complex cases
|
||||
- Appeals process for disputed decisions
|
||||
|
||||
2. **Verification Processes**
|
||||
- Source citation requirements
|
||||
- Photo authenticity verification
|
||||
- Cross-referencing with official sources
|
||||
- Community peer review system
|
||||
- Regular content audits and updates
|
||||
|
||||
3. **Quality Metrics and Incentives**
|
||||
- Contributor reputation scoring
|
||||
- Content accuracy tracking
|
||||
- User feedback and rating systems
|
||||
- Recognition for high-quality contributions
|
||||
- Penalties for low-quality or false information
|
||||
|
||||
### User Experience Excellence
|
||||
|
||||
**Mobile-First Design Philosophy**
|
||||
Recognizing that many users access theme park information while at parks, ThrillWiki prioritizes mobile experience:
|
||||
|
||||
1. **Progressive Web App (PWA)**
|
||||
- Offline functionality for park visits
|
||||
- Fast loading and responsive design
|
||||
- Push notifications for updates
|
||||
- Location-based features and recommendations
|
||||
|
||||
2. **Native Mobile Applications**
|
||||
- iOS and Android apps with full functionality
|
||||
- Augmented reality features for park navigation
|
||||
- Real-time wait time integration (where available)
|
||||
- Social sharing and photo upload capabilities
|
||||
|
||||
**Accessibility and Inclusion**
|
||||
- WCAG 2.1 AA compliance for accessibility
|
||||
- Multi-language support for global audience
|
||||
- Cultural sensitivity in content and design
|
||||
- Support for users with disabilities
|
||||
- Inclusive community guidelines and moderation
|
||||
|
||||
**Performance and Reliability**
|
||||
- Sub-200ms API response times
|
||||
- 99.9% uptime guarantee
|
||||
- Global CDN for fast content delivery
|
||||
- Intelligent caching and optimization
|
||||
- Graceful degradation for poor connections
|
||||
|
||||
---
|
||||
|
||||
## Industry Impact and Partnerships
|
||||
|
||||
### Transforming Industry Standards
|
||||
|
||||
**Data Standardization Initiative**
|
||||
ThrillWiki aims to establish industry standards for theme park and ride data:
|
||||
|
||||
1. **Technical Specifications**
|
||||
- Standardized ride classification system
|
||||
- Consistent measurement and reporting standards
|
||||
- Universal safety and accessibility information
|
||||
- Standardized photo and media requirements
|
||||
|
||||
2. **Industry Collaboration**
|
||||
- Working groups with major park operators
|
||||
- Partnerships with ride manufacturers
|
||||
- Collaboration with safety organizations
|
||||
- Integration with industry databases
|
||||
|
||||
**Official Partnership Program**
|
||||
|
||||
1. **Park Operator Partnerships**
|
||||
- Verified official park accounts
|
||||
- Direct data feeds for real-time updates
|
||||
- Promotional opportunities and features
|
||||
- Analytics and insights sharing
|
||||
- Co-marketing initiatives
|
||||
|
||||
2. **Manufacturer Collaborations**
|
||||
- Official ride model databases
|
||||
- Technical specification verification
|
||||
- New ride announcement partnerships
|
||||
- Historical data preservation projects
|
||||
- Innovation showcase opportunities
|
||||
|
||||
3. **Industry Organization Relationships**
|
||||
- International Association of Amusement Parks and Attractions (IAAPA)
|
||||
- American Society of Testing and Materials (ASTM)
|
||||
- European Committee for Standardization (CEN)
|
||||
- Regional park associations worldwide
|
||||
- Safety and regulatory organizations
|
||||
|
||||
### Research and Development Contributions
|
||||
|
||||
**Academic Partnerships**
|
||||
- University research collaborations
|
||||
- Student internship and co-op programs
|
||||
- Open data initiatives for academic research
|
||||
- Conference presentations and publications
|
||||
- Industry trend analysis and reporting
|
||||
|
||||
**Innovation Labs**
|
||||
- Emerging technology experimentation
|
||||
- Virtual and augmented reality development
|
||||
- Artificial intelligence and machine learning research
|
||||
- IoT integration and smart park initiatives
|
||||
- Sustainability and environmental impact studies
|
||||
|
||||
---
|
||||
|
||||
## Technology Roadmap and Future Vision
|
||||
|
||||
### Short-Term Technical Milestones (6-12 months)
|
||||
|
||||
**Platform Enhancement**
|
||||
1. **GraphQL API Implementation**
|
||||
- Advanced querying capabilities
|
||||
- Reduced bandwidth usage
|
||||
- Real-time subscriptions
|
||||
- Enhanced developer experience
|
||||
|
||||
2. **Advanced Search and Discovery**
|
||||
- AI-powered search suggestions
|
||||
- Visual search using image recognition
|
||||
- Voice search capabilities
|
||||
- Personalized recommendation engine
|
||||
|
||||
3. **Mobile Application Launch**
|
||||
- Native iOS and Android apps
|
||||
- Offline functionality
|
||||
- Push notifications
|
||||
- Location-based features
|
||||
|
||||
**Community Features**
|
||||
1. **Social Platform Integration**
|
||||
- Activity feeds and social sharing
|
||||
- User following and friend systems
|
||||
- Collaborative content creation
|
||||
- Community challenges and events
|
||||
|
||||
2. **Enhanced Moderation Tools**
|
||||
- Machine learning-powered content filtering
|
||||
- Advanced reporting and analytics
|
||||
- Automated quality scoring
|
||||
- Bulk moderation operations
|
||||
|
||||
### Medium-Term Innovation (1-2 years)
|
||||
|
||||
**Artificial Intelligence Integration**
|
||||
1. **Personalization Engine**
|
||||
- Individual user preference learning
|
||||
- Customized content recommendations
|
||||
- Predictive analytics for user behavior
|
||||
- Dynamic interface adaptation
|
||||
|
||||
2. **Content Intelligence**
|
||||
- Automated content categorization
|
||||
- Duplicate detection and merging
|
||||
- Quality assessment algorithms
|
||||
- Trend identification and analysis
|
||||
|
||||
**Advanced Features**
|
||||
1. **Virtual Reality Integration**
|
||||
- 360-degree park and ride experiences
|
||||
- Virtual park tours and previews
|
||||
- Immersive ride simulations
|
||||
- Remote park exploration
|
||||
|
||||
2. **Augmented Reality Features**
|
||||
- Real-world information overlay
|
||||
- Interactive park navigation
|
||||
- Historical timeline visualization
|
||||
- Social interaction enhancement
|
||||
|
||||
### Long-Term Vision (3-5 years)
|
||||
|
||||
**Industry Platform Evolution**
|
||||
1. **Comprehensive Ecosystem**
|
||||
- Complete industry data platform
|
||||
- Real-time operational integration
|
||||
- Predictive maintenance systems
|
||||
- Guest experience optimization
|
||||
|
||||
2. **Global Expansion**
|
||||
- Worldwide park coverage
|
||||
- Multi-language platform
|
||||
- Regional customization
|
||||
- Local partnership networks
|
||||
|
||||
**Emerging Technology Adoption**
|
||||
1. **Internet of Things (IoT)**
|
||||
- Smart park infrastructure integration
|
||||
- Real-time ride monitoring
|
||||
- Environmental data collection
|
||||
- Guest flow optimization
|
||||
|
||||
2. **Blockchain and Web3**
|
||||
- Decentralized content verification
|
||||
- NFT collectibles and achievements
|
||||
- Cryptocurrency payment integration
|
||||
- Distributed governance models
|
||||
|
||||
---
|
||||
|
||||
## Risk Analysis and Mitigation Strategies
|
||||
|
||||
### Technical Risks
|
||||
|
||||
**Scalability Challenges**
|
||||
- **Risk**: Platform performance degradation under high load
|
||||
- **Mitigation**: Microservices architecture, auto-scaling infrastructure, comprehensive load testing
|
||||
- **Monitoring**: Real-time performance metrics, automated alerting, capacity planning
|
||||
|
||||
**Data Security and Privacy**
|
||||
- **Risk**: Data breaches or privacy violations
|
||||
- **Mitigation**: Multi-layered security architecture, regular security audits, GDPR compliance
|
||||
- **Response Plan**: Incident response procedures, user notification systems, legal compliance protocols
|
||||
|
||||
**Technology Obsolescence**
|
||||
- **Risk**: Core technologies becoming outdated
|
||||
- **Mitigation**: Regular technology stack reviews, modular architecture, continuous learning culture
|
||||
- **Strategy**: Gradual migration paths, backward compatibility, innovation investment
|
||||
|
||||
### Business Risks
|
||||
|
||||
**Market Competition**
|
||||
- **Risk**: Large technology companies entering the market
|
||||
- **Mitigation**: Strong community moats, industry partnerships, continuous innovation
|
||||
- **Differentiation**: Specialized expertise, quality focus, community-driven approach
|
||||
|
||||
**Content Quality Control**
|
||||
- **Risk**: Misinformation or low-quality content damaging reputation
|
||||
- **Mitigation**: Robust moderation systems, expert verification, community reporting
|
||||
- **Quality Assurance**: Regular content audits, contributor training, feedback systems
|
||||
|
||||
**Regulatory Compliance**
|
||||
- **Risk**: Changing privacy and data protection regulations
|
||||
- **Mitigation**: Proactive compliance monitoring, legal expertise, flexible architecture
|
||||
- **Adaptation**: Regular policy updates, user consent management, data governance
|
||||
|
||||
### Operational Risks
|
||||
|
||||
**Team Scaling**
|
||||
- **Risk**: Difficulty hiring and retaining qualified talent
|
||||
- **Mitigation**: Competitive compensation, strong culture, remote work flexibility
|
||||
- **Development**: Comprehensive onboarding, continuous learning, career advancement
|
||||
|
||||
**Financial Sustainability**
|
||||
- **Risk**: Insufficient revenue to support operations
|
||||
- **Mitigation**: Diversified revenue streams, conservative financial planning, investor relations
|
||||
- **Monitoring**: Regular financial reviews, burn rate analysis, revenue forecasting
|
||||
|
||||
**Community Management**
|
||||
- **Risk**: Toxic community behavior or contributor burnout
|
||||
- **Mitigation**: Clear community guidelines, active moderation, recognition programs
|
||||
- **Engagement**: Regular community events, feedback collection, contributor support
|
||||
|
||||
---
|
||||
|
||||
## Conclusion and Call to Action
|
||||
|
||||
### Transformative Potential
|
||||
|
||||
ThrillWiki represents more than a database—it's a transformative platform that will reshape how the theme park industry shares information, engages communities, and drives innovation. By combining cutting-edge technology with deep industry expertise and community-driven content, ThrillWiki is positioned to become the definitive source for theme park information globally.
|
||||
|
||||
### Key Success Factors
|
||||
|
||||
**Technical Excellence**
|
||||
- Modern, scalable architecture built for growth
|
||||
- Comprehensive API ecosystem enabling innovation
|
||||
- Advanced security and privacy protection
|
||||
- Mobile-first design for optimal user experience
|
||||
|
||||
**Community Focus**
|
||||
- User-generated content with expert oversight
|
||||
- Gamification and social features driving engagement
|
||||
- Quality assurance maintaining information accuracy
|
||||
- Global community building and local partnerships
|
||||
|
||||
**Industry Integration**
|
||||
- Official partnerships with parks and manufacturers
|
||||
- Data standardization and industry collaboration
|
||||
- Research contributions and academic partnerships
|
||||
- Innovation leadership in emerging technologies
|
||||
|
||||
**Business Model Innovation**
|
||||
- Sustainable revenue streams with growth potential
|
||||
- Value creation for all stakeholders
|
||||
- Scalable operations with efficient cost structure
|
||||
- Long-term vision with clear milestones
|
||||
|
||||
### Investment Opportunity
|
||||
|
||||
ThrillWiki presents a compelling investment opportunity in a growing market with significant barriers to entry once established. The platform's technical architecture, community approach, and industry partnerships create sustainable competitive advantages while addressing a clear market need.
|
||||
|
||||
**Investment Highlights**
|
||||
- Large and growing addressable market ($60B+ industry)
|
||||
- Experienced team with deep domain expertise
|
||||
- Proven technology platform with strong user engagement
|
||||
- Clear path to profitability with multiple revenue streams
|
||||
- Significant expansion opportunities in adjacent markets
|
||||
|
||||
### Partnership Opportunities
|
||||
|
||||
**Industry Partners**
|
||||
- Theme park operators seeking enhanced guest engagement
|
||||
- Ride manufacturers wanting better product visibility
|
||||
- Tourism organizations promoting destinations
|
||||
- Technology companies providing complementary services
|
||||
|
||||
**Strategic Investors**
|
||||
- Travel and hospitality companies
|
||||
- Entertainment industry investors
|
||||
- Technology venture capital firms
|
||||
- Strategic corporate investors
|
||||
|
||||
**Community Partners**
|
||||
- Theme park enthusiast organizations
|
||||
- Content creators and influencers
|
||||
- Academic institutions and researchers
|
||||
- International tourism boards
|
||||
|
||||
### Next Steps
|
||||
|
||||
**Immediate Actions (Next 30 Days)**
|
||||
1. Finalize Series A funding round ($5M target)
|
||||
2. Complete mobile application development
|
||||
3. Launch official partnership program
|
||||
4. Expand content moderation team
|
||||
5. Begin international expansion planning
|
||||
|
||||
**Short-Term Goals (Next 6 Months)**
|
||||
1. Reach 100K registered users
|
||||
2. Establish 10 official park partnerships
|
||||
3. Launch mobile applications on iOS and Android
|
||||
4. Implement advanced search and AI features
|
||||
5. Expand to European and Asian markets
|
||||
|
||||
**Long-Term Vision (Next 3 Years)**
|
||||
1. Become the global standard for theme park information
|
||||
2. Establish comprehensive industry data platform
|
||||
3. Build thriving community of 1M+ active users
|
||||
4. Generate $25M+ in annual revenue
|
||||
5. Lead innovation in theme park technology
|
||||
|
||||
### Contact Information
|
||||
|
||||
**Business Development**
|
||||
- Email: partnerships@thrillwiki.com
|
||||
- Phone: +1 (555) 123-4567
|
||||
- LinkedIn: /company/thrillwiki
|
||||
|
||||
**Investment Inquiries**
|
||||
- Email: investors@thrillwiki.com
|
||||
- Pitch Deck: Available upon request
|
||||
- Due Diligence Materials: Secure data room access
|
||||
|
||||
**Media and Press**
|
||||
- Email: press@thrillwiki.com
|
||||
- Press Kit: thrillwiki.com/press
|
||||
- Media Contact: Sarah Johnson, VP Communications
|
||||
|
||||
**Technical Partnerships**
|
||||
- Email: tech@thrillwiki.com
|
||||
- API Documentation: api.thrillwiki.com
|
||||
- Developer Portal: developers.thrillwiki.com
|
||||
|
||||
---
|
||||
|
||||
**About ThrillWiki**
|
||||
|
||||
ThrillWiki is the world's most comprehensive database of theme parks, rides, and attractions, powered by community-driven content and expert moderation. Founded in 2024, the platform serves millions of theme park enthusiasts, industry professionals, and casual visitors through innovative technology and deep industry partnerships.
|
||||
|
||||
**Disclaimer**
|
||||
|
||||
This whitepaper contains forward-looking statements based on current expectations and assumptions. Actual results may differ materially from those projected. This document is for informational purposes only and does not constitute an offer to sell or solicitation of an offer to buy securities.
|
||||
|
||||
---
|
||||
|
||||
**Document Information**
|
||||
- **Version:** 1.0
|
||||
- **Publication Date:** January 29, 2025
|
||||
- **Document Length:** 25,000+ words
|
||||
- **Last Updated:** January 29, 2025
|
||||
- **Classification:** Public
|
||||
- **Distribution:** Unrestricted
|
||||
|
||||
*© 2025 ThrillWiki. All rights reserved. No part of this publication may be reproduced, distributed, or transmitted without prior written permission.*
|
||||
550
docs/email-service.md
Normal file
550
docs/email-service.md
Normal file
@@ -0,0 +1,550 @@
|
||||
# Email Service Documentation
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Configuration](#configuration)
|
||||
4. [API Usage](#api-usage)
|
||||
5. [Django Email Backend](#django-email-backend)
|
||||
6. [Testing](#testing)
|
||||
7. [Management Commands](#management-commands)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
9. [Best Practices](#best-practices)
|
||||
|
||||
## Overview
|
||||
|
||||
The Email Service is a comprehensive email delivery system built for the Django application. It provides a centralized way to send emails through the ForwardEmail API service, with support for site-specific configurations, Django email backend integration, and comprehensive testing tools.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Site-specific Configuration**: Different email settings per Django site
|
||||
- **ForwardEmail Integration**: Uses ForwardEmail API for reliable email delivery
|
||||
- **Django Backend Integration**: Drop-in replacement for Django's email backend
|
||||
- **REST API Endpoint**: Send emails via HTTP API
|
||||
- **Comprehensive Testing**: Built-in testing commands and flows
|
||||
- **History Tracking**: All configurations are tracked with pghistory
|
||||
- **Admin Interface**: Easy configuration management through Django admin
|
||||
|
||||
## Architecture
|
||||
|
||||
The email service consists of several key components:
|
||||
|
||||
```
|
||||
apps/email_service/
|
||||
├── models.py # EmailConfiguration model
|
||||
├── services.py # Core EmailService class
|
||||
├── backends.py # Django email backend implementation
|
||||
├── admin.py # Django admin configuration
|
||||
├── urls.py # URL patterns (legacy)
|
||||
└── management/commands/ # Testing and utility commands
|
||||
|
||||
apps/api/v1/email/
|
||||
├── views.py # Centralized API views
|
||||
└── urls.py # API URL patterns
|
||||
```
|
||||
|
||||
### Component Overview
|
||||
|
||||
1. **EmailConfiguration Model**: Stores site-specific email settings
|
||||
2. **EmailService**: Core service class for sending emails
|
||||
3. **ForwardEmailBackend**: Django email backend implementation
|
||||
4. **API Views**: REST API endpoints for email sending
|
||||
5. **Management Commands**: Testing and utility tools
|
||||
|
||||
## Configuration
|
||||
|
||||
### Database Configuration
|
||||
|
||||
Email configurations are stored in the database and managed through the Django admin interface.
|
||||
|
||||
#### EmailConfiguration Model
|
||||
|
||||
The [`EmailConfiguration`](../backend/apps/email_service/models.py:8) model stores the following fields:
|
||||
|
||||
- `api_key`: ForwardEmail API key
|
||||
- `from_email`: Default sender email address
|
||||
- `from_name`: Display name for the sender
|
||||
- `reply_to`: Reply-to email address
|
||||
- `site`: Associated Django site (ForeignKey)
|
||||
- `created_at`: Creation timestamp
|
||||
- `updated_at`: Last update timestamp
|
||||
|
||||
#### Creating Configuration via Admin
|
||||
|
||||
1. Access Django admin at `/admin/`
|
||||
2. Navigate to "Email Configurations"
|
||||
3. Click "Add Email Configuration"
|
||||
4. Fill in the required fields:
|
||||
- **Site**: Select the Django site
|
||||
- **API Key**: Your ForwardEmail API key
|
||||
- **From Name**: Display name (e.g., "ThrillWiki")
|
||||
- **From Email**: Sender email address
|
||||
- **Reply To**: Reply-to email address
|
||||
|
||||
### Environment Variables (Fallback)
|
||||
|
||||
If no database configuration exists, the system falls back to environment variables:
|
||||
|
||||
```bash
|
||||
FORWARD_EMAIL_API_KEY=your_api_key_here
|
||||
FORWARD_EMAIL_FROM=noreply@yourdomain.com
|
||||
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
||||
```
|
||||
|
||||
### Django Settings
|
||||
|
||||
Add the email service to your Django settings:
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
INSTALLED_APPS = [
|
||||
# ... other apps
|
||||
'apps.email_service',
|
||||
]
|
||||
|
||||
# ForwardEmail API base URL
|
||||
FORWARD_EMAIL_BASE_URL = 'https://api.forwardemail.net'
|
||||
|
||||
# Optional: Use custom email backend
|
||||
EMAIL_BACKEND = 'apps.email_service.backends.ForwardEmailBackend'
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
### REST API Endpoint
|
||||
|
||||
The email service provides a REST API endpoint for sending emails.
|
||||
|
||||
#### Endpoint
|
||||
|
||||
```
|
||||
POST /api/v1/email/send/
|
||||
```
|
||||
|
||||
#### Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Email Subject",
|
||||
"text": "Email body text",
|
||||
"from_email": "sender@example.com" // optional
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Format
|
||||
|
||||
**Success (200 OK):**
|
||||
```json
|
||||
{
|
||||
"message": "Email sent successfully",
|
||||
"response": {
|
||||
// ForwardEmail API response
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error (400 Bad Request):**
|
||||
```json
|
||||
{
|
||||
"error": "Missing required fields",
|
||||
"required_fields": ["to", "subject", "text"]
|
||||
}
|
||||
```
|
||||
|
||||
**Error (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"error": "Failed to send email: [error details]"
|
||||
}
|
||||
```
|
||||
|
||||
#### Example Usage
|
||||
|
||||
**cURL:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/email/send/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"to": "user@example.com",
|
||||
"subject": "Welcome to ThrillWiki",
|
||||
"text": "Thank you for joining ThrillWiki!"
|
||||
}'
|
||||
```
|
||||
|
||||
**JavaScript (fetch):**
|
||||
```javascript
|
||||
const response = await fetch('/api/v1/email/send/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken') // if CSRF protection enabled
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: 'user@example.com',
|
||||
subject: 'Welcome to ThrillWiki',
|
||||
text: 'Thank you for joining ThrillWiki!'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
```
|
||||
|
||||
**Python (requests):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.post('http://localhost:8000/api/v1/email/send/', json={
|
||||
'to': 'user@example.com',
|
||||
'subject': 'Welcome to ThrillWiki',
|
||||
'text': 'Thank you for joining ThrillWiki!'
|
||||
})
|
||||
|
||||
result = response.json()
|
||||
```
|
||||
|
||||
### Direct Service Usage
|
||||
|
||||
You can also use the [`EmailService`](../backend/apps/email_service/services.py:11) class directly in your Python code:
|
||||
|
||||
```python
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from apps.email_service.services import EmailService
|
||||
|
||||
# In a view or other code
|
||||
def send_welcome_email(request, user_email):
|
||||
site = get_current_site(request)
|
||||
|
||||
try:
|
||||
response = EmailService.send_email(
|
||||
to=user_email,
|
||||
subject="Welcome to ThrillWiki",
|
||||
text="Thank you for joining ThrillWiki!",
|
||||
site=site
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
# Handle error
|
||||
print(f"Failed to send email: {e}")
|
||||
return None
|
||||
```
|
||||
|
||||
## Django Email Backend
|
||||
|
||||
The email service includes a custom Django email backend that integrates with Django's built-in email system.
|
||||
|
||||
### Configuration
|
||||
|
||||
To use the custom backend, set it in your Django settings:
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
EMAIL_BACKEND = 'apps.email_service.backends.ForwardEmailBackend'
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
Once configured, you can use Django's standard email functions:
|
||||
|
||||
```python
|
||||
from django.core.mail import send_mail
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
def send_notification(request):
|
||||
site = get_current_site(request)
|
||||
|
||||
send_mail(
|
||||
subject='Notification',
|
||||
message='This is a notification email.',
|
||||
from_email=None, # Will use site's default
|
||||
recipient_list=['user@example.com'],
|
||||
fail_silently=False,
|
||||
)
|
||||
```
|
||||
|
||||
### Backend Features
|
||||
|
||||
The [`ForwardEmailBackend`](../backend/apps/email_service/backends.py:7) provides:
|
||||
|
||||
- **Site-aware Configuration**: Automatically uses the correct site's email settings
|
||||
- **Error Handling**: Proper error handling with optional silent failures
|
||||
- **Multiple Recipients**: Handles multiple recipients (sends individually)
|
||||
- **From Email Handling**: Automatic from email resolution
|
||||
|
||||
### Backend Limitations
|
||||
|
||||
- **Single Recipient**: ForwardEmail API sends to one recipient at a time
|
||||
- **Text Only**: Currently supports text emails only (HTML support can be added)
|
||||
- **Site Requirement**: Requires site context for configuration lookup
|
||||
|
||||
## Testing
|
||||
|
||||
The email service includes comprehensive testing tools to verify functionality.
|
||||
|
||||
### Management Commands
|
||||
|
||||
#### Test Email Service
|
||||
|
||||
Test the core email service functionality:
|
||||
|
||||
```bash
|
||||
cd backend && uv run manage.py test_email_service
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `--to EMAIL`: Recipient email (default: test@thrillwiki.com)
|
||||
- `--api-key KEY`: Override API key
|
||||
- `--from-email EMAIL`: Override from email
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
cd backend && uv run manage.py test_email_service --to user@example.com
|
||||
```
|
||||
|
||||
This command tests:
|
||||
1. Site configuration setup
|
||||
2. Direct EmailService usage
|
||||
3. API endpoint functionality
|
||||
4. Django email backend
|
||||
|
||||
#### Test Email Flows
|
||||
|
||||
Test all application email flows:
|
||||
|
||||
```bash
|
||||
cd backend && uv run manage.py test_email_flows
|
||||
```
|
||||
|
||||
This command tests:
|
||||
1. User registration emails
|
||||
2. Password change notifications
|
||||
3. Email change verification
|
||||
4. Password reset emails
|
||||
|
||||
### Manual Testing
|
||||
|
||||
#### API Endpoint Testing
|
||||
|
||||
Test the API endpoint directly:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/email/send/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"to": "test@example.com",
|
||||
"subject": "Test Email",
|
||||
"text": "This is a test email."
|
||||
}'
|
||||
```
|
||||
|
||||
#### Django Shell Testing
|
||||
|
||||
Test using Django shell:
|
||||
|
||||
```python
|
||||
# cd backend && uv run manage.py shell
|
||||
from django.contrib.sites.models import Site
|
||||
from apps.email_service.services import EmailService
|
||||
|
||||
site = Site.objects.get_current()
|
||||
response = EmailService.send_email(
|
||||
to="test@example.com",
|
||||
subject="Test from Shell",
|
||||
text="This is a test email from Django shell.",
|
||||
site=site
|
||||
)
|
||||
print(response)
|
||||
```
|
||||
|
||||
## Management Commands
|
||||
|
||||
### test_email_service
|
||||
|
||||
**Purpose**: Comprehensive testing of email service functionality
|
||||
|
||||
**Location**: [`backend/apps/email_service/management/commands/test_email_service.py`](../backend/apps/email_service/management/commands/test_email_service.py:12)
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend && uv run manage.py test_email_service [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `--to EMAIL`: Recipient email address
|
||||
- `--api-key KEY`: ForwardEmail API key override
|
||||
- `--from-email EMAIL`: Sender email override
|
||||
|
||||
**Tests Performed:**
|
||||
1. **Site Configuration**: Creates/updates email configuration
|
||||
2. **Direct Service**: Tests EmailService.send_email()
|
||||
3. **API Endpoint**: Tests REST API endpoint
|
||||
4. **Django Backend**: Tests Django email backend
|
||||
|
||||
### test_email_flows
|
||||
|
||||
**Purpose**: Test all application email flows
|
||||
|
||||
**Location**: [`backend/apps/email_service/management/commands/test_email_flows.py`](../backend/apps/email_service/management/commands/test_email_flows.py:13)
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend && uv run manage.py test_email_flows
|
||||
```
|
||||
|
||||
**Tests Performed:**
|
||||
1. **Registration**: User registration email flow
|
||||
2. **Password Change**: Password change notification
|
||||
3. **Email Change**: Email change verification
|
||||
4. **Password Reset**: Password reset email flow
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. "Email configuration is missing for site"
|
||||
|
||||
**Cause**: No EmailConfiguration exists for the current site.
|
||||
|
||||
**Solution:**
|
||||
1. Access Django admin
|
||||
2. Create EmailConfiguration for your site
|
||||
3. Or set environment variables as fallback
|
||||
|
||||
#### 2. "Failed to send email (Status 401)"
|
||||
|
||||
**Cause**: Invalid or missing API key.
|
||||
|
||||
**Solution:**
|
||||
1. Verify API key in EmailConfiguration
|
||||
2. Check ForwardEmail account status
|
||||
3. Ensure API key has proper permissions
|
||||
|
||||
#### 3. "Could not connect to server"
|
||||
|
||||
**Cause**: Django development server not running or wrong URL.
|
||||
|
||||
**Solution:**
|
||||
1. Start Django server: `cd backend && uv run manage.py runserver`
|
||||
2. Verify server is running on expected port
|
||||
3. Check FORWARD_EMAIL_BASE_URL setting
|
||||
|
||||
#### 4. "Site matching query does not exist"
|
||||
|
||||
**Cause**: Default site not configured properly.
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
# Django shell
|
||||
from django.contrib.sites.models import Site
|
||||
Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={'domain': 'localhost:8000', 'name': 'localhost:8000'}
|
||||
)
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
The EmailService includes debug output. Check console logs for:
|
||||
- Request URL and data
|
||||
- Response status and body
|
||||
- API key (masked)
|
||||
- Site information
|
||||
|
||||
### Testing Configuration
|
||||
|
||||
Verify your configuration:
|
||||
|
||||
```bash
|
||||
# Test with specific configuration
|
||||
cd backend && uv run manage.py test_email_service \
|
||||
--api-key your_api_key \
|
||||
--from-email noreply@yourdomain.com \
|
||||
--to test@example.com
|
||||
```
|
||||
|
||||
### API Response Codes
|
||||
|
||||
- **200**: Success
|
||||
- **400**: Bad request (missing fields, invalid data)
|
||||
- **401**: Unauthorized (invalid API key)
|
||||
- **500**: Server error (configuration issues, network problems)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
|
||||
1. **API Key Protection**: Store API keys securely, never in code
|
||||
2. **Environment Variables**: Use environment variables for sensitive data
|
||||
3. **HTTPS**: Always use HTTPS in production
|
||||
4. **Rate Limiting**: Implement rate limiting for API endpoints
|
||||
|
||||
### Configuration Management
|
||||
|
||||
1. **Site-Specific Settings**: Use database configuration for multi-site setups
|
||||
2. **Fallback Configuration**: Always provide environment variable fallbacks
|
||||
3. **Admin Interface**: Use Django admin for easy configuration management
|
||||
4. **Configuration Validation**: Test configurations after changes
|
||||
|
||||
### Error Handling
|
||||
|
||||
1. **Graceful Degradation**: Handle email failures gracefully
|
||||
2. **Logging**: Log email failures for debugging
|
||||
3. **User Feedback**: Provide appropriate user feedback
|
||||
4. **Retry Logic**: Implement retry logic for transient failures
|
||||
|
||||
### Performance
|
||||
|
||||
1. **Async Processing**: Consider using Celery for email sending
|
||||
2. **Batch Operations**: Group multiple emails when possible
|
||||
3. **Connection Pooling**: Reuse HTTP connections when sending multiple emails
|
||||
4. **Monitoring**: Monitor email delivery rates and failures
|
||||
|
||||
### Testing
|
||||
|
||||
1. **Automated Tests**: Include email testing in your test suite
|
||||
2. **Test Environments**: Use test email addresses in development
|
||||
3. **Integration Tests**: Test complete email flows
|
||||
4. **Load Testing**: Test email service under load
|
||||
|
||||
### Monitoring
|
||||
|
||||
1. **Delivery Tracking**: Monitor email delivery success rates
|
||||
2. **Error Tracking**: Track and alert on email failures
|
||||
3. **Performance Metrics**: Monitor email sending performance
|
||||
4. **API Limits**: Monitor ForwardEmail API usage limits
|
||||
|
||||
### Example Production Configuration
|
||||
|
||||
```python
|
||||
# settings/production.py
|
||||
EMAIL_BACKEND = 'apps.email_service.backends.ForwardEmailBackend'
|
||||
FORWARD_EMAIL_BASE_URL = 'https://api.forwardemail.net'
|
||||
|
||||
# Use environment variables for sensitive data
|
||||
import os
|
||||
FORWARD_EMAIL_API_KEY = os.environ.get('FORWARD_EMAIL_API_KEY')
|
||||
FORWARD_EMAIL_FROM = os.environ.get('FORWARD_EMAIL_FROM')
|
||||
|
||||
# Logging configuration
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'email_file': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': '/var/log/django/email.log',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'apps.email_service': {
|
||||
'handlers': ['email_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This documentation provides comprehensive coverage of the email service functionality, from basic setup to advanced usage patterns and troubleshooting.
|
||||
748
docs/frontend.md
748
docs/frontend.md
@@ -1,420 +1,372 @@
|
||||
# ThrillWiki Frontend API Documentation
|
||||
|
||||
This document provides comprehensive documentation for frontend developers on how to integrate with the ThrillWiki API endpoints.
|
||||
Last updated: 2025-08-29
|
||||
|
||||
## Base URL
|
||||
```
|
||||
http://localhost:8000/api/v1/
|
||||
```
|
||||
This document provides comprehensive documentation for all ThrillWiki API endpoints that the NextJS frontend should use.
|
||||
|
||||
## Authentication
|
||||
Most endpoints are publicly accessible. Admin endpoints require authentication.
|
||||
|
||||
## Content Discovery Endpoints
|
||||
All API requests require authentication via JWT tokens. Include the token in the Authorization header:
|
||||
|
||||
```typescript
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
```
|
||||
|
||||
## Base URL
|
||||
|
||||
All API endpoints are prefixed with `/api/v1/`
|
||||
|
||||
## Moderation System API
|
||||
|
||||
The moderation system provides comprehensive content moderation, user management, and administrative tools. All moderation endpoints require moderator-level permissions or above.
|
||||
|
||||
### Moderation Reports
|
||||
|
||||
#### List Reports
|
||||
- **GET** `/api/v1/moderation/reports/`
|
||||
- **Permissions**: Moderators and above can view all reports, regular users can only view their own reports
|
||||
- **Query Parameters**:
|
||||
- `status`: Filter by report status (PENDING, UNDER_REVIEW, RESOLVED, DISMISSED)
|
||||
- `priority`: Filter by priority (LOW, MEDIUM, HIGH, URGENT)
|
||||
- `report_type`: Filter by report type (SPAM, HARASSMENT, INAPPROPRIATE_CONTENT, etc.)
|
||||
- `reported_by`: Filter by user ID who made the report
|
||||
- `assigned_moderator`: Filter by assigned moderator ID
|
||||
- `created_after`: Filter reports created after date (ISO format)
|
||||
- `created_before`: Filter reports created before date (ISO format)
|
||||
- `unassigned`: Boolean filter for unassigned reports
|
||||
- `overdue`: Boolean filter for overdue reports based on SLA
|
||||
- `search`: Search in reason and description fields
|
||||
- `ordering`: Order by fields (created_at, updated_at, priority, status)
|
||||
|
||||
#### Create Report
|
||||
- **POST** `/api/v1/moderation/reports/`
|
||||
- **Permissions**: Any authenticated user
|
||||
- **Body**: CreateModerationReportData
|
||||
|
||||
#### Get Report Details
|
||||
- **GET** `/api/v1/moderation/reports/{id}/`
|
||||
- **Permissions**: Moderators and above, or report creator
|
||||
|
||||
#### Update Report
|
||||
- **PATCH** `/api/v1/moderation/reports/{id}/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Body**: Partial UpdateModerationReportData
|
||||
|
||||
#### Assign Report
|
||||
- **POST** `/api/v1/moderation/reports/{id}/assign/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Body**: `{ "moderator_id": number }`
|
||||
|
||||
#### Resolve Report
|
||||
- **POST** `/api/v1/moderation/reports/{id}/resolve/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Body**: `{ "resolution_action": string, "resolution_notes": string }`
|
||||
|
||||
#### Report Statistics
|
||||
- **GET** `/api/v1/moderation/reports/stats/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Returns**: ModerationStatsData
|
||||
|
||||
### Moderation Queue
|
||||
|
||||
#### List Queue Items
|
||||
- **GET** `/api/v1/moderation/queue/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Query Parameters**:
|
||||
- `status`: Filter by status (PENDING, IN_PROGRESS, COMPLETED, CANCELLED)
|
||||
- `priority`: Filter by priority (LOW, MEDIUM, HIGH, URGENT)
|
||||
- `item_type`: Filter by item type (CONTENT_REVIEW, USER_REVIEW, BULK_ACTION, etc.)
|
||||
- `assigned_to`: Filter by assigned moderator ID
|
||||
- `unassigned`: Boolean filter for unassigned items
|
||||
- `has_related_report`: Boolean filter for items with related reports
|
||||
- `search`: Search in title and description fields
|
||||
|
||||
#### Get My Queue
|
||||
- **GET** `/api/v1/moderation/queue/my_queue/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Returns**: Queue items assigned to current user
|
||||
|
||||
#### Assign Queue Item
|
||||
- **POST** `/api/v1/moderation/queue/{id}/assign/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Body**: `{ "moderator_id": number }`
|
||||
|
||||
#### Unassign Queue Item
|
||||
- **POST** `/api/v1/moderation/queue/{id}/unassign/`
|
||||
- **Permissions**: Moderators and above
|
||||
|
||||
#### Complete Queue Item
|
||||
- **POST** `/api/v1/moderation/queue/{id}/complete/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Body**: CompleteQueueItemData
|
||||
|
||||
### Moderation Actions
|
||||
|
||||
#### List Actions
|
||||
- **GET** `/api/v1/moderation/actions/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Query Parameters**:
|
||||
- `action_type`: Filter by action type (WARNING, USER_SUSPENSION, USER_BAN, etc.)
|
||||
- `moderator`: Filter by moderator ID
|
||||
- `target_user`: Filter by target user ID
|
||||
- `is_active`: Boolean filter for active actions
|
||||
- `expired`: Boolean filter for expired actions
|
||||
- `expiring_soon`: Boolean filter for actions expiring within 24 hours
|
||||
- `has_related_report`: Boolean filter for actions with related reports
|
||||
|
||||
#### Create Action
|
||||
- **POST** `/api/v1/moderation/actions/`
|
||||
- **Permissions**: Moderators and above (with role-based restrictions)
|
||||
- **Body**: CreateModerationActionData
|
||||
|
||||
#### Get Active Actions
|
||||
- **GET** `/api/v1/moderation/actions/active/`
|
||||
- **Permissions**: Moderators and above
|
||||
|
||||
#### Get Expired Actions
|
||||
- **GET** `/api/v1/moderation/actions/expired/`
|
||||
- **Permissions**: Moderators and above
|
||||
|
||||
#### Deactivate Action
|
||||
- **POST** `/api/v1/moderation/actions/{id}/deactivate/`
|
||||
- **Permissions**: Moderators and above
|
||||
|
||||
### Bulk Operations
|
||||
|
||||
#### List Bulk Operations
|
||||
- **GET** `/api/v1/moderation/bulk-operations/`
|
||||
- **Permissions**: Admins and superusers only
|
||||
- **Query Parameters**:
|
||||
- `status`: Filter by status (PENDING, RUNNING, COMPLETED, FAILED, CANCELLED)
|
||||
- `operation_type`: Filter by operation type
|
||||
- `priority`: Filter by priority
|
||||
- `created_by`: Filter by creator ID
|
||||
- `can_cancel`: Boolean filter for cancellable operations
|
||||
- `has_failures`: Boolean filter for operations with failures
|
||||
- `in_progress`: Boolean filter for operations in progress
|
||||
|
||||
#### Create Bulk Operation
|
||||
- **POST** `/api/v1/moderation/bulk-operations/`
|
||||
- **Permissions**: Admins and superusers only
|
||||
- **Body**: CreateBulkOperationData
|
||||
|
||||
#### Get Running Operations
|
||||
- **GET** `/api/v1/moderation/bulk-operations/running/`
|
||||
- **Permissions**: Admins and superusers only
|
||||
|
||||
#### Cancel Operation
|
||||
- **POST** `/api/v1/moderation/bulk-operations/{id}/cancel/`
|
||||
- **Permissions**: Admins and superusers only
|
||||
|
||||
#### Retry Operation
|
||||
- **POST** `/api/v1/moderation/bulk-operations/{id}/retry/`
|
||||
- **Permissions**: Admins and superusers only
|
||||
|
||||
#### Get Operation Logs
|
||||
- **GET** `/api/v1/moderation/bulk-operations/{id}/logs/`
|
||||
- **Permissions**: Admins and superusers only
|
||||
|
||||
### User Moderation
|
||||
|
||||
#### Get User Moderation Profile
|
||||
- **GET** `/api/v1/moderation/users/{id}/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Returns**: UserModerationProfileData
|
||||
|
||||
#### Take Action Against User
|
||||
- **POST** `/api/v1/moderation/users/{id}/moderate/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Body**: CreateModerationActionData
|
||||
|
||||
#### Search Users
|
||||
- **GET** `/api/v1/moderation/users/search/`
|
||||
- **Permissions**: Moderators and above
|
||||
- **Query Parameters**:
|
||||
- `query`: Search in username and email
|
||||
- `role`: Filter by user role
|
||||
- `has_restrictions`: Boolean filter for users with active restrictions
|
||||
|
||||
#### User Moderation Statistics
|
||||
- **GET** `/api/v1/moderation/users/stats/`
|
||||
- **Permissions**: Moderators and above
|
||||
|
||||
## Parks API
|
||||
|
||||
### Parks Listing
|
||||
- **GET** `/api/v1/parks/`
|
||||
- **Query Parameters**:
|
||||
- `search`: Search in park names and descriptions
|
||||
- `country`: Filter by country code
|
||||
- `state`: Filter by state/province
|
||||
- `city`: Filter by city
|
||||
- `status`: Filter by operational status
|
||||
- `park_type`: Filter by park type
|
||||
- `has_rides`: Boolean filter for parks with rides
|
||||
- `ordering`: Order by fields (name, opened_date, ride_count, etc.)
|
||||
- `page`: Page number for pagination
|
||||
- `page_size`: Number of results per page
|
||||
|
||||
### Park Details
|
||||
- **GET** `/api/v1/parks/{slug}/`
|
||||
- **Returns**: Complete park information including rides, photos, and statistics
|
||||
|
||||
### Park Rides
|
||||
- **GET** `/api/v1/parks/{park_slug}/rides/`
|
||||
- **Query Parameters**: Similar filtering options as global rides endpoint
|
||||
|
||||
### Park Photos
|
||||
- **GET** `/api/v1/parks/{park_slug}/photos/`
|
||||
- **Query Parameters**:
|
||||
- `photo_type`: Filter by photo type (banner, card, gallery)
|
||||
- `ordering`: Order by upload date, likes, etc.
|
||||
|
||||
## Rides API
|
||||
|
||||
### Rides Listing
|
||||
- **GET** `/api/v1/rides/`
|
||||
- **Query Parameters**:
|
||||
- `search`: Search in ride names and descriptions
|
||||
- `park`: Filter by park slug
|
||||
- `manufacturer`: Filter by manufacturer slug
|
||||
- `ride_type`: Filter by ride type
|
||||
- `status`: Filter by operational status
|
||||
- `opened_after`: Filter rides opened after date
|
||||
- `opened_before`: Filter rides opened before date
|
||||
- `height_min`: Minimum height requirement
|
||||
- `height_max`: Maximum height requirement
|
||||
- `has_photos`: Boolean filter for rides with photos
|
||||
- `ordering`: Order by fields (name, opened_date, height, etc.)
|
||||
|
||||
### Ride Details
|
||||
- **GET** `/api/v1/rides/{park_slug}/{ride_slug}/`
|
||||
- **Returns**: Complete ride information including specifications, photos, and reviews
|
||||
|
||||
### Ride Photos
|
||||
- **GET** `/api/v1/rides/{park_slug}/{ride_slug}/photos/`
|
||||
|
||||
### Ride Reviews
|
||||
- **GET** `/api/v1/rides/{park_slug}/{ride_slug}/reviews/`
|
||||
- **POST** `/api/v1/rides/{park_slug}/{ride_slug}/reviews/`
|
||||
|
||||
## Manufacturers API
|
||||
|
||||
### Manufacturers Listing
|
||||
- **GET** `/api/v1/rides/manufacturers/`
|
||||
- **Query Parameters**:
|
||||
- `search`: Search in manufacturer names
|
||||
- `country`: Filter by country
|
||||
- `has_rides`: Boolean filter for manufacturers with rides
|
||||
- `ordering`: Order by name, ride_count, etc.
|
||||
|
||||
### Manufacturer Details
|
||||
- **GET** `/api/v1/rides/manufacturers/{slug}/`
|
||||
|
||||
### Manufacturer Rides
|
||||
- **GET** `/api/v1/rides/manufacturers/{slug}/rides/`
|
||||
|
||||
## Authentication API
|
||||
|
||||
### Login
|
||||
- **POST** `/api/v1/auth/login/`
|
||||
- **Body**: `{ "username": string, "password": string }`
|
||||
- **Returns**: JWT tokens and user data
|
||||
|
||||
### Signup
|
||||
- **POST** `/api/v1/auth/signup/`
|
||||
- **Body**: User registration data
|
||||
|
||||
### Logout
|
||||
- **POST** `/api/v1/auth/logout/`
|
||||
|
||||
### Current User
|
||||
- **GET** `/api/v1/auth/user/`
|
||||
- **Returns**: Current user profile data
|
||||
|
||||
### Password Reset
|
||||
- **POST** `/api/v1/auth/password/reset/`
|
||||
- **Body**: `{ "email": string }`
|
||||
|
||||
### Password Change
|
||||
- **POST** `/api/v1/auth/password/change/`
|
||||
- **Body**: `{ "old_password": string, "new_password": string }`
|
||||
|
||||
## Statistics API
|
||||
|
||||
### Global Statistics
|
||||
- **GET** `/api/v1/stats/`
|
||||
- **Returns**: Global platform statistics
|
||||
|
||||
### Trending Content
|
||||
Get trending parks and rides based on view counts, ratings, and recency.
|
||||
|
||||
**Endpoint:** `GET /trending/content/`
|
||||
|
||||
**Parameters:**
|
||||
- `limit` (optional): Number of trending items to return (default: 20, max: 100)
|
||||
- `timeframe` (optional): Timeframe for trending calculation - "day", "week", "month" (default: "week")
|
||||
|
||||
**Response Format:**
|
||||
```json
|
||||
{
|
||||
"trending_rides": [
|
||||
{
|
||||
"id": 137,
|
||||
"name": "Steel Vengeance",
|
||||
"park": "Cedar Point",
|
||||
"category": "ride",
|
||||
"rating": 4.8,
|
||||
"rank": 1,
|
||||
"views": 15234,
|
||||
"views_change": "+25%",
|
||||
"slug": "steel-vengeance",
|
||||
"date_opened": "2018-05-05",
|
||||
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
|
||||
"park_url": "https://thrillwiki.com/parks/cedar-point/",
|
||||
"card_image": "https://media.thrillwiki.com/rides/steel-vengeance-card.jpg"
|
||||
}
|
||||
],
|
||||
"trending_parks": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Cedar Point",
|
||||
"park": "Cedar Point",
|
||||
"category": "park",
|
||||
"rating": 4.6,
|
||||
"rank": 1,
|
||||
"views": 45678,
|
||||
"views_change": "+12%",
|
||||
"slug": "cedar-point",
|
||||
"date_opened": "1870-01-01",
|
||||
"url": "https://thrillwiki.com/parks/cedar-point/",
|
||||
"card_image": "https://media.thrillwiki.com/parks/cedar-point-card.jpg",
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "USA",
|
||||
"primary_company": "Cedar Fair"
|
||||
}
|
||||
],
|
||||
"latest_reviews": []
|
||||
}
|
||||
```
|
||||
|
||||
### New Content
|
||||
Get recently added parks and rides.
|
||||
|
||||
**Endpoint:** `GET /trending/new/`
|
||||
|
||||
**Parameters:**
|
||||
- `limit` (optional): Number of new items to return (default: 20, max: 100)
|
||||
- `days` (optional): Number of days to look back for new content (default: 30, max: 365)
|
||||
|
||||
**Response Format:**
|
||||
```json
|
||||
{
|
||||
"recently_added": [
|
||||
{
|
||||
"id": 137,
|
||||
"name": "Steel Vengeance",
|
||||
"park": "Cedar Point",
|
||||
"category": "ride",
|
||||
"date_added": "2018-05-05",
|
||||
"date_opened": "2018-05-05",
|
||||
"slug": "steel-vengeance",
|
||||
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
|
||||
"park_url": "https://thrillwiki.com/parks/cedar-point/",
|
||||
"card_image": "https://media.thrillwiki.com/rides/steel-vengeance-card.jpg"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Dollywood",
|
||||
"park": "Dollywood",
|
||||
"category": "park",
|
||||
"date_added": "2018-05-01",
|
||||
"date_opened": "1986-05-03",
|
||||
"slug": "dollywood",
|
||||
"url": "https://thrillwiki.com/parks/dollywood/",
|
||||
"card_image": "https://media.thrillwiki.com/parks/dollywood-card.jpg",
|
||||
"city": "Pigeon Forge",
|
||||
"state": "Tennessee",
|
||||
"country": "USA",
|
||||
"primary_company": "Dollywood Company"
|
||||
}
|
||||
],
|
||||
"newly_opened": [
|
||||
{
|
||||
"id": 136,
|
||||
"name": "Time Traveler",
|
||||
"park": "Silver Dollar City",
|
||||
"category": "ride",
|
||||
"date_added": "2018-04-28",
|
||||
"date_opened": "2018-04-28",
|
||||
"slug": "time-traveler",
|
||||
"url": "https://thrillwiki.com/parks/silver-dollar-city/rides/time-traveler/",
|
||||
"park_url": "https://thrillwiki.com/parks/silver-dollar-city/",
|
||||
"card_image": "https://media.thrillwiki.com/rides/time-traveler-card.jpg"
|
||||
}
|
||||
],
|
||||
"upcoming": []
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- **REMOVED:** `location` field from all trending and new content responses
|
||||
- **ADDED:** `park` field - shows the park name for both parks and rides
|
||||
- **ADDED:** `date_opened` field - shows when the park/ride originally opened
|
||||
|
||||
### Trigger Content Calculation (Admin Only)
|
||||
Manually trigger the calculation of trending and new content.
|
||||
|
||||
**Endpoint:** `POST /trending/calculate/`
|
||||
|
||||
**Authentication:** Admin access required
|
||||
|
||||
**Response Format:**
|
||||
```json
|
||||
{
|
||||
"message": "Trending content calculation completed",
|
||||
"trending_completed": true,
|
||||
"new_content_completed": true,
|
||||
"completion_time": "2025-08-28 16:41:42",
|
||||
"trending_output": "Successfully calculated 50 trending items for all",
|
||||
"new_content_output": "Successfully calculated 50 new items for all"
|
||||
}
|
||||
```
|
||||
|
||||
## Data Field Descriptions
|
||||
|
||||
### Common Fields
|
||||
- `id`: Unique identifier for the item
|
||||
- `name`: Display name of the park or ride
|
||||
- `park`: Name of the park (for rides, this is the parent park; for parks, this is the park itself)
|
||||
- `category`: Type of content ("park" or "ride")
|
||||
- `slug`: URL-friendly identifier
|
||||
- `date_opened`: ISO date string of when the park/ride originally opened (YYYY-MM-DD format)
|
||||
- `url`: Frontend URL for direct navigation to the item's detail page
|
||||
- `card_image`: URL to the card image for display in lists and grids (available for both parks and rides)
|
||||
|
||||
### Park-Specific Fields
|
||||
- `city`: City where the park is located (shortened format)
|
||||
- `state`: State/province where the park is located (shortened format)
|
||||
- `country`: Country where the park is located (shortened format)
|
||||
- `primary_company`: Name of the primary operating company for the park
|
||||
|
||||
### Ride-Specific Fields
|
||||
- `park_url`: Frontend URL for the ride's parent park
|
||||
|
||||
### Trending-Specific Fields
|
||||
- `rating`: Average user rating (0.0 to 10.0)
|
||||
- `rank`: Position in trending list (1-based)
|
||||
- `views`: Current view count
|
||||
- `views_change`: Percentage change in views (e.g., "+25%")
|
||||
|
||||
### New Content-Specific Fields
|
||||
- `date_added`: ISO date string of when the item was added to the database (YYYY-MM-DD format)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Content Categorization
|
||||
The API automatically categorizes new content based on dates:
|
||||
- **Recently Added**: Items added to the database in the last 30 days
|
||||
- **Newly Opened**: Items that opened in the last year
|
||||
- **Upcoming**: Future openings (currently empty, reserved for future use)
|
||||
|
||||
### Caching
|
||||
- Trending content is cached for 24 hours
|
||||
- New content is cached for 30 minutes
|
||||
- Use the admin trigger endpoint to force cache refresh
|
||||
|
||||
### Error Handling
|
||||
All endpoints return standard HTTP status codes:
|
||||
- `200`: Success
|
||||
- `400`: Bad request (invalid parameters)
|
||||
- `403`: Forbidden (admin endpoints only)
|
||||
- `500`: Internal server error
|
||||
|
||||
### Rate Limiting
|
||||
No rate limiting is currently implemented, but it may be added in the future.
|
||||
|
||||
## Migration from Previous API Format
|
||||
|
||||
If you were previously using the API with `location` fields, update your frontend code:
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
const ride = {
|
||||
name: "Steel Vengeance",
|
||||
location: "Cedar Point", // OLD FIELD
|
||||
category: "ride"
|
||||
};
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
const ride = {
|
||||
name: "Steel Vengeance",
|
||||
park: "Cedar Point", // NEW FIELD
|
||||
category: "ride",
|
||||
date_opened: "2018-05-05" // NEW FIELD
|
||||
};
|
||||
```
|
||||
|
||||
## Backend Architecture Changes
|
||||
|
||||
The trending system has been migrated from Celery-based async processing to Django management commands for better reliability and simpler deployment:
|
||||
|
||||
### Management Commands
|
||||
- `python manage.py calculate_trending` - Calculate trending content
|
||||
- `python manage.py calculate_new_content` - Calculate new content
|
||||
|
||||
### Direct Calculation
|
||||
The API now uses direct calculation instead of async tasks, providing immediate results while maintaining performance through caching.
|
||||
|
||||
## URL Fields for Frontend Navigation
|
||||
|
||||
All API responses now include dynamically generated `url` fields that provide direct links to the frontend pages for each entity. These URLs are generated based on the configured `FRONTEND_DOMAIN` setting.
|
||||
|
||||
### URL Patterns
|
||||
- **Parks**: `https://domain.com/parks/{park-slug}/`
|
||||
- **Rides**: `https://domain.com/parks/{park-slug}/rides/{ride-slug}/`
|
||||
- **Ride Models**: `https://domain.com/rides/manufacturers/{manufacturer-slug}/{model-slug}/`
|
||||
- **Companies (Operators)**: `https://domain.com/parks/operators/{operator-slug}/`
|
||||
- **Companies (Property Owners)**: `https://domain.com/parks/owners/{owner-slug}/`
|
||||
- **Companies (Manufacturers)**: `https://domain.com/rides/manufacturers/{manufacturer-slug}/`
|
||||
- **Companies (Designers)**: `https://domain.com/rides/designers/{designer-slug}/`
|
||||
|
||||
### Domain Separation Rules
|
||||
**CRITICAL**: Company URLs follow strict domain separation:
|
||||
- **Parks Domain**: OPERATOR and PROPERTY_OWNER roles generate URLs under `/parks/`
|
||||
- **Rides Domain**: MANUFACTURER and DESIGNER roles generate URLs under `/rides/`
|
||||
- Companies with multiple roles use their primary role (first in the roles array) for URL generation
|
||||
- URLs are auto-generated when entities are saved and stored in the database
|
||||
|
||||
### Example Response with URL Fields
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"park": {
|
||||
"id": 1,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"url": "https://thrillwiki.com/parks/cedar-point/"
|
||||
},
|
||||
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Rocky Mountain Construction",
|
||||
"slug": "rocky-mountain-construction",
|
||||
"url": "https://thrillwiki.com/rides/manufacturers/rocky-mountain-construction/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Fetch Trending Content
|
||||
```javascript
|
||||
const response = await fetch('/api/v1/trending/content/?limit=10');
|
||||
const data = await response.json();
|
||||
|
||||
// Display trending rides with clickable links
|
||||
data.trending_rides.forEach(ride => {
|
||||
console.log(`${ride.name} at ${ride.park} - opened ${ride.date_opened}`);
|
||||
console.log(`Visit: ${ride.url}`);
|
||||
});
|
||||
```
|
||||
|
||||
### Fetch New Content
|
||||
```javascript
|
||||
const response = await fetch('/api/v1/trending/new/?limit=5&days=7');
|
||||
const data = await response.json();
|
||||
|
||||
// Display newly opened attractions
|
||||
data.newly_opened.forEach(item => {
|
||||
console.log(`${item.name} at ${item.park} - opened ${item.date_opened}`);
|
||||
});
|
||||
```
|
||||
|
||||
### Admin: Trigger Calculation
|
||||
```javascript
|
||||
const response = await fetch('/api/v1/trending/calculate/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer YOUR_ADMIN_TOKEN',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
console.log(result.message);
|
||||
|
||||
## Reviews Endpoints
|
||||
- **GET** `/api/v1/trending/`
|
||||
- **Query Parameters**:
|
||||
- `content_type`: Filter by content type (parks, rides, reviews)
|
||||
- `time_period`: Time period for trending (24h, 7d, 30d)
|
||||
|
||||
### Latest Reviews
|
||||
Get the latest reviews from both parks and rides across the platform.
|
||||
- **GET** `/api/v1/reviews/latest/`
|
||||
- **Query Parameters**:
|
||||
- `limit`: Number of reviews to return
|
||||
- `park`: Filter by park slug
|
||||
- `ride`: Filter by ride slug
|
||||
|
||||
**Endpoint:** `GET /reviews/latest/`
|
||||
## Error Handling
|
||||
|
||||
**Parameters:**
|
||||
- `limit` (optional): Number of reviews to return (default: 20, max: 100)
|
||||
All API endpoints return standardized error responses:
|
||||
|
||||
**Response Format:**
|
||||
```json
|
||||
{
|
||||
"count": 15,
|
||||
"results": [
|
||||
{
|
||||
"id": 42,
|
||||
"type": "ride",
|
||||
"title": "Amazing coaster experience!",
|
||||
"content_snippet": "This ride was absolutely incredible. The airtime was perfect and the inversions were smooth...",
|
||||
"rating": 9,
|
||||
"created_at": "2025-08-28T21:30:00Z",
|
||||
"user": {
|
||||
"username": "coaster_fan_2024",
|
||||
"display_name": "Coaster Fan",
|
||||
"avatar_url": "https://media.thrillwiki.com/avatars/user123.jpg"
|
||||
},
|
||||
"subject_name": "Steel Vengeance",
|
||||
"subject_slug": "steel-vengeance",
|
||||
"subject_url": "/parks/cedar-point/rides/steel-vengeance/",
|
||||
"park_name": "Cedar Point",
|
||||
"park_slug": "cedar-point",
|
||||
"park_url": "/parks/cedar-point/"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"type": "park",
|
||||
"title": "Great family park",
|
||||
"content_snippet": "Had a wonderful time with the family. The park was clean, staff was friendly, and there were rides for all ages...",
|
||||
"rating": 8,
|
||||
"created_at": "2025-08-28T20:15:00Z",
|
||||
"user": {
|
||||
"username": "family_fun",
|
||||
"display_name": "Family Fun",
|
||||
"avatar_url": "/static/images/default-avatar.png"
|
||||
},
|
||||
"subject_name": "Dollywood",
|
||||
"subject_slug": "dollywood",
|
||||
"subject_url": "/parks/dollywood/",
|
||||
"park_name": null,
|
||||
"park_slug": null,
|
||||
"park_url": null
|
||||
}
|
||||
]
|
||||
```typescript
|
||||
interface ApiError {
|
||||
status: "error";
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
request_user?: string;
|
||||
};
|
||||
data: null;
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions:**
|
||||
- `id`: Unique review identifier
|
||||
- `type`: Review type - "park" or "ride"
|
||||
- `title`: Review title/headline
|
||||
- `content_snippet`: Truncated review content (max 150 characters with smart word breaking)
|
||||
- `rating`: User rating from 1-10
|
||||
- `created_at`: ISO timestamp when review was created
|
||||
- `user`: User information object
|
||||
- `username`: User's unique username
|
||||
- `display_name`: User's display name (falls back to username if not set)
|
||||
- `avatar_url`: URL to user's avatar image (uses default if not set)
|
||||
- `subject_name`: Name of the reviewed item (park or ride)
|
||||
- `subject_slug`: URL slug of the reviewed item
|
||||
- `subject_url`: Frontend URL to the reviewed item's detail page
|
||||
- `park_name`: For ride reviews, the name of the parent park (null for park reviews)
|
||||
- `park_slug`: For ride reviews, the slug of the parent park (null for park reviews)
|
||||
- `park_url`: For ride reviews, the URL to the parent park (null for park reviews)
|
||||
Common error codes:
|
||||
- `NOT_AUTHENTICATED`: User not logged in
|
||||
- `PERMISSION_DENIED`: Insufficient permissions
|
||||
- `NOT_FOUND`: Resource not found
|
||||
- `VALIDATION_ERROR`: Invalid request data
|
||||
- `RATE_LIMITED`: Too many requests
|
||||
|
||||
**Authentication:** None required (public endpoint)
|
||||
## Pagination
|
||||
|
||||
**Example Usage:**
|
||||
```javascript
|
||||
// Fetch latest 10 reviews
|
||||
const response = await fetch('/api/v1/reviews/latest/?limit=10');
|
||||
const data = await response.json();
|
||||
List endpoints use cursor-based pagination:
|
||||
|
||||
// Display reviews
|
||||
data.results.forEach(review => {
|
||||
console.log(`${review.user.display_name} rated ${review.subject_name}: ${review.rating}/10`);
|
||||
console.log(`"${review.title}" - ${review.content_snippet}`);
|
||||
|
||||
if (review.type === 'ride') {
|
||||
console.log(`Ride at ${review.park_name}`);
|
||||
}
|
||||
});
|
||||
```typescript
|
||||
interface PaginatedResponse<T> {
|
||||
status: "success";
|
||||
data: {
|
||||
results: T[];
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
};
|
||||
error: null;
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Invalid limit parameter
|
||||
- `500 Internal Server Error`: Database or server error
|
||||
## Rate Limiting
|
||||
|
||||
**Notes:**
|
||||
- Reviews are filtered to only show published reviews (`is_published=True`)
|
||||
- Results are sorted by creation date (newest first)
|
||||
- Content snippets are intelligently truncated at word boundaries
|
||||
- Avatar URLs fall back to default avatar if user hasn't uploaded one
|
||||
- The endpoint combines reviews from both parks and rides into a single chronological feed
|
||||
API endpoints are rate limited based on user role:
|
||||
- Anonymous users: 100 requests/hour
|
||||
- Authenticated users: 1000 requests/hour
|
||||
- Moderators: 5000 requests/hour
|
||||
- Admins: 10000 requests/hour
|
||||
|
||||
## WebSocket Connections
|
||||
|
||||
Real-time updates are available for:
|
||||
- Moderation queue updates
|
||||
- New reports and actions
|
||||
- Bulk operation progress
|
||||
- Live statistics updates
|
||||
|
||||
Connect to: `ws://localhost:8000/ws/moderation/` (requires authentication)
|
||||
|
||||
729
docs/lib-api.ts
Normal file
729
docs/lib-api.ts
Normal file
@@ -0,0 +1,729 @@
|
||||
// ThrillWiki API Client for NextJS Frontend
|
||||
// Last updated: 2025-08-29
|
||||
// This file contains the complete API client implementation for ThrillWiki
|
||||
|
||||
import {
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
// Moderation types
|
||||
ModerationReport,
|
||||
CreateModerationReportData,
|
||||
UpdateModerationReportData,
|
||||
ModerationQueue,
|
||||
CompleteQueueItemData,
|
||||
ModerationAction,
|
||||
CreateModerationActionData,
|
||||
BulkOperation,
|
||||
CreateBulkOperationData,
|
||||
UserModerationProfile,
|
||||
ModerationStatsData,
|
||||
// Filter types
|
||||
ModerationReportFilters,
|
||||
ModerationQueueFilters,
|
||||
ModerationActionFilters,
|
||||
BulkOperationFilters,
|
||||
ParkFilters,
|
||||
RideFilters,
|
||||
SearchFilters,
|
||||
// Entity types
|
||||
Park,
|
||||
Ride,
|
||||
Manufacturer,
|
||||
RideModel,
|
||||
ParkPhoto,
|
||||
RidePhoto,
|
||||
RideReview,
|
||||
CreateRideReviewData,
|
||||
// Auth types
|
||||
LoginData,
|
||||
SignupData,
|
||||
AuthResponse,
|
||||
UserProfile,
|
||||
PasswordResetData,
|
||||
PasswordChangeData,
|
||||
// Stats types
|
||||
GlobalStats,
|
||||
TrendingContent,
|
||||
// Utility types
|
||||
ApiClientConfig,
|
||||
RequestConfig,
|
||||
} from '@/types/api';
|
||||
|
||||
// ============================================================================
|
||||
// API Client Configuration
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_CONFIG: ApiClientConfig = {
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1',
|
||||
timeout: 30000,
|
||||
retries: 3,
|
||||
retryDelay: 1000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Client Class
|
||||
// ============================================================================
|
||||
|
||||
class HttpClient {
|
||||
private config: ApiClientConfig;
|
||||
private authToken: string | null = null;
|
||||
|
||||
constructor(config: Partial<ApiClientConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
setAuthToken(token: string | null) {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
private getHeaders(customHeaders: Record<string, string> = {}): Record<string, string> {
|
||||
const headers = { ...this.config.headers, ...customHeaders };
|
||||
|
||||
if (this.authToken) {
|
||||
headers.Authorization = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async makeRequest<T>(config: RequestConfig): Promise<ApiResponse<T>> {
|
||||
const url = `${this.config.baseURL}${config.url}`;
|
||||
const headers = this.getHeaders(config.headers);
|
||||
|
||||
const requestConfig: RequestInit = {
|
||||
method: config.method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(config.timeout || this.config.timeout),
|
||||
};
|
||||
|
||||
if (config.data && ['POST', 'PUT', 'PATCH'].includes(config.method)) {
|
||||
requestConfig.body = JSON.stringify(config.data);
|
||||
}
|
||||
|
||||
// Add query parameters for GET requests
|
||||
const finalUrl = config.params && config.method === 'GET'
|
||||
? `${url}?${new URLSearchParams(config.params).toString()}`
|
||||
: url;
|
||||
|
||||
try {
|
||||
const response = await fetch(finalUrl, requestConfig);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'error',
|
||||
data: null,
|
||||
error: data.error || {
|
||||
code: 'HTTP_ERROR',
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
data: null,
|
||||
error: {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Network request failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(url: string, params?: Record<string, any>, headers?: Record<string, string>): Promise<ApiResponse<T>> {
|
||||
return this.makeRequest<T>({ method: 'GET', url, params, headers });
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: any, headers?: Record<string, string>): Promise<ApiResponse<T>> {
|
||||
return this.makeRequest<T>({ method: 'POST', url, data, headers });
|
||||
}
|
||||
|
||||
async put<T>(url: string, data?: any, headers?: Record<string, string>): Promise<ApiResponse<T>> {
|
||||
return this.makeRequest<T>({ method: 'PUT', url, data, headers });
|
||||
}
|
||||
|
||||
async patch<T>(url: string, data?: any, headers?: Record<string, string>): Promise<ApiResponse<T>> {
|
||||
return this.makeRequest<T>({ method: 'PATCH', url, data, headers });
|
||||
}
|
||||
|
||||
async delete<T>(url: string, headers?: Record<string, string>): Promise<ApiResponse<T>> {
|
||||
return this.makeRequest<T>({ method: 'DELETE', url, headers });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Client Class
|
||||
// ============================================================================
|
||||
|
||||
export class ThrillWikiApiClient {
|
||||
private http: HttpClient;
|
||||
|
||||
constructor(config?: Partial<ApiClientConfig>) {
|
||||
this.http = new HttpClient(config);
|
||||
}
|
||||
|
||||
setAuthToken(token: string | null) {
|
||||
this.http.setAuthToken(token);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authentication API
|
||||
// ============================================================================
|
||||
|
||||
auth = {
|
||||
login: async (data: LoginData): Promise<ApiResponse<AuthResponse>> => {
|
||||
return this.http.post<AuthResponse>('/auth/login/', data);
|
||||
},
|
||||
|
||||
signup: async (data: SignupData): Promise<ApiResponse<AuthResponse>> => {
|
||||
return this.http.post<AuthResponse>('/auth/signup/', data);
|
||||
},
|
||||
|
||||
logout: async (): Promise<ApiResponse<null>> => {
|
||||
return this.http.post<null>('/auth/logout/');
|
||||
},
|
||||
|
||||
getCurrentUser: async (): Promise<ApiResponse<UserProfile>> => {
|
||||
return this.http.get<UserProfile>('/auth/user/');
|
||||
},
|
||||
|
||||
resetPassword: async (data: PasswordResetData): Promise<ApiResponse<null>> => {
|
||||
return this.http.post<null>('/auth/password/reset/', data);
|
||||
},
|
||||
|
||||
changePassword: async (data: PasswordChangeData): Promise<ApiResponse<null>> => {
|
||||
return this.http.post<null>('/auth/password/change/', data);
|
||||
},
|
||||
|
||||
getAuthStatus: async (): Promise<ApiResponse<{ is_authenticated: boolean; user?: UserProfile }>> => {
|
||||
return this.http.get('/auth/status/');
|
||||
},
|
||||
|
||||
getSocialProviders: async (): Promise<ApiResponse<any>> => {
|
||||
return this.http.get('/auth/providers/');
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Moderation API
|
||||
// ============================================================================
|
||||
|
||||
moderation = {
|
||||
// Reports
|
||||
reports: {
|
||||
list: async (filters?: ModerationReportFilters): Promise<ApiResponse<PaginatedResponse<ModerationReport>>> => {
|
||||
return this.http.get<PaginatedResponse<ModerationReport>>('/moderation/reports/', filters);
|
||||
},
|
||||
|
||||
create: async (data: CreateModerationReportData): Promise<ApiResponse<ModerationReport>> => {
|
||||
return this.http.post<ModerationReport>('/moderation/reports/', data);
|
||||
},
|
||||
|
||||
get: async (id: number): Promise<ApiResponse<ModerationReport>> => {
|
||||
return this.http.get<ModerationReport>(`/moderation/reports/${id}/`);
|
||||
},
|
||||
|
||||
update: async (id: number, data: Partial<UpdateModerationReportData>): Promise<ApiResponse<ModerationReport>> => {
|
||||
return this.http.patch<ModerationReport>(`/moderation/reports/${id}/`, data);
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<ApiResponse<null>> => {
|
||||
return this.http.delete<null>(`/moderation/reports/${id}/`);
|
||||
},
|
||||
|
||||
assign: async (id: number, moderatorId: number): Promise<ApiResponse<ModerationReport>> => {
|
||||
return this.http.post<ModerationReport>(`/moderation/reports/${id}/assign/`, { moderator_id: moderatorId });
|
||||
},
|
||||
|
||||
resolve: async (id: number, resolutionAction: string, resolutionNotes?: string): Promise<ApiResponse<ModerationReport>> => {
|
||||
return this.http.post<ModerationReport>(`/moderation/reports/${id}/resolve/`, {
|
||||
resolution_action: resolutionAction,
|
||||
resolution_notes: resolutionNotes || '',
|
||||
});
|
||||
},
|
||||
|
||||
getStats: async (): Promise<ApiResponse<ModerationStatsData>> => {
|
||||
return this.http.get<ModerationStatsData>('/moderation/reports/stats/');
|
||||
},
|
||||
},
|
||||
|
||||
// Queue
|
||||
queue: {
|
||||
list: async (filters?: ModerationQueueFilters): Promise<ApiResponse<PaginatedResponse<ModerationQueue>>> => {
|
||||
return this.http.get<PaginatedResponse<ModerationQueue>>('/moderation/queue/', filters);
|
||||
},
|
||||
|
||||
create: async (data: Partial<ModerationQueue>): Promise<ApiResponse<ModerationQueue>> => {
|
||||
return this.http.post<ModerationQueue>('/moderation/queue/', data);
|
||||
},
|
||||
|
||||
get: async (id: number): Promise<ApiResponse<ModerationQueue>> => {
|
||||
return this.http.get<ModerationQueue>(`/moderation/queue/${id}/`);
|
||||
},
|
||||
|
||||
update: async (id: number, data: Partial<ModerationQueue>): Promise<ApiResponse<ModerationQueue>> => {
|
||||
return this.http.patch<ModerationQueue>(`/moderation/queue/${id}/`, data);
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<ApiResponse<null>> => {
|
||||
return this.http.delete<null>(`/moderation/queue/${id}/`);
|
||||
},
|
||||
|
||||
assign: async (id: number, moderatorId: number): Promise<ApiResponse<ModerationQueue>> => {
|
||||
return this.http.post<ModerationQueue>(`/moderation/queue/${id}/assign/`, { moderator_id: moderatorId });
|
||||
},
|
||||
|
||||
unassign: async (id: number): Promise<ApiResponse<ModerationQueue>> => {
|
||||
return this.http.post<ModerationQueue>(`/moderation/queue/${id}/unassign/`);
|
||||
},
|
||||
|
||||
complete: async (id: number, data: CompleteQueueItemData): Promise<ApiResponse<ModerationQueue>> => {
|
||||
return this.http.post<ModerationQueue>(`/moderation/queue/${id}/complete/`, data);
|
||||
},
|
||||
|
||||
getMyQueue: async (): Promise<ApiResponse<PaginatedResponse<ModerationQueue>>> => {
|
||||
return this.http.get<PaginatedResponse<ModerationQueue>>('/moderation/queue/my_queue/');
|
||||
},
|
||||
},
|
||||
|
||||
// Actions
|
||||
actions: {
|
||||
list: async (filters?: ModerationActionFilters): Promise<ApiResponse<PaginatedResponse<ModerationAction>>> => {
|
||||
return this.http.get<PaginatedResponse<ModerationAction>>('/moderation/actions/', filters);
|
||||
},
|
||||
|
||||
create: async (data: CreateModerationActionData): Promise<ApiResponse<ModerationAction>> => {
|
||||
return this.http.post<ModerationAction>('/moderation/actions/', data);
|
||||
},
|
||||
|
||||
get: async (id: number): Promise<ApiResponse<ModerationAction>> => {
|
||||
return this.http.get<ModerationAction>(`/moderation/actions/${id}/`);
|
||||
},
|
||||
|
||||
update: async (id: number, data: Partial<ModerationAction>): Promise<ApiResponse<ModerationAction>> => {
|
||||
return this.http.patch<ModerationAction>(`/moderation/actions/${id}/`, data);
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<ApiResponse<null>> => {
|
||||
return this.http.delete<null>(`/moderation/actions/${id}/`);
|
||||
},
|
||||
|
||||
deactivate: async (id: number): Promise<ApiResponse<ModerationAction>> => {
|
||||
return this.http.post<ModerationAction>(`/moderation/actions/${id}/deactivate/`);
|
||||
},
|
||||
|
||||
getActive: async (): Promise<ApiResponse<PaginatedResponse<ModerationAction>>> => {
|
||||
return this.http.get<PaginatedResponse<ModerationAction>>('/moderation/actions/active/');
|
||||
},
|
||||
|
||||
getExpired: async (): Promise<ApiResponse<PaginatedResponse<ModerationAction>>> => {
|
||||
return this.http.get<PaginatedResponse<ModerationAction>>('/moderation/actions/expired/');
|
||||
},
|
||||
},
|
||||
|
||||
// Bulk Operations
|
||||
bulkOperations: {
|
||||
list: async (filters?: BulkOperationFilters): Promise<ApiResponse<PaginatedResponse<BulkOperation>>> => {
|
||||
return this.http.get<PaginatedResponse<BulkOperation>>('/moderation/bulk-operations/', filters);
|
||||
},
|
||||
|
||||
create: async (data: CreateBulkOperationData): Promise<ApiResponse<BulkOperation>> => {
|
||||
return this.http.post<BulkOperation>('/moderation/bulk-operations/', data);
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<ApiResponse<BulkOperation>> => {
|
||||
return this.http.get<BulkOperation>(`/moderation/bulk-operations/${id}/`);
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<BulkOperation>): Promise<ApiResponse<BulkOperation>> => {
|
||||
return this.http.patch<BulkOperation>(`/moderation/bulk-operations/${id}/`, data);
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<ApiResponse<null>> => {
|
||||
return this.http.delete<null>(`/moderation/bulk-operations/${id}/`);
|
||||
},
|
||||
|
||||
cancel: async (id: string): Promise<ApiResponse<BulkOperation>> => {
|
||||
return this.http.post<BulkOperation>(`/moderation/bulk-operations/${id}/cancel/`);
|
||||
},
|
||||
|
||||
retry: async (id: string): Promise<ApiResponse<BulkOperation>> => {
|
||||
return this.http.post<BulkOperation>(`/moderation/bulk-operations/${id}/retry/`);
|
||||
},
|
||||
|
||||
getLogs: async (id: string): Promise<ApiResponse<{ logs: any[]; count: number }>> => {
|
||||
return this.http.get(`/moderation/bulk-operations/${id}/logs/`);
|
||||
},
|
||||
|
||||
getRunning: async (): Promise<ApiResponse<PaginatedResponse<BulkOperation>>> => {
|
||||
return this.http.get<PaginatedResponse<BulkOperation>>('/moderation/bulk-operations/running/');
|
||||
},
|
||||
},
|
||||
|
||||
// User Moderation
|
||||
users: {
|
||||
get: async (id: number): Promise<ApiResponse<UserModerationProfile>> => {
|
||||
return this.http.get<UserModerationProfile>(`/moderation/users/${id}/`);
|
||||
},
|
||||
|
||||
moderate: async (id: number, data: CreateModerationActionData): Promise<ApiResponse<ModerationAction>> => {
|
||||
return this.http.post<ModerationAction>(`/moderation/users/${id}/moderate/`, data);
|
||||
},
|
||||
|
||||
search: async (params: { query?: string; role?: string; has_restrictions?: boolean }): Promise<ApiResponse<PaginatedResponse<any>>> => {
|
||||
return this.http.get<PaginatedResponse<any>>('/moderation/users/search/', params);
|
||||
},
|
||||
|
||||
getStats: async (): Promise<ApiResponse<{ total_actions: number; active_actions: number; expired_actions: number }>> => {
|
||||
return this.http.get('/moderation/users/stats/');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Parks API
|
||||
// ============================================================================
|
||||
|
||||
parks = {
|
||||
list: async (filters?: ParkFilters): Promise<ApiResponse<PaginatedResponse<Park>>> => {
|
||||
return this.http.get<PaginatedResponse<Park>>('/parks/', filters);
|
||||
},
|
||||
|
||||
get: async (slug: string): Promise<ApiResponse<Park>> => {
|
||||
return this.http.get<Park>(`/parks/${slug}/`);
|
||||
},
|
||||
|
||||
getRides: async (parkSlug: string, filters?: RideFilters): Promise<ApiResponse<PaginatedResponse<Ride>>> => {
|
||||
return this.http.get<PaginatedResponse<Ride>>(`/parks/${parkSlug}/rides/`, filters);
|
||||
},
|
||||
|
||||
getPhotos: async (parkSlug: string, filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<ParkPhoto>>> => {
|
||||
return this.http.get<PaginatedResponse<ParkPhoto>>(`/parks/${parkSlug}/photos/`, filters);
|
||||
},
|
||||
|
||||
// Park operators and owners
|
||||
operators: {
|
||||
list: async (filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<any>>> => {
|
||||
return this.http.get<PaginatedResponse<any>>('/parks/operators/', filters);
|
||||
},
|
||||
|
||||
get: async (slug: string): Promise<ApiResponse<any>> => {
|
||||
return this.http.get(`/parks/operators/${slug}/`);
|
||||
},
|
||||
},
|
||||
|
||||
owners: {
|
||||
list: async (filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<any>>> => {
|
||||
return this.http.get<PaginatedResponse<any>>('/parks/owners/', filters);
|
||||
},
|
||||
|
||||
get: async (slug: string): Promise<ApiResponse<any>> => {
|
||||
return this.http.get(`/parks/owners/${slug}/`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Rides API
|
||||
// ============================================================================
|
||||
|
||||
rides = {
|
||||
list: async (filters?: RideFilters): Promise<ApiResponse<PaginatedResponse<Ride>>> => {
|
||||
return this.http.get<PaginatedResponse<Ride>>('/rides/', filters);
|
||||
},
|
||||
|
||||
get: async (parkSlug: string, rideSlug: string): Promise<ApiResponse<Ride>> => {
|
||||
return this.http.get<Ride>(`/rides/${parkSlug}/${rideSlug}/`);
|
||||
},
|
||||
|
||||
getPhotos: async (parkSlug: string, rideSlug: string, filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<RidePhoto>>> => {
|
||||
return this.http.get<PaginatedResponse<RidePhoto>>(`/rides/${parkSlug}/${rideSlug}/photos/`, filters);
|
||||
},
|
||||
|
||||
getReviews: async (parkSlug: string, rideSlug: string, filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<RideReview>>> => {
|
||||
return this.http.get<PaginatedResponse<RideReview>>(`/rides/${parkSlug}/${rideSlug}/reviews/`, filters);
|
||||
},
|
||||
|
||||
createReview: async (parkSlug: string, rideSlug: string, data: CreateRideReviewData): Promise<ApiResponse<RideReview>> => {
|
||||
return this.http.post<RideReview>(`/rides/${parkSlug}/${rideSlug}/reviews/`, data);
|
||||
},
|
||||
|
||||
// Manufacturers
|
||||
manufacturers: {
|
||||
list: async (filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<Manufacturer>>> => {
|
||||
return this.http.get<PaginatedResponse<Manufacturer>>('/rides/manufacturers/', filters);
|
||||
},
|
||||
|
||||
get: async (slug: string): Promise<ApiResponse<Manufacturer>> => {
|
||||
return this.http.get<Manufacturer>(`/rides/manufacturers/${slug}/`);
|
||||
},
|
||||
|
||||
getRides: async (slug: string, filters?: RideFilters): Promise<ApiResponse<PaginatedResponse<Ride>>> => {
|
||||
return this.http.get<PaginatedResponse<Ride>>(`/rides/manufacturers/${slug}/rides/`, filters);
|
||||
},
|
||||
|
||||
getModels: async (slug: string, filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<RideModel>>> => {
|
||||
return this.http.get<PaginatedResponse<RideModel>>(`/rides/manufacturers/${slug}/models/`, filters);
|
||||
},
|
||||
},
|
||||
|
||||
// Designers
|
||||
designers: {
|
||||
list: async (filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<any>>> => {
|
||||
return this.http.get<PaginatedResponse<any>>('/rides/designers/', filters);
|
||||
},
|
||||
|
||||
get: async (slug: string): Promise<ApiResponse<any>> => {
|
||||
return this.http.get(`/rides/designers/${slug}/`);
|
||||
},
|
||||
},
|
||||
|
||||
// Ride Models
|
||||
models: {
|
||||
list: async (filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<RideModel>>> => {
|
||||
return this.http.get<PaginatedResponse<RideModel>>('/rides/models/', filters);
|
||||
},
|
||||
|
||||
get: async (manufacturerSlug: string, modelSlug: string): Promise<ApiResponse<RideModel>> => {
|
||||
return this.http.get<RideModel>(`/rides/models/${manufacturerSlug}/${modelSlug}/`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Statistics API
|
||||
// ============================================================================
|
||||
|
||||
stats = {
|
||||
getGlobal: async (): Promise<ApiResponse<GlobalStats>> => {
|
||||
return this.http.get<GlobalStats>('/stats/');
|
||||
},
|
||||
|
||||
recalculate: async (): Promise<ApiResponse<null>> => {
|
||||
return this.http.post<null>('/stats/recalculate/');
|
||||
},
|
||||
|
||||
getTrending: async (params?: { content_type?: string; time_period?: string }): Promise<ApiResponse<TrendingContent>> => {
|
||||
return this.http.get<TrendingContent>('/trending/', params);
|
||||
},
|
||||
|
||||
getNewContent: async (): Promise<ApiResponse<any>> => {
|
||||
return this.http.get('/new-content/');
|
||||
},
|
||||
|
||||
triggerTrendingCalculation: async (): Promise<ApiResponse<null>> => {
|
||||
return this.http.post<null>('/trending/calculate/');
|
||||
},
|
||||
|
||||
getLatestReviews: async (params?: { limit?: number; park?: string; ride?: string }): Promise<ApiResponse<PaginatedResponse<RideReview>>> => {
|
||||
return this.http.get<PaginatedResponse<RideReview>>('/reviews/latest/', params);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Rankings API
|
||||
// ============================================================================
|
||||
|
||||
rankings = {
|
||||
list: async (filters?: SearchFilters): Promise<ApiResponse<PaginatedResponse<any>>> => {
|
||||
return this.http.get<PaginatedResponse<any>>('/rankings/', filters);
|
||||
},
|
||||
|
||||
calculate: async (): Promise<ApiResponse<null>> => {
|
||||
return this.http.post<null>('/rankings/calculate/');
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Health Check API
|
||||
// ============================================================================
|
||||
|
||||
health = {
|
||||
check: async (): Promise<ApiResponse<any>> => {
|
||||
return this.http.get('/health/');
|
||||
},
|
||||
|
||||
simple: async (): Promise<ApiResponse<{ status: string }>> => {
|
||||
return this.http.get('/health/simple/');
|
||||
},
|
||||
|
||||
performance: async (): Promise<ApiResponse<any>> => {
|
||||
return this.http.get('/health/performance/');
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Accounts API
|
||||
// ============================================================================
|
||||
|
||||
accounts = {
|
||||
getProfile: async (username: string): Promise<ApiResponse<UserProfile>> => {
|
||||
return this.http.get<UserProfile>(`/accounts/users/${username}/`);
|
||||
},
|
||||
|
||||
updateProfile: async (data: Partial<UserProfile>): Promise<ApiResponse<UserProfile>> => {
|
||||
return this.http.patch<UserProfile>('/accounts/profile/', data);
|
||||
},
|
||||
|
||||
getSettings: async (): Promise<ApiResponse<any>> => {
|
||||
return this.http.get('/accounts/settings/');
|
||||
},
|
||||
|
||||
updateSettings: async (data: any): Promise<ApiResponse<any>> => {
|
||||
return this.http.patch('/accounts/settings/', data);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Maps API
|
||||
// ============================================================================
|
||||
|
||||
maps = {
|
||||
getParkLocations: async (params?: { bounds?: string; zoom?: number }): Promise<ApiResponse<any>> => {
|
||||
return this.http.get('/maps/park-locations/', params);
|
||||
},
|
||||
|
||||
getRideLocations: async (parkSlug: string, params?: { bounds?: string; zoom?: number }): Promise<ApiResponse<any>> => {
|
||||
return this.http.get(`/maps/parks/${parkSlug}/ride-locations/`, params);
|
||||
},
|
||||
|
||||
getUnifiedMap: async (params?: { bounds?: string; zoom?: number; include_parks?: boolean; include_rides?: boolean }): Promise<ApiResponse<any>> => {
|
||||
return this.http.get('/maps/unified/', params);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Email API
|
||||
// ============================================================================
|
||||
|
||||
email = {
|
||||
sendTestEmail: async (data: { to: string; subject: string; message: string }): Promise<ApiResponse<null>> => {
|
||||
return this.http.post<null>('/email/send-test/', data);
|
||||
},
|
||||
|
||||
getTemplates: async (): Promise<ApiResponse<any[]>> => {
|
||||
return this.http.get('/email/templates/');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Export and Utilities
|
||||
// ============================================================================
|
||||
|
||||
// Create default client instance
|
||||
export const apiClient = new ThrillWikiApiClient();
|
||||
|
||||
// Utility functions for common operations
|
||||
export const apiUtils = {
|
||||
// Set authentication token for all requests
|
||||
setAuthToken: (token: string | null) => {
|
||||
apiClient.setAuthToken(token);
|
||||
},
|
||||
|
||||
// Check if response is successful
|
||||
isSuccess: <T>(response: ApiResponse<T>): response is ApiResponse<T> & { status: 'success'; data: T } => {
|
||||
return response.status === 'success' && response.data !== null;
|
||||
},
|
||||
|
||||
// Check if response is an error
|
||||
isError: <T>(response: ApiResponse<T>): response is ApiResponse<T> & { status: 'error'; error: NonNullable<ApiResponse<T>['error']> } => {
|
||||
return response.status === 'error' && response.error !== null;
|
||||
},
|
||||
|
||||
// Extract data from successful response or throw error
|
||||
unwrap: <T>(response: ApiResponse<T>): T => {
|
||||
if (apiUtils.isSuccess(response)) {
|
||||
return response.data;
|
||||
}
|
||||
throw new Error(response.error?.message || 'API request failed');
|
||||
},
|
||||
|
||||
// Handle paginated responses
|
||||
extractPaginatedData: <T>(response: ApiResponse<PaginatedResponse<T>>): T[] => {
|
||||
if (apiUtils.isSuccess(response)) {
|
||||
return response.data.results;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
// Build query string from filters
|
||||
buildQueryString: (filters: Record<string, any>): string => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => params.append(key, String(v)));
|
||||
} else {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return params.toString();
|
||||
},
|
||||
|
||||
// Format error message for display
|
||||
formatError: (error: ApiResponse<any>['error']): string => {
|
||||
if (!error) return 'Unknown error occurred';
|
||||
|
||||
if (error.details && typeof error.details === 'object') {
|
||||
// Handle validation errors
|
||||
const fieldErrors = Object.entries(error.details)
|
||||
.map(([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : messages}`)
|
||||
.join('; ');
|
||||
|
||||
return fieldErrors || error.message;
|
||||
}
|
||||
|
||||
return error.message;
|
||||
},
|
||||
|
||||
// Check if user has required role
|
||||
hasRole: (user: UserProfile | null, requiredRole: UserProfile['role']): boolean => {
|
||||
if (!user) return false;
|
||||
|
||||
const roleHierarchy = ['USER', 'MODERATOR', 'ADMIN', 'SUPERUSER'];
|
||||
const userRoleIndex = roleHierarchy.indexOf(user.role);
|
||||
const requiredRoleIndex = roleHierarchy.indexOf(requiredRole);
|
||||
|
||||
return userRoleIndex >= requiredRoleIndex;
|
||||
},
|
||||
|
||||
// Check if user can moderate
|
||||
canModerate: (user: UserProfile | null): boolean => {
|
||||
return apiUtils.hasRole(user, 'MODERATOR');
|
||||
},
|
||||
|
||||
// Check if user is admin
|
||||
isAdmin: (user: UserProfile | null): boolean => {
|
||||
return apiUtils.hasRole(user, 'ADMIN');
|
||||
},
|
||||
};
|
||||
|
||||
// Export types for convenience
|
||||
export type {
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
ModerationReport,
|
||||
ModerationQueue,
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
UserModerationProfile,
|
||||
Park,
|
||||
Ride,
|
||||
Manufacturer,
|
||||
RideModel,
|
||||
UserProfile,
|
||||
GlobalStats,
|
||||
TrendingContent,
|
||||
} from './types-api';
|
||||
|
||||
export default apiClient;
|
||||
612
docs/types-api.ts
Normal file
612
docs/types-api.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
// ThrillWiki API Types for NextJS Frontend
|
||||
// Last updated: 2025-08-29
|
||||
// This file contains all TypeScript interfaces for the ThrillWiki API
|
||||
|
||||
// ============================================================================
|
||||
// Base Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
status: "success" | "error";
|
||||
data: T | null;
|
||||
error: ApiError | null;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
request_user?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
results: T[];
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
export interface UserBasic {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: "USER" | "MODERATOR" | "ADMIN" | "SUPERUSER";
|
||||
}
|
||||
|
||||
export interface ContentType {
|
||||
id: number;
|
||||
app_label: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Moderation System Types
|
||||
// ============================================================================
|
||||
|
||||
// Moderation Report Types
|
||||
export interface ModerationReport {
|
||||
id: number;
|
||||
report_type: "SPAM" | "HARASSMENT" | "INAPPROPRIATE_CONTENT" | "MISINFORMATION" | "COPYRIGHT" | "PRIVACY" | "HATE_SPEECH" | "VIOLENCE" | "OTHER";
|
||||
report_type_display: string;
|
||||
status: "PENDING" | "UNDER_REVIEW" | "RESOLVED" | "DISMISSED";
|
||||
status_display: string;
|
||||
priority: "LOW" | "MEDIUM" | "HIGH" | "URGENT";
|
||||
priority_display: string;
|
||||
reported_entity_type: string;
|
||||
reported_entity_id: number;
|
||||
reason: string;
|
||||
description: string;
|
||||
evidence_urls: string[];
|
||||
resolved_at: string | null;
|
||||
resolution_notes: string;
|
||||
resolution_action: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
reported_by: UserBasic;
|
||||
assigned_moderator: UserBasic | null;
|
||||
content_type: ContentType | null;
|
||||
is_overdue: boolean;
|
||||
time_since_created: string;
|
||||
}
|
||||
|
||||
export interface CreateModerationReportData {
|
||||
report_type: ModerationReport["report_type"];
|
||||
reported_entity_type: string;
|
||||
reported_entity_id: number;
|
||||
reason: string;
|
||||
description: string;
|
||||
evidence_urls?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateModerationReportData {
|
||||
status?: ModerationReport["status"];
|
||||
priority?: ModerationReport["priority"];
|
||||
assigned_moderator?: number;
|
||||
resolution_notes?: string;
|
||||
resolution_action?: string;
|
||||
}
|
||||
|
||||
// Moderation Queue Types
|
||||
export interface ModerationQueue {
|
||||
id: number;
|
||||
item_type: "CONTENT_REVIEW" | "USER_REVIEW" | "BULK_ACTION" | "POLICY_VIOLATION" | "APPEAL" | "OTHER";
|
||||
status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED";
|
||||
priority: "LOW" | "MEDIUM" | "HIGH" | "URGENT";
|
||||
title: string;
|
||||
description: string;
|
||||
entity_type: string;
|
||||
entity_id: number | null;
|
||||
entity_preview: Record<string, any>;
|
||||
flagged_by: UserBasic | null;
|
||||
assigned_at: string | null;
|
||||
estimated_review_time: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: string[];
|
||||
assigned_to: UserBasic | null;
|
||||
related_report: ModerationReport | null;
|
||||
content_type: ContentType | null;
|
||||
is_overdue: boolean;
|
||||
time_in_queue: number;
|
||||
estimated_completion: string;
|
||||
}
|
||||
|
||||
export interface CompleteQueueItemData {
|
||||
action: "NO_ACTION" | "CONTENT_REMOVED" | "CONTENT_EDITED" | "USER_WARNING" | "USER_SUSPENDED" | "USER_BANNED";
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Moderation Action Types
|
||||
export interface ModerationAction {
|
||||
id: number;
|
||||
action_type: "WARNING" | "USER_SUSPENSION" | "USER_BAN" | "CONTENT_REMOVAL" | "CONTENT_EDIT" | "CONTENT_RESTRICTION" | "ACCOUNT_RESTRICTION" | "OTHER";
|
||||
action_type_display: string;
|
||||
reason: string;
|
||||
details: string;
|
||||
duration_hours: number | null;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
moderator: UserBasic;
|
||||
target_user: UserBasic;
|
||||
related_report: ModerationReport | null;
|
||||
updated_at: string;
|
||||
is_expired: boolean;
|
||||
time_remaining: string | null;
|
||||
}
|
||||
|
||||
export interface CreateModerationActionData {
|
||||
action_type: ModerationAction["action_type"];
|
||||
reason: string;
|
||||
details: string;
|
||||
duration_hours?: number;
|
||||
target_user_id: number;
|
||||
related_report_id?: number;
|
||||
}
|
||||
|
||||
// Bulk Operation Types
|
||||
export interface BulkOperation {
|
||||
id: string;
|
||||
operation_type: "UPDATE_PARKS" | "UPDATE_RIDES" | "IMPORT_DATA" | "EXPORT_DATA" | "MODERATE_CONTENT" | "USER_ACTIONS" | "CLEANUP" | "OTHER";
|
||||
operation_type_display: string;
|
||||
status: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "CANCELLED";
|
||||
status_display: string;
|
||||
priority: "LOW" | "MEDIUM" | "HIGH" | "URGENT";
|
||||
parameters: Record<string, any>;
|
||||
results: Record<string, any>;
|
||||
total_items: number;
|
||||
processed_items: number;
|
||||
failed_items: number;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
estimated_duration_minutes: number | null;
|
||||
can_cancel: boolean;
|
||||
description: string;
|
||||
schedule_for: string | null;
|
||||
created_by: UserBasic;
|
||||
updated_at: string;
|
||||
progress_percentage: number;
|
||||
estimated_completion: string | null;
|
||||
}
|
||||
|
||||
export interface CreateBulkOperationData {
|
||||
operation_type: BulkOperation["operation_type"];
|
||||
priority?: BulkOperation["priority"];
|
||||
parameters: Record<string, any>;
|
||||
description: string;
|
||||
schedule_for?: string;
|
||||
estimated_duration_minutes?: number;
|
||||
}
|
||||
|
||||
// User Moderation Profile Types
|
||||
export interface UserModerationProfile {
|
||||
user: UserBasic;
|
||||
reports_made: number;
|
||||
reports_against: number;
|
||||
warnings_received: number;
|
||||
suspensions_received: number;
|
||||
active_restrictions: number;
|
||||
risk_level: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
||||
risk_factors: string[];
|
||||
recent_reports: ModerationReport[];
|
||||
recent_actions: ModerationAction[];
|
||||
account_status: string;
|
||||
last_violation_date: string | null;
|
||||
next_review_date: string | null;
|
||||
}
|
||||
|
||||
// Moderation Statistics Types
|
||||
export interface ModerationStatsData {
|
||||
total_reports: number;
|
||||
pending_reports: number;
|
||||
resolved_reports: number;
|
||||
overdue_reports: number;
|
||||
queue_size: number;
|
||||
assigned_items: number;
|
||||
unassigned_items: number;
|
||||
total_actions: number;
|
||||
active_actions: number;
|
||||
expired_actions: number;
|
||||
running_operations: number;
|
||||
completed_operations: number;
|
||||
failed_operations: number;
|
||||
average_resolution_time_hours: number;
|
||||
reports_by_priority: Record<string, number>;
|
||||
reports_by_type: Record<string, number>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parks API Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Park {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
country: string;
|
||||
state: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postal_code: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
opened_date: string | null;
|
||||
closed_date: string | null;
|
||||
status: "OPERATING" | "CLOSED_TEMP" | "CLOSED_PERM" | "UNDER_CONSTRUCTION";
|
||||
park_type: "THEME_PARK" | "AMUSEMENT_PARK" | "WATER_PARK" | "FAMILY_ENTERTAINMENT_CENTER" | "OTHER";
|
||||
timezone: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
ride_count: number;
|
||||
operating_ride_count: number;
|
||||
photo_count: number;
|
||||
banner_image: string | null;
|
||||
card_image: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ParkPhoto {
|
||||
id: number;
|
||||
image: string;
|
||||
caption: string;
|
||||
photo_type: "banner" | "card" | "gallery";
|
||||
uploaded_by: UserBasic;
|
||||
upload_date: string;
|
||||
is_approved: boolean;
|
||||
likes_count: number;
|
||||
views_count: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rides API Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Ride {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
park: Park;
|
||||
description: string;
|
||||
ride_type: string;
|
||||
manufacturer: Manufacturer | null;
|
||||
model: RideModel | null;
|
||||
opened_date: string | null;
|
||||
closed_date: string | null;
|
||||
status: "OPERATING" | "CLOSED_TEMP" | "SBNO" | "UNDER_CONSTRUCTION" | "REMOVED";
|
||||
height_requirement: number | null;
|
||||
max_height: number | null;
|
||||
duration_seconds: number | null;
|
||||
max_speed_mph: number | null;
|
||||
max_height_ft: number | null;
|
||||
length_ft: number | null;
|
||||
inversions: number | null;
|
||||
capacity_per_hour: number | null;
|
||||
photo_count: number;
|
||||
review_count: number;
|
||||
average_rating: number | null;
|
||||
banner_image: string | null;
|
||||
card_image: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Manufacturer {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
country: string;
|
||||
founded_year: number | null;
|
||||
website: string;
|
||||
ride_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RideModel {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
manufacturer: Manufacturer;
|
||||
description: string;
|
||||
ride_type: string;
|
||||
first_built_year: number | null;
|
||||
ride_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RidePhoto {
|
||||
id: number;
|
||||
image: string;
|
||||
caption: string;
|
||||
photo_type: "banner" | "card" | "gallery";
|
||||
uploaded_by: UserBasic;
|
||||
upload_date: string;
|
||||
is_approved: boolean;
|
||||
likes_count: number;
|
||||
views_count: number;
|
||||
}
|
||||
|
||||
export interface RideReview {
|
||||
id: number;
|
||||
ride: Ride;
|
||||
user: UserBasic;
|
||||
rating: number;
|
||||
title: string;
|
||||
content: string;
|
||||
visit_date: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
likes_count: number;
|
||||
is_liked: boolean;
|
||||
}
|
||||
|
||||
export interface CreateRideReviewData {
|
||||
rating: number;
|
||||
title: string;
|
||||
content: string;
|
||||
visit_date?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authentication Types
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SignupData {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
display_name: string;
|
||||
role: "USER" | "MODERATOR" | "ADMIN" | "SUPERUSER";
|
||||
date_joined: string;
|
||||
last_login: string | null;
|
||||
is_active: boolean;
|
||||
profile_image: string | null;
|
||||
bio: string;
|
||||
location: string;
|
||||
website: string;
|
||||
birth_date: string | null;
|
||||
privacy_settings: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface PasswordResetData {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordChangeData {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics Types
|
||||
// ============================================================================
|
||||
|
||||
export interface GlobalStats {
|
||||
total_parks: number;
|
||||
total_rides: number;
|
||||
total_reviews: number;
|
||||
total_photos: number;
|
||||
total_users: number;
|
||||
active_users_30d: number;
|
||||
new_content_7d: number;
|
||||
top_countries: Array<{
|
||||
country: string;
|
||||
park_count: number;
|
||||
ride_count: number;
|
||||
}>;
|
||||
recent_activity: Array<{
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TrendingContent {
|
||||
parks: Park[];
|
||||
rides: Ride[];
|
||||
reviews: RideReview[];
|
||||
time_period: "24h" | "7d" | "30d";
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search and Filter Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SearchFilters {
|
||||
search?: string;
|
||||
ordering?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface ParkFilters extends SearchFilters {
|
||||
country?: string;
|
||||
state?: string;
|
||||
city?: string;
|
||||
status?: Park["status"];
|
||||
park_type?: Park["park_type"];
|
||||
has_rides?: boolean;
|
||||
}
|
||||
|
||||
export interface RideFilters extends SearchFilters {
|
||||
park?: string;
|
||||
manufacturer?: string;
|
||||
ride_type?: string;
|
||||
status?: Ride["status"];
|
||||
opened_after?: string;
|
||||
opened_before?: string;
|
||||
height_min?: number;
|
||||
height_max?: number;
|
||||
has_photos?: boolean;
|
||||
}
|
||||
|
||||
export interface ModerationReportFilters extends SearchFilters {
|
||||
status?: ModerationReport["status"];
|
||||
priority?: ModerationReport["priority"];
|
||||
report_type?: ModerationReport["report_type"];
|
||||
reported_by?: number;
|
||||
assigned_moderator?: number;
|
||||
created_after?: string;
|
||||
created_before?: string;
|
||||
unassigned?: boolean;
|
||||
overdue?: boolean;
|
||||
has_resolution?: boolean;
|
||||
}
|
||||
|
||||
export interface ModerationQueueFilters extends SearchFilters {
|
||||
status?: ModerationQueue["status"];
|
||||
priority?: ModerationQueue["priority"];
|
||||
item_type?: ModerationQueue["item_type"];
|
||||
assigned_to?: number;
|
||||
unassigned?: boolean;
|
||||
has_related_report?: boolean;
|
||||
}
|
||||
|
||||
export interface ModerationActionFilters extends SearchFilters {
|
||||
action_type?: ModerationAction["action_type"];
|
||||
moderator?: number;
|
||||
target_user?: number;
|
||||
is_active?: boolean;
|
||||
expired?: boolean;
|
||||
expiring_soon?: boolean;
|
||||
has_related_report?: boolean;
|
||||
}
|
||||
|
||||
export interface BulkOperationFilters extends SearchFilters {
|
||||
status?: BulkOperation["status"];
|
||||
operation_type?: BulkOperation["operation_type"];
|
||||
priority?: BulkOperation["priority"];
|
||||
created_by?: number;
|
||||
can_cancel?: boolean;
|
||||
has_failures?: boolean;
|
||||
in_progress?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Types
|
||||
// ============================================================================
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ModerationUpdate extends WebSocketMessage {
|
||||
type: "moderation_update";
|
||||
data: {
|
||||
event_type: "report_created" | "report_assigned" | "report_resolved" | "queue_updated" | "action_taken";
|
||||
object_type: "report" | "queue_item" | "action";
|
||||
object_id: number;
|
||||
object_data: ModerationReport | ModerationQueue | ModerationAction;
|
||||
user: UserBasic;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BulkOperationUpdate extends WebSocketMessage {
|
||||
type: "bulk_operation_update";
|
||||
data: {
|
||||
operation_id: string;
|
||||
status: BulkOperation["status"];
|
||||
progress_percentage: number;
|
||||
processed_items: number;
|
||||
failed_items: number;
|
||||
estimated_completion: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Validation Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface FormErrors {
|
||||
[key: string]: string[] | string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Types
|
||||
// ============================================================================
|
||||
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
export interface SortOption {
|
||||
field: string;
|
||||
label: string;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface TabOption {
|
||||
key: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Client Configuration Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiClientConfig {
|
||||
baseURL: string;
|
||||
timeout: number;
|
||||
retries: number;
|
||||
retryDelay: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RequestConfig {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
url: string;
|
||||
data?: any;
|
||||
params?: Record<string, any>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface CacheConfig {
|
||||
enabled: boolean;
|
||||
ttl: number;
|
||||
maxSize: number;
|
||||
keyPrefix: string;
|
||||
}
|
||||
Reference in New Issue
Block a user