From 2e35f8c5d9745d2781cbf5ed6f59fba1f841b435 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:17:31 -0500 Subject: [PATCH] feat: Refactor rides app with unique constraints, mixins, and enhanced documentation - Added migration to convert unique_together constraints to UniqueConstraint for RideModel. - Introduced RideFormMixin for handling entity suggestions in ride forms. - Created comprehensive code standards documentation outlining formatting, docstring requirements, complexity guidelines, and testing requirements. - Established error handling guidelines with a structured exception hierarchy and best practices for API and view error handling. - Documented view pattern guidelines, emphasizing the use of CBVs, FBVs, and ViewSets with examples. - Implemented a benchmarking script for query performance analysis and optimization. - Developed security documentation detailing measures, configurations, and a security checklist. - Compiled a database optimization guide covering indexing strategies, query optimization patterns, and computed fields. --- .github/SECURITY.md | 83 + .../accounts/management/commands/reset_db.py | 77 +- .../migrations/0013_add_user_query_indexes.py | 40 + backend/apps/accounts/models.py | 15 +- backend/apps/accounts/services.py | 275 ++- .../accounts/tests/test_model_constraints.py | 101 + backend/apps/api/v1/accounts/views.py | 12 +- backend/apps/api/v1/core/views.py | 6 +- backend/apps/api/v1/maps/views.py | 18 +- backend/apps/api/v1/parks/views.py | 636 ++++--- backend/apps/api/v1/rides/views.py | 1666 +++++++++++------ backend/apps/api/v1/serializers/maps.py | 2 +- backend/apps/core/apps.py | 9 + backend/apps/core/checks.py | 372 ++++ backend/apps/core/exceptions.py | 8 + .../management/commands/security_audit.py | 240 +++ backend/apps/core/middleware/rate_limiting.py | 253 +++ .../apps/core/middleware/request_logging.py | 86 +- .../apps/core/middleware/security_headers.py | 196 ++ backend/apps/core/services/data_structures.py | 2 +- .../apps/core/services/location_adapters.py | 2 +- backend/apps/core/templatetags/safe_html.py | 275 +++ backend/apps/core/utils/error_handling.py | 161 ++ backend/apps/core/utils/file_scanner.py | 432 +++++ backend/apps/core/utils/html_sanitizer.py | 382 ++++ backend/apps/core/views/base.py | 79 + backend/apps/core/views/entity_search.py | 6 +- backend/apps/core/views/inline_edit.py | 1 - backend/apps/core/views/map_views.py | 8 +- backend/apps/core/views/modal_views.py | 1 - backend/apps/core/views/views.py | 192 +- .../commands/analyze_transitions.py | 7 +- backend/apps/moderation/selectors.py | 13 +- backend/apps/moderation/views.py | 304 +-- .../management/commands/create_sample_data.py | 7 +- .../management/commands/seed_sample_data.py | 7 +- backend/apps/parks/managers.py | 49 +- .../0023_add_company_roles_gin_index.py | 25 + .../migrations/0024_add_timezone_default.py | 28 + backend/apps/parks/models/parks.py | 2 + .../apps/parks/services/park_management.py | 288 ++- backend/apps/parks/signals.py | 67 +- .../parks/tests/test_query_optimization.py | 243 +++ backend/apps/parks/views.py | 235 +-- backend/apps/rides/managers.py | 15 +- ..._convert_unique_together_to_constraints.py | 40 + backend/apps/rides/mixins.py | 49 + backend/apps/rides/models/rides.py | 21 +- backend/apps/rides/services.py | 82 + backend/apps/rides/signals.py | 79 +- backend/apps/rides/views.py | 341 ++-- backend/config/django/base.py | 57 +- backend/config/django/production.py | 24 +- backend/config/settings/security.py | 124 +- backend/docs/code_standards.md | 261 +++ backend/docs/error_handling.md | 219 +++ backend/docs/view_guidelines.md | 158 ++ backend/scripts/benchmark_queries.py | 204 ++ backend/static/js/main.js | 66 + backend/templates/components/ui/card.html | 15 +- backend/templates/maps/nearby_locations.html | 18 +- .../maps/partials/location_card.html | 21 +- .../maps/partials/location_popup.html | 27 +- docs/SECURITY.md | 193 ++ docs/SECURITY_CHECKLIST.md | 155 ++ docs/database-optimization.md | 290 +++ shared/media/views.py | 62 +- templates/components/ui/button.html | 15 +- templates/components/ui/card.html | 13 +- templates/components/ui/dialog.html | 23 +- templates/components/ui/dropdown.html | 15 +- 71 files changed, 8036 insertions(+), 1462 deletions(-) create mode 100644 .github/SECURITY.md create mode 100644 backend/apps/accounts/migrations/0013_add_user_query_indexes.py create mode 100644 backend/apps/accounts/tests/test_model_constraints.py create mode 100644 backend/apps/core/checks.py create mode 100644 backend/apps/core/management/commands/security_audit.py create mode 100644 backend/apps/core/middleware/rate_limiting.py create mode 100644 backend/apps/core/middleware/security_headers.py create mode 100644 backend/apps/core/templatetags/safe_html.py create mode 100644 backend/apps/core/utils/error_handling.py create mode 100644 backend/apps/core/utils/file_scanner.py create mode 100644 backend/apps/core/utils/html_sanitizer.py create mode 100644 backend/apps/core/views/base.py create mode 100644 backend/apps/parks/migrations/0023_add_company_roles_gin_index.py create mode 100644 backend/apps/parks/migrations/0024_add_timezone_default.py create mode 100644 backend/apps/parks/tests/test_query_optimization.py create mode 100644 backend/apps/rides/migrations/0026_convert_unique_together_to_constraints.py create mode 100644 backend/apps/rides/mixins.py create mode 100644 backend/docs/code_standards.md create mode 100644 backend/docs/error_handling.md create mode 100644 backend/docs/view_guidelines.md create mode 100644 backend/scripts/benchmark_queries.py create mode 100644 docs/SECURITY.md create mode 100644 docs/SECURITY_CHECKLIST.md create mode 100644 docs/database-optimization.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..2c122c8f --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,83 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| latest | :white_check_mark: | +| < latest | :x: | + +Only the latest version of ThrillWiki receives security updates. + +## Reporting a Vulnerability + +We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly. + +### How to Report + +1. **Do not** create a public GitHub issue for security vulnerabilities +2. Email your report to the project maintainers +3. Include as much detail as possible: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Affected versions + - Any proof of concept (if available) + +### What to Expect + +- **Acknowledgment**: We will acknowledge receipt within 48 hours +- **Assessment**: We will assess the vulnerability and its impact +- **Updates**: We will keep you informed of our progress +- **Resolution**: We aim to resolve critical vulnerabilities within 7 days +- **Credit**: With your permission, we will credit you in our security advisories + +### Scope + +The following are in scope for security reports: + +- ThrillWiki web application vulnerabilities +- Authentication and authorization issues +- Data exposure vulnerabilities +- Injection vulnerabilities (SQL, XSS, etc.) +- CSRF vulnerabilities +- Server-side request forgery (SSRF) +- Insecure direct object references + +### Out of Scope + +The following are out of scope: + +- Denial of service attacks +- Social engineering attacks +- Physical security issues +- Issues in third-party applications or services +- Issues requiring physical access to a user's device +- Vulnerabilities in outdated versions + +## Security Measures + +ThrillWiki implements the following security measures: + +- HTTPS enforcement with HSTS +- Content Security Policy +- XSS protection with input sanitization +- CSRF protection +- SQL injection prevention via ORM +- Rate limiting on authentication endpoints +- Secure session management +- JWT token rotation and blacklisting + +For more details, see [docs/SECURITY.md](../docs/SECURITY.md). + +## Security Updates + +Security updates are released as soon as possible after a vulnerability is confirmed. We recommend: + +1. Keep your installation up to date +2. Subscribe to release notifications +3. Review security advisories + +## Contact + +For security-related inquiries, please contact the project maintainers. diff --git a/backend/apps/accounts/management/commands/reset_db.py b/backend/apps/accounts/management/commands/reset_db.py index ea2a105a..cd3657f0 100644 --- a/backend/apps/accounts/management/commands/reset_db.py +++ b/backend/apps/accounts/management/commands/reset_db.py @@ -1,7 +1,15 @@ +""" +Management command to reset the database and create an admin user. + +Security Note: This command uses a mix of raw SQL (for PostgreSQL-specific operations +like dropping all tables) and Django ORM (for creating users). The raw SQL operations +use quote_ident() for table/sequence names which is safe from SQL injection. + +WARNING: This command is destructive and should only be used in development. +""" + from django.core.management.base import BaseCommand from django.db import connection -from django.contrib.auth.hashers import make_password -import uuid class Command(BaseCommand): @@ -10,7 +18,8 @@ class Command(BaseCommand): def handle(self, *args, **options): self.stdout.write("Resetting database...") - # Drop all tables + # Drop all tables using PostgreSQL-specific operations + # Security: Using quote_ident() to safely quote table/sequence names with connection.cursor() as cursor: cursor.execute( """ @@ -21,7 +30,7 @@ class Command(BaseCommand): SELECT tablename FROM pg_tables WHERE schemaname = current_schema() ) LOOP - EXECUTE 'DROP TABLE IF EXISTS ' || \ + EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE'; END LOOP; END $$; @@ -38,7 +47,7 @@ class Command(BaseCommand): SELECT sequencename FROM pg_sequences WHERE schemaname = current_schema() ) LOOP - EXECUTE 'ALTER SEQUENCE ' || \ + EXECUTE 'ALTER SEQUENCE ' || quote_ident(r.sequencename) || ' RESTART WITH 1'; END LOOP; END $$; @@ -54,51 +63,25 @@ class Command(BaseCommand): self.stdout.write("Migrations applied.") - # Create superuser using raw SQL + # Create superuser using Django ORM (safer than raw SQL) try: - with connection.cursor() as cursor: - # Create user - user_id = str(uuid.uuid4())[:10] - cursor.execute( - """ - INSERT INTO accounts_user ( - username, password, email, is_superuser, is_staff, - is_active, date_joined, user_id, first_name, - last_name, role, is_banned, ban_reason, - theme_preference - ) VALUES ( - 'admin', %s, 'admin@thrillwiki.com', true, true, - true, NOW(), %s, '', '', 'SUPERUSER', false, '', - 'light' - ) RETURNING id; - """, - [make_password("admin"), user_id], - ) + from apps.accounts.models import User, UserProfile - result = cursor.fetchone() - if result is None: - raise Exception("Failed to create user - no ID returned") - user_db_id = result[0] + # Security: Using Django ORM instead of raw SQL for user creation + user = User.objects.create_superuser( + username='admin', + email='admin@thrillwiki.com', + password='admin', + role='SUPERUSER', + ) - # Create profile - profile_id = str(uuid.uuid4())[:10] - cursor.execute( - """ - INSERT INTO accounts_userprofile ( - profile_id, display_name, pronouns, bio, - twitter, instagram, youtube, discord, - coaster_credits, dark_ride_credits, - flat_ride_credits, water_ride_credits, - user_id, avatar - ) VALUES ( - %s, 'Admin', 'they/them', 'ThrillWiki Administrator', - '', '', '', '', - 0, 0, 0, 0, - %s, '' - ); - """, - [profile_id, user_db_id], - ) + # Create profile using ORM + UserProfile.objects.create( + user=user, + display_name='Admin', + pronouns='they/them', + bio='ThrillWiki Administrator', + ) self.stdout.write("Superuser created.") except Exception as e: diff --git a/backend/apps/accounts/migrations/0013_add_user_query_indexes.py b/backend/apps/accounts/migrations/0013_add_user_query_indexes.py new file mode 100644 index 00000000..749cb233 --- /dev/null +++ b/backend/apps/accounts/migrations/0013_add_user_query_indexes.py @@ -0,0 +1,40 @@ +""" +Add performance indexes and constraints to User model. + +This migration adds: +1. db_index=True to is_banned and role fields for faster filtering +2. Composite index on (is_banned, role) for common query patterns +3. CheckConstraint to ensure banned users have a ban_date set +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0012_alter_toplist_category_and_more'), + ] + + operations = [ + # Add db_index to is_banned field + migrations.AlterField( + model_name='user', + name='is_banned', + field=models.BooleanField(default=False, db_index=True), + ), + # Add composite index for common query patterns + migrations.AddIndex( + model_name='user', + index=models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'), + ), + # Add CheckConstraint for ban consistency + migrations.AddConstraint( + model_name='user', + constraint=models.CheckConstraint( + name='user_ban_consistency', + check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False), + violation_error_message='Banned users must have a ban_date set' + ), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index b31f569b..833d36c9 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -49,8 +49,9 @@ class User(AbstractUser): domain="accounts", max_length=10, default="USER", + db_index=True, ) - is_banned = models.BooleanField(default=False) + is_banned = models.BooleanField(default=False, db_index=True) ban_reason = models.TextField(blank=True) ban_date = models.DateTimeField(null=True, blank=True) pending_email = models.EmailField(blank=True, null=True) @@ -127,6 +128,18 @@ class User(AbstractUser): return profile.display_name return self.username + class Meta: + indexes = [ + models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'), + ] + constraints = [ + models.CheckConstraint( + name='user_ban_consistency', + check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False), + violation_error_message='Banned users must have a ban_date set' + ), + ] + def save(self, *args, **kwargs): if not self.user_id: self.user_id = generate_random_id(User, "user_id") diff --git a/backend/apps/accounts/services.py b/backend/apps/accounts/services.py index 5c5df120..f757be28 100644 --- a/backend/apps/accounts/services.py +++ b/backend/apps/accounts/services.py @@ -2,16 +2,281 @@ User management services for ThrillWiki. This module contains services for user account management including -user deletion while preserving submissions. +user deletion while preserving submissions, password management, +and email change functionality. + +Recent additions: +- AccountService: Handles password and email change operations +- UserDeletionService: Manages user deletion while preserving content """ -from typing import Optional -from django.db import transaction -from django.utils import timezone +import logging +import re +from typing import Any, Dict, Optional + from django.conf import settings +from django.contrib.auth import update_session_auth_hash from django.contrib.sites.models import Site +from django.contrib.sites.shortcuts import get_current_site +from django.db import transaction +from django.http import HttpRequest +from django.template.loader import render_to_string +from django.utils import timezone +from django.utils.crypto import get_random_string from django_forwardemail.services import EmailService -from .models import User, UserProfile, UserDeletionRequest + +from .models import EmailVerification, User, UserDeletionRequest, UserProfile + +logger = logging.getLogger(__name__) + + +class AccountService: + """Service for account management operations including password and email changes.""" + + @staticmethod + def validate_password(password: str) -> bool: + """ + Validate password meets requirements. + + Args: + password: The password to validate + + Returns: + True if password meets requirements, False otherwise + """ + return ( + len(password) >= 8 + and bool(re.search(r"[A-Z]", password)) + and bool(re.search(r"[a-z]", password)) + and bool(re.search(r"[0-9]", password)) + ) + + @staticmethod + def change_password( + *, + user: User, + old_password: str, + new_password: str, + request: HttpRequest, + ) -> Dict[str, Any]: + """ + Change user password with validation and notification. + + Validates the old password, checks new password requirements, + updates the password, and sends a confirmation email. + + Args: + user: The user whose password is being changed + old_password: Current password for verification + new_password: New password to set + request: HTTP request for session handling + + Returns: + Dictionary with success status, message, and optional redirect URL: + { + 'success': bool, + 'message': str, + 'redirect_url': Optional[str] + } + """ + # Verify old password + if not user.check_password(old_password): + logger.warning( + f"Password change failed: incorrect current password for user {user.id}" + ) + return { + 'success': False, + 'message': "Current password is incorrect", + 'redirect_url': None + } + + # Validate new password + if not AccountService.validate_password(new_password): + return { + 'success': False, + 'message': "Password must be at least 8 characters and contain uppercase, lowercase, and numbers", + 'redirect_url': None + } + + # Update password + user.set_password(new_password) + user.save() + + # Keep user logged in after password change + update_session_auth_hash(request, user) + + # Send confirmation email + AccountService._send_password_change_confirmation(request, user) + + logger.info(f"Password changed successfully for user {user.id}") + + return { + 'success': True, + 'message': "Password changed successfully. Please check your email for confirmation.", + 'redirect_url': None + } + + @staticmethod + def _send_password_change_confirmation(request: HttpRequest, user: User) -> None: + """Send password change confirmation email.""" + site = get_current_site(request) + context = { + "user": user, + "site_name": site.name, + } + + email_html = render_to_string( + "accounts/email/password_change_confirmation.html", context + ) + + try: + EmailService.send_email( + to=user.email, + subject="Password Changed Successfully", + text="Your password has been changed successfully.", + site=site, + html=email_html, + ) + except Exception as e: + logger.error(f"Failed to send password change confirmation email: {e}") + + @staticmethod + def initiate_email_change( + *, + user: User, + new_email: str, + request: HttpRequest, + ) -> Dict[str, Any]: + """ + Initiate email change with verification. + + Creates a verification token and sends a verification email + to the new email address. + + Args: + user: The user changing their email + new_email: The new email address + request: HTTP request for site context + + Returns: + Dictionary with success status and message: + { + 'success': bool, + 'message': str + } + """ + if not new_email: + return { + 'success': False, + 'message': "New email is required" + } + + # Check if email is already in use + if User.objects.filter(email=new_email).exclude(id=user.id).exists(): + return { + 'success': False, + 'message': "This email address is already in use" + } + + # Generate verification token + token = get_random_string(64) + + # Create or update email verification record + EmailVerification.objects.update_or_create( + user=user, + defaults={"token": token} + ) + + # Store pending email + user.pending_email = new_email + user.save() + + # Send verification email + AccountService._send_email_verification(request, user, new_email, token) + + logger.info(f"Email change initiated for user {user.id} to {new_email}") + + return { + 'success': True, + 'message': "Verification email sent to your new email address" + } + + @staticmethod + def _send_email_verification( + request: HttpRequest, + user: User, + new_email: str, + token: str + ) -> None: + """Send email verification for email change.""" + from django.urls import reverse + + site = get_current_site(request) + verification_url = reverse("verify_email", kwargs={"token": token}) + + context = { + "user": user, + "verification_url": verification_url, + "site_name": site.name, + } + + email_html = render_to_string("accounts/email/verify_email.html", context) + + try: + EmailService.send_email( + to=new_email, + subject="Verify your new email address", + text="Click the link to verify your new email address", + site=site, + html=email_html, + ) + except Exception as e: + logger.error(f"Failed to send email verification: {e}") + + @staticmethod + def verify_email_change(*, token: str) -> Dict[str, Any]: + """ + Verify email change token and update user email. + + Args: + token: The verification token + + Returns: + Dictionary with success status and message + """ + try: + verification = EmailVerification.objects.select_related("user").get( + token=token + ) + except EmailVerification.DoesNotExist: + return { + 'success': False, + 'message': "Invalid or expired verification token" + } + + user = verification.user + + if not user.pending_email: + return { + 'success': False, + 'message': "No pending email change found" + } + + # Update email + old_email = user.email + user.email = user.pending_email + user.pending_email = None + user.save() + + # Delete verification record + verification.delete() + + logger.info(f"Email changed for user {user.id} from {old_email} to {user.email}") + + return { + 'success': True, + 'message': "Email address updated successfully" + } class UserDeletionService: diff --git a/backend/apps/accounts/tests/test_model_constraints.py b/backend/apps/accounts/tests/test_model_constraints.py new file mode 100644 index 00000000..5654583c --- /dev/null +++ b/backend/apps/accounts/tests/test_model_constraints.py @@ -0,0 +1,101 @@ +""" +Tests for model constraints and validators in the accounts app. + +These tests verify that: +1. CheckConstraints raise appropriate errors +2. Validators work correctly +3. Business rules are enforced at the model level +""" + +from django.test import TestCase +from django.db import IntegrityError +from django.core.exceptions import ValidationError +from django.utils import timezone + +from apps.accounts.models import User + + +class UserConstraintTests(TestCase): + """Tests for User model constraints.""" + + def test_banned_user_without_ban_date_raises_error(self): + """Verify banned users must have a ban_date set.""" + user = User( + username="testuser", + email="test@example.com", + is_banned=True, + ban_date=None, # This should violate the constraint + ) + + # The constraint should be enforced at database level + with self.assertRaises(IntegrityError): + user.save() + + def test_banned_user_with_ban_date_saves_successfully(self): + """Verify banned users with ban_date save successfully.""" + user = User.objects.create_user( + username="testuser2", + email="test2@example.com", + password="testpass123", + is_banned=True, + ban_date=timezone.now(), + ) + self.assertIsNotNone(user.pk) + self.assertTrue(user.is_banned) + self.assertIsNotNone(user.ban_date) + + def test_non_banned_user_without_ban_date_saves_successfully(self): + """Verify non-banned users can be saved without ban_date.""" + user = User.objects.create_user( + username="testuser3", + email="test3@example.com", + password="testpass123", + is_banned=False, + ban_date=None, + ) + self.assertIsNotNone(user.pk) + self.assertFalse(user.is_banned) + + def test_user_id_is_auto_generated(self): + """Verify user_id is automatically generated on save.""" + user = User.objects.create_user( + username="testuser4", + email="test4@example.com", + password="testpass123", + ) + self.assertIsNotNone(user.user_id) + self.assertTrue(len(user.user_id) >= 4) + + def test_user_id_is_unique(self): + """Verify user_id is unique across users.""" + user1 = User.objects.create_user( + username="testuser5", + email="test5@example.com", + password="testpass123", + ) + user2 = User.objects.create_user( + username="testuser6", + email="test6@example.com", + password="testpass123", + ) + self.assertNotEqual(user1.user_id, user2.user_id) + + +class UserIndexTests(TestCase): + """Tests for User model indexes.""" + + def test_is_banned_field_is_indexed(self): + """Verify is_banned field has db_index=True.""" + field = User._meta.get_field('is_banned') + self.assertTrue(field.db_index) + + def test_role_field_is_indexed(self): + """Verify role field has db_index=True.""" + field = User._meta.get_field('role') + self.assertTrue(field.db_index) + + def test_composite_index_exists(self): + """Verify composite index on (is_banned, role) exists.""" + indexes = User._meta.indexes + index_names = [idx.name for idx in indexes] + self.assertIn('accounts_user_banned_role_idx', index_names) diff --git a/backend/apps/api/v1/accounts/views.py b/backend/apps/api/v1/accounts/views.py index 06bd9fe4..94f9817c 100644 --- a/backend/apps/api/v1/accounts/views.py +++ b/backend/apps/api/v1/accounts/views.py @@ -1302,11 +1302,15 @@ def get_user_statistics(request): user = request.user # Calculate user statistics + # TODO(THRILLWIKI-104): Implement full user statistics tracking + from apps.parks.models import ParkReview + from apps.rides.models import RideReview + data = { - "parks_visited": 0, # TODO: Implement based on reviews/check-ins - "rides_ridden": 0, # TODO: Implement based on reviews/check-ins - "reviews_written": 0, # TODO: Count user's reviews - "photos_uploaded": 0, # TODO: Count user's photos + "parks_visited": ParkReview.objects.filter(user=user).values("park").distinct().count(), + "rides_ridden": RideReview.objects.filter(user=user).values("ride").distinct().count(), + "reviews_written": ParkReview.objects.filter(user=user).count() + RideReview.objects.filter(user=user).count(), + "photos_uploaded": 0, # TODO(THRILLWIKI-105): Implement photo counting "top_lists_created": TopList.objects.filter(user=user).count(), "member_since": user.date_joined, "last_activity": user.last_login, diff --git a/backend/apps/api/v1/core/views.py b/backend/apps/api/v1/core/views.py index ddcd58f1..9109f812 100644 --- a/backend/apps/api/v1/core/views.py +++ b/backend/apps/api/v1/core/views.py @@ -7,8 +7,6 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny -from django.views.decorators.csrf import csrf_exempt -from django.utils.decorators import method_decorator from typing import Optional, List from drf_spectacular.utils import extend_schema @@ -260,12 +258,14 @@ class EntityNotFoundView(APIView): ) -@method_decorator(csrf_exempt, name="dispatch") class QuickEntitySuggestionView(APIView): """ Lightweight endpoint for quick entity suggestions (e.g., autocomplete). Migrated from apps.core.views.entity_search.QuickEntitySuggestionView + + Security Note: This endpoint only accepts GET requests, which are inherently + safe from CSRF attacks. No CSRF exemption is needed. """ permission_classes = [AllowAny] diff --git a/backend/apps/api/v1/maps/views.py b/backend/apps/api/v1/maps/views.py index be67d8d5..298c3947 100644 --- a/backend/apps/api/v1/maps/views.py +++ b/backend/apps/api/v1/maps/views.py @@ -12,7 +12,7 @@ from django.contrib.gis.geos import Polygon from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAdminUser from drf_spectacular.utils import ( extend_schema, extend_schema_view, @@ -306,7 +306,7 @@ class MapLocationsAPIView(APIView): return { "status": "success", "locations": locations, - "clusters": [], # TODO: Implement clustering + "clusters": [], # TODO(THRILLWIKI-106): Implement map clustering algorithm "bounds": self._calculate_bounds(locations), "total_count": len(locations), "clustered": params["cluster"], @@ -471,7 +471,7 @@ class MapLocationDetailAPIView(APIView): obj.opening_date.isoformat() if obj.opening_date else None ), }, - "nearby_locations": [], # TODO: Implement nearby locations + "nearby_locations": [], # TODO(THRILLWIKI-107): Implement nearby locations for parks } else: # ride data = { @@ -538,7 +538,7 @@ class MapLocationDetailAPIView(APIView): obj.manufacturer.name if obj.manufacturer else None ), }, - "nearby_locations": [], # TODO: Implement nearby locations + "nearby_locations": [], # TODO(THRILLWIKI-107): Implement nearby locations for rides } return Response( @@ -669,7 +669,7 @@ class MapSearchAPIView(APIView): else "" ), }, - "relevance_score": 1.0, # TODO: Implement relevance scoring + "relevance_score": 1.0, # TODO(THRILLWIKI-108): Implement relevance scoring for search } ) @@ -722,7 +722,7 @@ class MapSearchAPIView(APIView): else "" ), }, - "relevance_score": 1.0, # TODO: Implement relevance scoring + "relevance_score": 1.0, # TODO(THRILLWIKI-108): Implement relevance scoring for search } ) @@ -965,8 +965,8 @@ class MapStatsAPIView(APIView): "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 + "cache_hits": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking + "cache_misses": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking }, } ) @@ -996,7 +996,7 @@ class MapStatsAPIView(APIView): class MapCacheAPIView(APIView): """API endpoint for cache management (admin only).""" - permission_classes = [AllowAny] # TODO: Add admin permission check + permission_classes = [IsAdminUser] # Admin only def delete(self, request: HttpRequest) -> Response: """Clear all map cache (admin only).""" diff --git a/backend/apps/api/v1/parks/views.py b/backend/apps/api/v1/parks/views.py index 398377d8..d2ffc023 100644 --- a/backend/apps/api/v1/parks/views.py +++ b/backend/apps/api/v1/parks/views.py @@ -5,34 +5,44 @@ This module contains consolidated park photo viewset for the centralized API str Enhanced from rogue implementation to maintain full feature parity. """ -from .serializers import ( - ParkPhotoOutputSerializer, - ParkPhotoCreateInputSerializer, - ParkPhotoUpdateInputSerializer, - ParkPhotoListOutputSerializer, - ParkPhotoApprovalInputSerializer, - ParkPhotoStatsOutputSerializer, -) -from typing import Any, cast import logging +from typing import Any, cast +from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied -from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet -from apps.parks.models import ParkPhoto, Park +from apps.core.exceptions import ( + NotFoundError, + PermissionDeniedError, + ServiceError, + ValidationException, +) +from apps.core.utils.error_handling import ErrorHandler +from apps.parks.models import Park, ParkPhoto from apps.parks.services import ParkMediaService -from django.contrib.auth import get_user_model +from apps.parks.services.hybrid_loader import smart_park_loader -UserModel = get_user_model() +from .serializers import ( + HybridParkSerializer, + ParkPhotoApprovalInputSerializer, + ParkPhotoCreateInputSerializer, + ParkPhotoListOutputSerializer, + ParkPhotoOutputSerializer, + ParkPhotoStatsOutputSerializer, + ParkPhotoUpdateInputSerializer, +) logger = logging.getLogger(__name__) +UserModel = get_user_model() @extend_schema_view( @@ -113,7 +123,7 @@ class ParkPhotoViewSet(ModelViewSet): def get_permissions(self): """Set permissions based on action.""" - if self.action in ['list', 'retrieve', 'stats']: + if self.action in ["list", "retrieve", "stats"]: permission_classes = [AllowAny] else: permission_classes = [IsAuthenticated] @@ -166,8 +176,11 @@ class ParkPhotoViewSet(ModelViewSet): # Set the instance for the serializer response serializer.instance = photo - except Exception as e: - logger.error(f"Error creating park photo: {e}") + except (ValidationException, ValidationError) as e: + logger.warning(f"Validation error creating park photo: {e}") + raise ValidationError(str(e)) + except ServiceError as e: + logger.error(f"Service error creating park photo: {e}") raise ValidationError(f"Failed to create photo: {str(e)}") def perform_update(self, serializer): @@ -190,8 +203,11 @@ class ParkPhotoViewSet(ModelViewSet): # Remove is_primary from validated_data since service handles it if "is_primary" in serializer.validated_data: del serializer.validated_data["is_primary"] - except Exception as e: - logger.error(f"Error setting primary photo: {e}") + except (ValidationException, ValidationError) as e: + logger.warning(f"Validation error setting primary photo: {e}") + raise ValidationError(str(e)) + except ServiceError as e: + logger.error(f"Service error setting primary photo: {e}") raise ValidationError(f"Failed to set primary photo: {str(e)}") def perform_destroy(self, instance): @@ -205,25 +221,30 @@ class ParkPhotoViewSet(ModelViewSet): "You can only delete your own photos or be an admin." ) - try: - # Delete from Cloudflare first if image exists - if instance.image: - try: - from django_cloudflareimages_toolkit.services import CloudflareImagesService - service = CloudflareImagesService() - service.delete_image(instance.image) - logger.info( - f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}") - except Exception as e: - logger.error( - f"Failed to delete park photo from Cloudflare: {str(e)}") - # Continue with database deletion even if Cloudflare deletion fails + # Delete from Cloudflare first if image exists + if instance.image: + try: + from django_cloudflareimages_toolkit.services import ( + CloudflareImagesService, + ) + service = CloudflareImagesService() + service.delete_image(instance.image) + logger.info( + f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}" + ) + except ImportError: + logger.warning("CloudflareImagesService not available") + except ServiceError as e: + logger.error(f"Service error deleting from Cloudflare: {str(e)}") + # Continue with database deletion even if Cloudflare deletion fails + + try: ParkMediaService().delete_photo( instance.id, deleted_by=cast(UserModel, self.request.user) ) - except Exception as e: - logger.error(f"Error deleting park photo: {e}") + except ServiceError as e: + logger.error(f"Service error deleting park photo: {e}") raise ValidationError(f"Failed to delete photo: {str(e)}") @extend_schema( @@ -265,11 +286,18 @@ class ParkPhotoViewSet(ModelViewSet): status=status.HTTP_200_OK, ) - except Exception as e: - logger.error(f"Error setting primary photo: {e}") - return Response( - {"error": f"Failed to set primary photo: {str(e)}"}, - status=status.HTTP_400_BAD_REQUEST, + except (ValidationException, ValidationError) as e: + logger.warning(f"Validation error setting primary photo: {e}") + return ErrorHandler.handle_api_error( + e, + user_message="Failed to set primary photo", + status_code=status.HTTP_400_BAD_REQUEST, + ) + except ServiceError as e: + return ErrorHandler.handle_api_error( + e, + user_message="Failed to set primary photo", + status_code=status.HTTP_400_BAD_REQUEST, ) @extend_schema( @@ -319,11 +347,18 @@ class ParkPhotoViewSet(ModelViewSet): status=status.HTTP_200_OK, ) - except Exception as e: - logger.error(f"Error in bulk photo approval: {e}") - return Response( - {"error": f"Failed to update photos: {str(e)}"}, - status=status.HTTP_400_BAD_REQUEST, + except (ValidationException, ValidationError) as e: + logger.warning(f"Validation error in bulk photo approval: {e}") + return ErrorHandler.handle_api_error( + e, + user_message="Failed to update photos", + status_code=status.HTTP_400_BAD_REQUEST, + ) + except ServiceError as e: + return ErrorHandler.handle_api_error( + e, + user_message="Failed to update photos", + status_code=status.HTTP_400_BAD_REQUEST, ) @extend_schema( @@ -345,9 +380,10 @@ class ParkPhotoViewSet(ModelViewSet): try: park = Park.objects.get(pk=park_pk) except Park.DoesNotExist: - return Response( - {"error": "Park not found."}, - status=status.HTTP_404_NOT_FOUND, + return ErrorHandler.handle_api_error( + NotFoundError(f"Park with id {park_pk} not found"), + user_message="Park not found", + status_code=status.HTTP_404_NOT_FOUND, ) try: @@ -359,11 +395,11 @@ class ParkPhotoViewSet(ModelViewSet): return Response(serializer.data, status=status.HTTP_200_OK) - except Exception as e: - logger.error(f"Error getting park photo stats: {e}") - return Response( - {"error": f"Failed to get photo statistics: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + except ServiceError as e: + return ErrorHandler.handle_api_error( + e, + user_message="Failed to get photo statistics", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) # Legacy compatibility action using the legacy set_primary logic @@ -394,9 +430,19 @@ class ParkPhotoViewSet(ModelViewSet): park_id=photo.park_id, photo_id=photo.id ) return Response({"message": "Photo set as primary successfully."}) - except Exception as e: - logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True) - return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except (ValidationException, ValidationError) as e: + logger.warning(f"Validation error in set_primary_photo: {str(e)}") + return ErrorHandler.handle_api_error( + e, + user_message="Failed to set primary photo", + status_code=status.HTTP_400_BAD_REQUEST, + ) + except ServiceError as e: + return ErrorHandler.handle_api_error( + e, + user_message="Failed to set primary photo", + status_code=status.HTTP_400_BAD_REQUEST, + ) @extend_schema( summary="Save Cloudflare image as park photo", @@ -442,60 +488,55 @@ class ParkPhotoViewSet(ModelViewSet): from django.utils import timezone # Always fetch the latest image data from Cloudflare API + # Get image details from Cloudflare API + service = CloudflareImagesService() + image_data = service.get_image(cloudflare_image_id) + + if not image_data: + return ErrorHandler.handle_api_error( + NotFoundError("Image not found in Cloudflare"), + user_message="Image not found in Cloudflare", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + # Try to find existing CloudflareImage record by cloudflare_id + cloudflare_image = None try: - # Get image details from Cloudflare API - service = CloudflareImagesService() - image_data = service.get_image(cloudflare_image_id) + cloudflare_image = CloudflareImage.objects.get( + cloudflare_id=cloudflare_image_id + ) - if not image_data: - return Response( - {"error": "Image not found in Cloudflare"}, - status=status.HTTP_400_BAD_REQUEST, - ) + # Update existing record with latest data from Cloudflare + cloudflare_image.status = "uploaded" + cloudflare_image.uploaded_at = timezone.now() + cloudflare_image.metadata = image_data.get("meta", {}) + # Extract variants from nested result structure + cloudflare_image.variants = image_data.get("result", {}).get( + "variants", [] + ) + cloudflare_image.cloudflare_metadata = image_data + cloudflare_image.width = image_data.get("width") + cloudflare_image.height = image_data.get("height") + cloudflare_image.format = image_data.get("format", "") + cloudflare_image.save() - # Try to find existing CloudflareImage record by cloudflare_id - cloudflare_image = None - try: - cloudflare_image = CloudflareImage.objects.get( - cloudflare_id=cloudflare_image_id) - - # Update existing record with latest data from Cloudflare - cloudflare_image.status = 'uploaded' - cloudflare_image.uploaded_at = timezone.now() - cloudflare_image.metadata = image_data.get('meta', {}) + except CloudflareImage.DoesNotExist: + # Create new CloudflareImage record from API response + cloudflare_image = CloudflareImage.objects.create( + cloudflare_id=cloudflare_image_id, + user=request.user, + status="uploaded", + upload_url="", # Not needed for uploaded images + expires_at=timezone.now() + + timezone.timedelta(days=365), # Set far future expiry + uploaded_at=timezone.now(), + metadata=image_data.get("meta", {}), # Extract variants from nested result structure - cloudflare_image.variants = image_data.get( - 'result', {}).get('variants', []) - cloudflare_image.cloudflare_metadata = image_data - cloudflare_image.width = image_data.get('width') - cloudflare_image.height = image_data.get('height') - cloudflare_image.format = image_data.get('format', '') - cloudflare_image.save() - - except CloudflareImage.DoesNotExist: - # Create new CloudflareImage record from API response - cloudflare_image = CloudflareImage.objects.create( - cloudflare_id=cloudflare_image_id, - user=request.user, - status='uploaded', - upload_url='', # Not needed for uploaded images - expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry - uploaded_at=timezone.now(), - metadata=image_data.get('meta', {}), - # Extract variants from nested result structure - variants=image_data.get('result', {}).get('variants', []), - cloudflare_metadata=image_data, - width=image_data.get('width'), - height=image_data.get('height'), - format=image_data.get('format', ''), - ) - - except Exception as api_error: - logger.error( - f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True) - return Response( - {"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, - status=status.HTTP_400_BAD_REQUEST, + variants=image_data.get("result", {}).get("variants", []), + cloudflare_metadata=image_data, + width=image_data.get("width"), + height=image_data.get("height"), + format=image_data.get("format", ""), ) # Create the park photo with the CloudflareImage reference @@ -516,25 +557,33 @@ class ParkPhotoViewSet(ModelViewSet): ParkMediaService().set_primary_photo( park_id=park.id, photo_id=photo.id ) - except Exception as e: + except ServiceError as e: logger.error(f"Error setting primary photo: {e}") # Don't fail the entire operation, just log the error serializer = ParkPhotoOutputSerializer(photo, context={"request": request}) return Response(serializer.data, status=status.HTTP_201_CREATED) - except Exception as e: - logger.error(f"Error saving park photo: {e}") - return Response( - {"error": f"Failed to save photo: {str(e)}"}, - status=status.HTTP_400_BAD_REQUEST, + except ImportError: + logger.error("CloudflareImagesService not available") + return ErrorHandler.handle_api_error( + ServiceError("Cloudflare Images service not available"), + user_message="Image upload service not available", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + except (ValidationException, ValidationError) as e: + logger.warning(f"Validation error saving park photo: {e}") + return ErrorHandler.handle_api_error( + e, + user_message="Failed to save photo", + status_code=status.HTTP_400_BAD_REQUEST, + ) + except ServiceError as e: + return ErrorHandler.handle_api_error( + e, + user_message="Failed to save photo", + status_code=status.HTTP_400_BAD_REQUEST, ) - - -from rest_framework.views import APIView -from rest_framework.permissions import AllowAny -from .serializers import HybridParkSerializer -from apps.parks.services.hybrid_loader import smart_park_loader @extend_schema_view( @@ -542,23 +591,79 @@ from apps.parks.services.hybrid_loader import smart_park_loader summary="Get parks with hybrid filtering", description="Retrieve parks with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.", parameters=[ - OpenApiParameter("status", OpenApiTypes.STR, description="Filter by park status (comma-separated for multiple)"), - OpenApiParameter("park_type", OpenApiTypes.STR, description="Filter by park type (comma-separated for multiple)"), - OpenApiParameter("country", OpenApiTypes.STR, description="Filter by country (comma-separated for multiple)"), - OpenApiParameter("state", OpenApiTypes.STR, description="Filter by state (comma-separated for multiple)"), - OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"), - OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"), - OpenApiParameter("size_min", OpenApiTypes.NUMBER, description="Minimum park size in acres"), - OpenApiParameter("size_max", OpenApiTypes.NUMBER, description="Maximum park size in acres"), - OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"), - OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"), - OpenApiParameter("ride_count_min", OpenApiTypes.INT, description="Minimum ride count"), - OpenApiParameter("ride_count_max", OpenApiTypes.INT, description="Maximum ride count"), - OpenApiParameter("coaster_count_min", OpenApiTypes.INT, description="Minimum coaster count"), - OpenApiParameter("coaster_count_max", OpenApiTypes.INT, description="Maximum coaster count"), - OpenApiParameter("operator", OpenApiTypes.STR, description="Filter by operator slug (comma-separated for multiple)"), - OpenApiParameter("search", OpenApiTypes.STR, description="Search query for park names, descriptions, locations, and operators"), - OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"), + OpenApiParameter( + "status", + OpenApiTypes.STR, + description="Filter by park status (comma-separated for multiple)", + ), + OpenApiParameter( + "park_type", + OpenApiTypes.STR, + description="Filter by park type (comma-separated for multiple)", + ), + OpenApiParameter( + "country", + OpenApiTypes.STR, + description="Filter by country (comma-separated for multiple)", + ), + OpenApiParameter( + "state", + OpenApiTypes.STR, + description="Filter by state (comma-separated for multiple)", + ), + OpenApiParameter( + "opening_year_min", OpenApiTypes.INT, description="Minimum opening year" + ), + OpenApiParameter( + "opening_year_max", OpenApiTypes.INT, description="Maximum opening year" + ), + OpenApiParameter( + "size_min", + OpenApiTypes.NUMBER, + description="Minimum park size in acres", + ), + OpenApiParameter( + "size_max", + OpenApiTypes.NUMBER, + description="Maximum park size in acres", + ), + OpenApiParameter( + "rating_min", OpenApiTypes.NUMBER, description="Minimum average rating" + ), + OpenApiParameter( + "rating_max", OpenApiTypes.NUMBER, description="Maximum average rating" + ), + OpenApiParameter( + "ride_count_min", OpenApiTypes.INT, description="Minimum ride count" + ), + OpenApiParameter( + "ride_count_max", OpenApiTypes.INT, description="Maximum ride count" + ), + OpenApiParameter( + "coaster_count_min", + OpenApiTypes.INT, + description="Minimum coaster count", + ), + OpenApiParameter( + "coaster_count_max", + OpenApiTypes.INT, + description="Maximum coaster count", + ), + OpenApiParameter( + "operator", + OpenApiTypes.STR, + description="Filter by operator slug (comma-separated for multiple)", + ), + OpenApiParameter( + "search", + OpenApiTypes.STR, + description="Search query for park names, descriptions, locations, and operators", + ), + OpenApiParameter( + "offset", + OpenApiTypes.INT, + description="Offset for progressive loading (server-side pagination)", + ), ], responses={ 200: { @@ -570,31 +675,33 @@ from apps.parks.services.hybrid_loader import smart_park_loader "properties": { "parks": { "type": "array", - "items": {"$ref": "#/components/schemas/HybridParkSerializer"} + "items": { + "$ref": "#/components/schemas/HybridParkSerializer" + }, }, "total_count": {"type": "integer"}, "strategy": { "type": "string", "enum": ["client_side", "server_side"], - "description": "Filtering strategy used" + "description": "Filtering strategy used", }, "has_more": { "type": "boolean", - "description": "Whether more data is available for progressive loading" + "description": "Whether more data is available for progressive loading", }, "next_offset": { "type": "integer", "nullable": True, - "description": "Next offset for progressive loading" + "description": "Next offset for progressive loading", }, "filter_metadata": { "type": "object", - "description": "Available filter options and ranges" - } - } + "description": "Available filter options and ranges", + }, + }, } } - } + }, } }, tags=["Parks"], @@ -603,77 +710,83 @@ from apps.parks.services.hybrid_loader import smart_park_loader class HybridParkAPIView(APIView): """ Hybrid Park API View with intelligent filtering strategy. - + Automatically chooses between client-side and server-side filtering based on data size and complexity. Provides progressive loading for large datasets and complete data for smaller sets. """ - + permission_classes = [AllowAny] - + def get(self, request): """Get parks with hybrid filtering strategy.""" + # Extract filters from query parameters + filters = self._extract_filters(request.query_params) + + # Check if this is a progressive load request + offset = request.query_params.get("offset") + if offset is not None: + try: + offset = int(offset) + except ValueError: + return ErrorHandler.handle_api_error( + ValidationException("Invalid offset parameter"), + user_message="Invalid offset parameter", + status_code=status.HTTP_400_BAD_REQUEST, + ) + try: - # Extract filters from query parameters - filters = self._extract_filters(request.query_params) - - # Check if this is a progressive load request - offset = request.query_params.get('offset') if offset is not None: - try: - offset = int(offset) - # Get progressive load data - data = smart_park_loader.get_progressive_load(offset, filters) - except ValueError: - return Response( - {"error": "Invalid offset parameter"}, - status=status.HTTP_400_BAD_REQUEST - ) + # Get progressive load data + data = smart_park_loader.get_progressive_load(offset, filters) else: # Get initial load data data = smart_park_loader.get_initial_load(filters) - + # Serialize the parks data - serializer = HybridParkSerializer(data['parks'], many=True) - + serializer = HybridParkSerializer(data["parks"], many=True) + # Prepare response response_data = { - 'parks': serializer.data, - 'total_count': data['total_count'], - 'strategy': data.get('strategy', 'server_side'), - 'has_more': data.get('has_more', False), - 'next_offset': data.get('next_offset'), + "parks": serializer.data, + "total_count": data["total_count"], + "strategy": data.get("strategy", "server_side"), + "has_more": data.get("has_more", False), + "next_offset": data.get("next_offset"), } - + # Include filter metadata for initial loads - if 'filter_metadata' in data: - response_data['filter_metadata'] = data['filter_metadata'] - + if "filter_metadata" in data: + response_data["filter_metadata"] = data["filter_metadata"] + return Response(response_data, status=status.HTTP_200_OK) - - except Exception as e: - logger.error(f"Error in HybridParkAPIView: {e}") - return Response( - {"error": "Internal server error"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + + except ServiceError as e: + return ErrorHandler.handle_api_error( + e, + user_message="Failed to load parks", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - + def _extract_filters(self, query_params): """Extract and parse filters from query parameters.""" filters = {} - + # Handle comma-separated list parameters - list_params = ['status', 'park_type', 'country', 'state', 'operator'] + list_params = ["status", "park_type", "country", "state", "operator"] for param in list_params: value = query_params.get(param) if value: - filters[param] = [v.strip() for v in value.split(',') if v.strip()] - + filters[param] = [v.strip() for v in value.split(",") if v.strip()] + # Handle integer parameters int_params = [ - 'opening_year_min', 'opening_year_max', - 'ride_count_min', 'ride_count_max', - 'coaster_count_min', 'coaster_count_max' + "opening_year_min", + "opening_year_max", + "ride_count_min", + "ride_count_max", + "coaster_count_min", + "coaster_count_max", ] for param in int_params: value = query_params.get(param) @@ -682,9 +795,9 @@ class HybridParkAPIView(APIView): filters[param] = int(value) except ValueError: pass # Skip invalid integer values - + # Handle float parameters - float_params = ['size_min', 'size_max', 'rating_min', 'rating_max'] + float_params = ["size_min", "size_max", "rating_min", "rating_max"] for param in float_params: value = query_params.get(param) if value: @@ -692,12 +805,12 @@ class HybridParkAPIView(APIView): filters[param] = float(value) except ValueError: pass # Skip invalid float values - + # Handle search parameter - search = query_params.get('search') + search = query_params.get("search") if search: - filters['search'] = search.strip() - + filters["search"] = search.strip() + return filters @@ -706,7 +819,11 @@ class HybridParkAPIView(APIView): summary="Get park filter metadata", description="Get available filter options and ranges for parks filtering.", parameters=[ - OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"), + OpenApiParameter( + "scoped", + OpenApiTypes.BOOL, + description="Whether to scope metadata to current filters", + ), ], responses={ 200: { @@ -719,21 +836,33 @@ class HybridParkAPIView(APIView): "categorical": { "type": "object", "properties": { - "countries": {"type": "array", "items": {"type": "string"}}, - "states": {"type": "array", "items": {"type": "string"}}, - "park_types": {"type": "array", "items": {"type": "string"}}, - "statuses": {"type": "array", "items": {"type": "string"}}, + "countries": { + "type": "array", + "items": {"type": "string"}, + }, + "states": { + "type": "array", + "items": {"type": "string"}, + }, + "park_types": { + "type": "array", + "items": {"type": "string"}, + }, + "statuses": { + "type": "array", + "items": {"type": "string"}, + }, "operators": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, - "slug": {"type": "string"} - } - } - } - } + "slug": {"type": "string"}, + }, + }, + }, + }, }, "ranges": { "type": "object", @@ -741,45 +870,75 @@ class HybridParkAPIView(APIView): "opening_year": { "type": "object", "properties": { - "min": {"type": "integer", "nullable": True}, - "max": {"type": "integer", "nullable": True} - } + "min": { + "type": "integer", + "nullable": True, + }, + "max": { + "type": "integer", + "nullable": True, + }, + }, }, "size_acres": { "type": "object", "properties": { - "min": {"type": "number", "nullable": True}, - "max": {"type": "number", "nullable": True} - } + "min": { + "type": "number", + "nullable": True, + }, + "max": { + "type": "number", + "nullable": True, + }, + }, }, "average_rating": { "type": "object", "properties": { - "min": {"type": "number", "nullable": True}, - "max": {"type": "number", "nullable": True} - } + "min": { + "type": "number", + "nullable": True, + }, + "max": { + "type": "number", + "nullable": True, + }, + }, }, "ride_count": { "type": "object", "properties": { - "min": {"type": "integer", "nullable": True}, - "max": {"type": "integer", "nullable": True} - } + "min": { + "type": "integer", + "nullable": True, + }, + "max": { + "type": "integer", + "nullable": True, + }, + }, }, "coaster_count": { "type": "object", "properties": { - "min": {"type": "integer", "nullable": True}, - "max": {"type": "integer", "nullable": True} - } - } - } + "min": { + "type": "integer", + "nullable": True, + }, + "max": { + "type": "integer", + "nullable": True, + }, + }, + }, + }, }, - "total_count": {"type": "integer"} - } + "total_count": {"type": "integer"}, + }, } } - } + }, } }, tags=["Parks"], @@ -788,35 +947,34 @@ class HybridParkAPIView(APIView): class ParkFilterMetadataAPIView(APIView): """ API view for getting park filter metadata. - + Provides information about available filter options and ranges to help build dynamic filter interfaces. """ - + permission_classes = [AllowAny] - + def get(self, request): """Get park filter metadata.""" + # Check if metadata should be scoped to current filters + scoped = request.query_params.get("scoped", "").lower() == "true" + filters = None + + if scoped: + filters = self._extract_filters(request.query_params) + try: - # Check if metadata should be scoped to current filters - scoped = request.query_params.get('scoped', '').lower() == 'true' - filters = None - - if scoped: - filters = self._extract_filters(request.query_params) - # Get filter metadata metadata = smart_park_loader.get_filter_metadata(filters) - return Response(metadata, status=status.HTTP_200_OK) - - except Exception as e: - logger.error(f"Error in ParkFilterMetadataAPIView: {e}") - return Response( - {"error": "Internal server error"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + + except ServiceError as e: + return ErrorHandler.handle_api_error( + e, + user_message="Failed to get filter metadata", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - + def _extract_filters(self, query_params): """Extract and parse filters from query parameters.""" # Reuse the same filter extraction logic diff --git a/backend/apps/api/v1/rides/views.py b/backend/apps/api/v1/rides/views.py index a4604f50..6ca9aa30 100644 --- a/backend/apps/api/v1/rides/views.py +++ b/backend/apps/api/v1/rides/views.py @@ -13,33 +13,30 @@ Notes: are not present, they return a clear 501 response explaining what to wire up. """ -from typing import Any import logging +from typing import Any from django.db import models - -logger = logging.getLogger(__name__) -from rest_framework import status, permissions -from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter +from rest_framework import permissions, status +from rest_framework.exceptions import NotFound +from rest_framework.pagination import PageNumberPagination from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.pagination import PageNumberPagination -from rest_framework.exceptions import NotFound -from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter -from drf_spectacular.types import OpenApiTypes +from rest_framework.views import APIView -# Reuse existing serializers where possible from apps.api.v1.serializers.rides import ( - RideListOutputSerializer, - RideDetailOutputSerializer, RideCreateInputSerializer, - RideUpdateInputSerializer, + RideDetailOutputSerializer, RideImageSettingsInputSerializer, + RideListOutputSerializer, + RideUpdateInputSerializer, ) - -# Import hybrid filtering components from apps.rides.services.hybrid_loader import SmartRideLoader +logger = logging.getLogger(__name__) + # Create smart loader instance smart_ride_loader = SmartRideLoader() @@ -358,7 +355,7 @@ class RideListCreateAPIView(APIView): qs = qs.filter(park_id=int(park_id)) except (ValueError, TypeError): pass - + return qs def _apply_category_status_filters(self, qs, params): @@ -370,7 +367,7 @@ class RideListCreateAPIView(APIView): statuses = params.getlist("status") if statuses: qs = qs.filter(status__in=statuses) - + return qs def _apply_company_filters(self, qs, params): @@ -396,7 +393,7 @@ class RideListCreateAPIView(APIView): designer_slug = params.get("designer_slug") if designer_slug: qs = qs.filter(designer__slug=designer_slug) - + return qs def _apply_ride_model_filters(self, qs, params): @@ -415,7 +412,7 @@ class RideListCreateAPIView(APIView): ride_model__slug=ride_model_slug, ride_model__manufacturer__slug=manufacturer_slug_for_model, ) - + return qs def _apply_rating_filters(self, qs, params): @@ -433,7 +430,7 @@ class RideListCreateAPIView(APIView): qs = qs.filter(average_rating__lte=float(max_rating)) except (ValueError, TypeError): pass - + return qs def _apply_height_requirement_filters(self, qs, params): @@ -451,7 +448,7 @@ class RideListCreateAPIView(APIView): qs = qs.filter(max_height_in__lte=int(max_height_req)) except (ValueError, TypeError): pass - + return qs def _apply_capacity_filters(self, qs, params): @@ -469,7 +466,7 @@ class RideListCreateAPIView(APIView): qs = qs.filter(capacity_per_hour__lte=int(max_capacity)) except (ValueError, TypeError): pass - + return qs def _apply_opening_year_filters(self, qs, params): @@ -494,7 +491,7 @@ class RideListCreateAPIView(APIView): qs = qs.filter(opening_date__year__lte=int(max_opening_year)) except (ValueError, TypeError): pass - + return qs def _apply_roller_coaster_filters(self, qs, params): @@ -562,7 +559,7 @@ class RideListCreateAPIView(APIView): qs = qs.filter(coaster_stats__inversions__gt=0) elif has_inversions.lower() in ["false", "0", "no"]: qs = qs.filter(coaster_stats__inversions=0) - + return qs def _apply_ordering(self, qs, params): @@ -594,7 +591,7 @@ class RideListCreateAPIView(APIView): qs = qs.order_by(ordering_field) else: qs = qs.order_by(ordering) - + return qs @extend_schema( @@ -695,8 +692,8 @@ class RideDetailAPIView(APIView): park_change_info = None # Handle park change specially if park_id is being updated - if 'park_id' in validated_data: - new_park_id = validated_data.pop('park_id') + if "park_id" in validated_data: + new_park_id = validated_data.pop("park_id") try: new_park = Park.objects.get(id=new_park_id) # type: ignore if new_park.id != ride.park_id: @@ -717,7 +714,7 @@ class RideDetailAPIView(APIView): # Add park change information to response if applicable if park_change_info: - response_data['park_change_info'] = park_change_info + response_data["park_change_info"] = park_change_info return Response(response_data) @@ -752,547 +749,940 @@ class FilterOptionsAPIView(APIView): """Return comprehensive filter options with Rich Choice Objects metadata.""" # Import Rich Choice registry from apps.core.choices.registry import get_choices - + if not MODELS_AVAILABLE: # Use Rich Choice Objects for fallback options try: # Get rich choice objects from registry - categories = get_choices('categories', 'rides') - statuses = get_choices('statuses', 'rides') - post_closing_statuses = get_choices('post_closing_statuses', 'rides') - track_materials = get_choices('track_materials', 'rides') - coaster_types = get_choices('coaster_types', 'rides') - propulsion_systems = get_choices('propulsion_systems', 'rides') - target_markets = get_choices('target_markets', 'rides') - + categories = get_choices("categories", "rides") + statuses = get_choices("statuses", "rides") + post_closing_statuses = get_choices("post_closing_statuses", "rides") + track_materials = get_choices("track_materials", "rides") + coaster_types = get_choices("coaster_types", "rides") + propulsion_systems = get_choices("propulsion_systems", "rides") + target_markets = get_choices("target_markets", "rides") + # Convert Rich Choice Objects to frontend format with metadata categories_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in categories ] - + statuses_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in statuses ] - + post_closing_statuses_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in post_closing_statuses ] - + track_materials_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in track_materials ] - + coaster_types_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in coaster_types ] - + propulsion_systems_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in propulsion_systems ] - + target_markets_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in target_markets ] - + except Exception: # Ultimate fallback with basic structure categories_data = [ - {"value": "RC", "label": "Roller Coaster", "description": "High-speed thrill rides with tracks", "color": "red", "icon": "roller-coaster", "css_class": "bg-red-100 text-red-800", "sort_order": 1}, - {"value": "DR", "label": "Dark Ride", "description": "Indoor themed experiences", "color": "purple", "icon": "dark-ride", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2}, - {"value": "FR", "label": "Flat Ride", "description": "Spinning and rotating attractions", "color": "blue", "icon": "flat-ride", "css_class": "bg-blue-100 text-blue-800", "sort_order": 3}, - {"value": "WR", "label": "Water Ride", "description": "Water-based attractions and slides", "color": "cyan", "icon": "water-ride", "css_class": "bg-cyan-100 text-cyan-800", "sort_order": 4}, - {"value": "TR", "label": "Transport", "description": "Transportation systems within parks", "color": "green", "icon": "transport", "css_class": "bg-green-100 text-green-800", "sort_order": 5}, - {"value": "OT", "label": "Other", "description": "Miscellaneous attractions", "color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 6}, + { + "value": "RC", + "label": "Roller Coaster", + "description": "High-speed thrill rides with tracks", + "color": "red", + "icon": "roller-coaster", + "css_class": "bg-red-100 text-red-800", + "sort_order": 1, + }, + { + "value": "DR", + "label": "Dark Ride", + "description": "Indoor themed experiences", + "color": "purple", + "icon": "dark-ride", + "css_class": "bg-purple-100 text-purple-800", + "sort_order": 2, + }, + { + "value": "FR", + "label": "Flat Ride", + "description": "Spinning and rotating attractions", + "color": "blue", + "icon": "flat-ride", + "css_class": "bg-blue-100 text-blue-800", + "sort_order": 3, + }, + { + "value": "WR", + "label": "Water Ride", + "description": "Water-based attractions and slides", + "color": "cyan", + "icon": "water-ride", + "css_class": "bg-cyan-100 text-cyan-800", + "sort_order": 4, + }, + { + "value": "TR", + "label": "Transport", + "description": "Transportation systems within parks", + "color": "green", + "icon": "transport", + "css_class": "bg-green-100 text-green-800", + "sort_order": 5, + }, + { + "value": "OT", + "label": "Other", + "description": "Miscellaneous attractions", + "color": "gray", + "icon": "other", + "css_class": "bg-gray-100 text-gray-800", + "sort_order": 6, + }, ] statuses_data = [ - {"value": "OPERATING", "label": "Operating", "description": "Ride is currently open and operating", "color": "green", "icon": "check-circle", "css_class": "bg-green-100 text-green-800", "sort_order": 1}, - {"value": "CLOSED_TEMP", "label": "Temporarily Closed", "description": "Ride is temporarily closed for maintenance", "color": "yellow", "icon": "pause-circle", "css_class": "bg-yellow-100 text-yellow-800", "sort_order": 2}, - {"value": "SBNO", "label": "Standing But Not Operating", "description": "Ride exists but is not operational", "color": "orange", "icon": "stop-circle", "css_class": "bg-orange-100 text-orange-800", "sort_order": 3}, - {"value": "CLOSING", "label": "Closing", "description": "Ride is scheduled to close permanently", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 4}, - {"value": "CLOSED_PERM", "label": "Permanently Closed", "description": "Ride has been permanently closed", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 5}, - {"value": "UNDER_CONSTRUCTION", "label": "Under Construction", "description": "Ride is currently being built", "color": "blue", "icon": "tool", "css_class": "bg-blue-100 text-blue-800", "sort_order": 6}, - {"value": "DEMOLISHED", "label": "Demolished", "description": "Ride has been completely removed", "color": "gray", "icon": "trash", "css_class": "bg-gray-100 text-gray-800", "sort_order": 7}, - {"value": "RELOCATED", "label": "Relocated", "description": "Ride has been moved to another location", "color": "purple", "icon": "arrow-right", "css_class": "bg-purple-100 text-purple-800", "sort_order": 8}, + { + "value": "OPERATING", + "label": "Operating", + "description": "Ride is currently open and operating", + "color": "green", + "icon": "check-circle", + "css_class": "bg-green-100 text-green-800", + "sort_order": 1, + }, + { + "value": "CLOSED_TEMP", + "label": "Temporarily Closed", + "description": "Ride is temporarily closed for maintenance", + "color": "yellow", + "icon": "pause-circle", + "css_class": "bg-yellow-100 text-yellow-800", + "sort_order": 2, + }, + { + "value": "SBNO", + "label": "Standing But Not Operating", + "description": "Ride exists but is not operational", + "color": "orange", + "icon": "stop-circle", + "css_class": "bg-orange-100 text-orange-800", + "sort_order": 3, + }, + { + "value": "CLOSING", + "label": "Closing", + "description": "Ride is scheduled to close permanently", + "color": "red", + "icon": "x-circle", + "css_class": "bg-red-100 text-red-800", + "sort_order": 4, + }, + { + "value": "CLOSED_PERM", + "label": "Permanently Closed", + "description": "Ride has been permanently closed", + "color": "red", + "icon": "x-circle", + "css_class": "bg-red-100 text-red-800", + "sort_order": 5, + }, + { + "value": "UNDER_CONSTRUCTION", + "label": "Under Construction", + "description": "Ride is currently being built", + "color": "blue", + "icon": "tool", + "css_class": "bg-blue-100 text-blue-800", + "sort_order": 6, + }, + { + "value": "DEMOLISHED", + "label": "Demolished", + "description": "Ride has been completely removed", + "color": "gray", + "icon": "trash", + "css_class": "bg-gray-100 text-gray-800", + "sort_order": 7, + }, + { + "value": "RELOCATED", + "label": "Relocated", + "description": "Ride has been moved to another location", + "color": "purple", + "icon": "arrow-right", + "css_class": "bg-purple-100 text-purple-800", + "sort_order": 8, + }, ] post_closing_statuses_data = [ - {"value": "SBNO", "label": "Standing But Not Operating", "description": "Ride exists but is not operational", "color": "orange", "icon": "stop-circle", "css_class": "bg-orange-100 text-orange-800", "sort_order": 1}, - {"value": "CLOSED_PERM", "label": "Permanently Closed", "description": "Ride has been permanently closed", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 2}, + { + "value": "SBNO", + "label": "Standing But Not Operating", + "description": "Ride exists but is not operational", + "color": "orange", + "icon": "stop-circle", + "css_class": "bg-orange-100 text-orange-800", + "sort_order": 1, + }, + { + "value": "CLOSED_PERM", + "label": "Permanently Closed", + "description": "Ride has been permanently closed", + "color": "red", + "icon": "x-circle", + "css_class": "bg-red-100 text-red-800", + "sort_order": 2, + }, ] track_materials_data = [ - {"value": "STEEL", "label": "Steel", "description": "Modern steel track construction", "color": "gray", "icon": "steel", "css_class": "bg-gray-100 text-gray-800", "sort_order": 1}, - {"value": "WOOD", "label": "Wood", "description": "Traditional wooden track construction", "color": "amber", "icon": "wood", "css_class": "bg-amber-100 text-amber-800", "sort_order": 2}, - {"value": "HYBRID", "label": "Hybrid", "description": "Steel track on wooden structure", "color": "orange", "icon": "hybrid", "css_class": "bg-orange-100 text-orange-800", "sort_order": 3}, + { + "value": "STEEL", + "label": "Steel", + "description": "Modern steel track construction", + "color": "gray", + "icon": "steel", + "css_class": "bg-gray-100 text-gray-800", + "sort_order": 1, + }, + { + "value": "WOOD", + "label": "Wood", + "description": "Traditional wooden track construction", + "color": "amber", + "icon": "wood", + "css_class": "bg-amber-100 text-amber-800", + "sort_order": 2, + }, + { + "value": "HYBRID", + "label": "Hybrid", + "description": "Steel track on wooden structure", + "color": "orange", + "icon": "hybrid", + "css_class": "bg-orange-100 text-orange-800", + "sort_order": 3, + }, ] coaster_types_data = [ - {"value": "SITDOWN", "label": "Sit Down", "description": "Traditional seated roller coaster", "color": "blue", "icon": "sitdown", "css_class": "bg-blue-100 text-blue-800", "sort_order": 1}, - {"value": "INVERTED", "label": "Inverted", "description": "Track above riders, feet dangle", "color": "purple", "icon": "inverted", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2}, - {"value": "FLYING", "label": "Flying", "description": "Riders positioned face-down", "color": "sky", "icon": "flying", "css_class": "bg-sky-100 text-sky-800", "sort_order": 3}, - {"value": "STANDUP", "label": "Stand Up", "description": "Riders stand during the ride", "color": "green", "icon": "standup", "css_class": "bg-green-100 text-green-800", "sort_order": 4}, - {"value": "WING", "label": "Wing", "description": "Seats extend beyond track sides", "color": "indigo", "icon": "wing", "css_class": "bg-indigo-100 text-indigo-800", "sort_order": 5}, - {"value": "DIVE", "label": "Dive", "description": "Features steep vertical drops", "color": "red", "icon": "dive", "css_class": "bg-red-100 text-red-800", "sort_order": 6}, + { + "value": "SITDOWN", + "label": "Sit Down", + "description": "Traditional seated roller coaster", + "color": "blue", + "icon": "sitdown", + "css_class": "bg-blue-100 text-blue-800", + "sort_order": 1, + }, + { + "value": "INVERTED", + "label": "Inverted", + "description": "Track above riders, feet dangle", + "color": "purple", + "icon": "inverted", + "css_class": "bg-purple-100 text-purple-800", + "sort_order": 2, + }, + { + "value": "FLYING", + "label": "Flying", + "description": "Riders positioned face-down", + "color": "sky", + "icon": "flying", + "css_class": "bg-sky-100 text-sky-800", + "sort_order": 3, + }, + { + "value": "STANDUP", + "label": "Stand Up", + "description": "Riders stand during the ride", + "color": "green", + "icon": "standup", + "css_class": "bg-green-100 text-green-800", + "sort_order": 4, + }, + { + "value": "WING", + "label": "Wing", + "description": "Seats extend beyond track sides", + "color": "indigo", + "icon": "wing", + "css_class": "bg-indigo-100 text-indigo-800", + "sort_order": 5, + }, + { + "value": "DIVE", + "label": "Dive", + "description": "Features steep vertical drops", + "color": "red", + "icon": "dive", + "css_class": "bg-red-100 text-red-800", + "sort_order": 6, + }, ] propulsion_systems_data = [ - {"value": "CHAIN", "label": "Chain Lift", "description": "Traditional chain lift hill", "color": "gray", "icon": "chain", "css_class": "bg-gray-100 text-gray-800", "sort_order": 1}, - {"value": "LSM", "label": "LSM Launch", "description": "Linear synchronous motor launch", "color": "blue", "icon": "lightning", "css_class": "bg-blue-100 text-blue-800", "sort_order": 2}, - {"value": "HYDRAULIC", "label": "Hydraulic Launch", "description": "High-pressure hydraulic launch", "color": "red", "icon": "hydraulic", "css_class": "bg-red-100 text-red-800", "sort_order": 3}, - {"value": "GRAVITY", "label": "Gravity", "description": "Gravity-powered ride", "color": "green", "icon": "gravity", "css_class": "bg-green-100 text-green-800", "sort_order": 4}, + { + "value": "CHAIN", + "label": "Chain Lift", + "description": "Traditional chain lift hill", + "color": "gray", + "icon": "chain", + "css_class": "bg-gray-100 text-gray-800", + "sort_order": 1, + }, + { + "value": "LSM", + "label": "LSM Launch", + "description": "Linear synchronous motor launch", + "color": "blue", + "icon": "lightning", + "css_class": "bg-blue-100 text-blue-800", + "sort_order": 2, + }, + { + "value": "HYDRAULIC", + "label": "Hydraulic Launch", + "description": "High-pressure hydraulic launch", + "color": "red", + "icon": "hydraulic", + "css_class": "bg-red-100 text-red-800", + "sort_order": 3, + }, + { + "value": "GRAVITY", + "label": "Gravity", + "description": "Gravity-powered ride", + "color": "green", + "icon": "gravity", + "css_class": "bg-green-100 text-green-800", + "sort_order": 4, + }, ] target_markets_data = [ - {"value": "FAMILY", "label": "Family", "description": "Suitable for all family members", "color": "green", "icon": "family", "css_class": "bg-green-100 text-green-800", "sort_order": 1}, - {"value": "THRILL", "label": "Thrill", "description": "High-intensity thrill experience", "color": "orange", "icon": "thrill", "css_class": "bg-orange-100 text-orange-800", "sort_order": 2}, - {"value": "EXTREME", "label": "Extreme", "description": "Maximum intensity experience", "color": "red", "icon": "extreme", "css_class": "bg-red-100 text-red-800", "sort_order": 3}, - {"value": "KIDDIE", "label": "Kiddie", "description": "Designed for young children", "color": "pink", "icon": "kiddie", "css_class": "bg-pink-100 text-pink-800", "sort_order": 4}, - {"value": "ALL_AGES", "label": "All Ages", "description": "Enjoyable for all age groups", "color": "blue", "icon": "all-ages", "css_class": "bg-blue-100 text-blue-800", "sort_order": 5}, + { + "value": "FAMILY", + "label": "Family", + "description": "Suitable for all family members", + "color": "green", + "icon": "family", + "css_class": "bg-green-100 text-green-800", + "sort_order": 1, + }, + { + "value": "THRILL", + "label": "Thrill", + "description": "High-intensity thrill experience", + "color": "orange", + "icon": "thrill", + "css_class": "bg-orange-100 text-orange-800", + "sort_order": 2, + }, + { + "value": "EXTREME", + "label": "Extreme", + "description": "Maximum intensity experience", + "color": "red", + "icon": "extreme", + "css_class": "bg-red-100 text-red-800", + "sort_order": 3, + }, + { + "value": "KIDDIE", + "label": "Kiddie", + "description": "Designed for young children", + "color": "pink", + "icon": "kiddie", + "css_class": "bg-pink-100 text-pink-800", + "sort_order": 4, + }, + { + "value": "ALL_AGES", + "label": "All Ages", + "description": "Enjoyable for all age groups", + "color": "blue", + "icon": "all-ages", + "css_class": "bg-blue-100 text-blue-800", + "sort_order": 5, + }, ] - + # Comprehensive fallback options with Rich Choice Objects metadata - return Response({ - "categories": categories_data, - "statuses": statuses_data, - "post_closing_statuses": [ - {"value": "SBNO", "label": "Standing But Not Operating"}, - {"value": "CLOSED_PERM", "label": "Permanently Closed"}, - ], - "roller_coaster_types": [ - {"value": "SITDOWN", "label": "Sit Down"}, - {"value": "INVERTED", "label": "Inverted"}, - {"value": "FLYING", "label": "Flying"}, - {"value": "STANDUP", "label": "Stand Up"}, - {"value": "WING", "label": "Wing"}, - {"value": "DIVE", "label": "Dive"}, - {"value": "FAMILY", "label": "Family"}, - {"value": "WILD_MOUSE", "label": "Wild Mouse"}, - {"value": "SPINNING", "label": "Spinning"}, - {"value": "FOURTH_DIMENSION", "label": "4th Dimension"}, - {"value": "OTHER", "label": "Other"}, - ], - "track_materials": [ - {"value": "STEEL", "label": "Steel"}, - {"value": "WOOD", "label": "Wood"}, - {"value": "HYBRID", "label": "Hybrid"}, - ], - "propulsion_systems": [ - {"value": "CHAIN", "label": "Chain Lift"}, - {"value": "LSM", "label": "LSM Launch"}, - {"value": "HYDRAULIC", "label": "Hydraulic Launch"}, - {"value": "GRAVITY", "label": "Gravity"}, - {"value": "OTHER", "label": "Other"}, - ], - "ride_model_target_markets": [ - {"value": "FAMILY", "label": "Family"}, - {"value": "THRILL", "label": "Thrill"}, - {"value": "EXTREME", "label": "Extreme"}, - {"value": "KIDDIE", "label": "Kiddie"}, - {"value": "ALL_AGES", "label": "All Ages"}, - ], - "parks": [], - "park_areas": [], - "manufacturers": [], - "designers": [], - "ride_models": [], - "ranges": { - "rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"}, - "height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"}, - "capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"}, - "ride_duration": {"min": 0, "max": 600, "step": 10, "unit": "seconds"}, - "height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"}, - "length_ft": {"min": 0, "max": 10000, "step": 100, "unit": "feet"}, - "speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"}, - "inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"}, - "ride_time": {"min": 0, "max": 600, "step": 10, "unit": "seconds"}, - "max_drop_height_ft": {"min": 0, "max": 500, "step": 10, "unit": "feet"}, - "trains_count": {"min": 1, "max": 10, "step": 1, "unit": "trains"}, - "cars_per_train": {"min": 1, "max": 20, "step": 1, "unit": "cars"}, - "seats_per_car": {"min": 1, "max": 8, "step": 1, "unit": "seats"}, - "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_coordinates", "label": "Has Location Coordinates", - "description": "Filter rides with GPS coordinates"}, - {"key": "has_ride_model", "label": "Has Ride Model", - "description": "Filter rides with specified ride model"}, - {"key": "has_manufacturer", "label": "Has Manufacturer", - "description": "Filter rides with specified manufacturer"}, - {"key": "has_designer", "label": "Has Designer", - "description": "Filter rides with specified designer"}, - ], - "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": "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": "ride_duration_seconds", - "label": "Duration (Shortest First)"}, - {"value": "-ride_duration_seconds", - "label": "Duration (Longest First)"}, - {"value": "height_ft", "label": "Height (Shortest First)"}, - {"value": "-height_ft", "label": "Height (Tallest First)"}, - {"value": "length_ft", "label": "Length (Shortest First)"}, - {"value": "-length_ft", "label": "Length (Longest First)"}, - {"value": "speed_mph", "label": "Speed (Slowest First)"}, - {"value": "-speed_mph", "label": "Speed (Fastest First)"}, - {"value": "inversions", "label": "Inversions (Fewest First)"}, - {"value": "-inversions", "label": "Inversions (Most First)"}, - {"value": "created_at", "label": "Date Added (Oldest First)"}, - {"value": "-created_at", "label": "Date Added (Newest First)"}, - {"value": "updated_at", "label": "Last Updated (Oldest First)"}, - {"value": "-updated_at", "label": "Last Updated (Newest First)"}, - ], - }) + return Response( + { + "categories": categories_data, + "statuses": statuses_data, + "post_closing_statuses": [ + {"value": "SBNO", "label": "Standing But Not Operating"}, + {"value": "CLOSED_PERM", "label": "Permanently Closed"}, + ], + "roller_coaster_types": [ + {"value": "SITDOWN", "label": "Sit Down"}, + {"value": "INVERTED", "label": "Inverted"}, + {"value": "FLYING", "label": "Flying"}, + {"value": "STANDUP", "label": "Stand Up"}, + {"value": "WING", "label": "Wing"}, + {"value": "DIVE", "label": "Dive"}, + {"value": "FAMILY", "label": "Family"}, + {"value": "WILD_MOUSE", "label": "Wild Mouse"}, + {"value": "SPINNING", "label": "Spinning"}, + {"value": "FOURTH_DIMENSION", "label": "4th Dimension"}, + {"value": "OTHER", "label": "Other"}, + ], + "track_materials": [ + {"value": "STEEL", "label": "Steel"}, + {"value": "WOOD", "label": "Wood"}, + {"value": "HYBRID", "label": "Hybrid"}, + ], + "propulsion_systems": [ + {"value": "CHAIN", "label": "Chain Lift"}, + {"value": "LSM", "label": "LSM Launch"}, + {"value": "HYDRAULIC", "label": "Hydraulic Launch"}, + {"value": "GRAVITY", "label": "Gravity"}, + {"value": "OTHER", "label": "Other"}, + ], + "ride_model_target_markets": [ + {"value": "FAMILY", "label": "Family"}, + {"value": "THRILL", "label": "Thrill"}, + {"value": "EXTREME", "label": "Extreme"}, + {"value": "KIDDIE", "label": "Kiddie"}, + {"value": "ALL_AGES", "label": "All Ages"}, + ], + "parks": [], + "park_areas": [], + "manufacturers": [], + "designers": [], + "ride_models": [], + "ranges": { + "rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"}, + "height_requirement": { + "min": 30, + "max": 90, + "step": 1, + "unit": "inches", + }, + "capacity": { + "min": 0, + "max": 5000, + "step": 50, + "unit": "riders/hour", + }, + "ride_duration": { + "min": 0, + "max": 600, + "step": 10, + "unit": "seconds", + }, + "height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"}, + "length_ft": { + "min": 0, + "max": 10000, + "step": 100, + "unit": "feet", + }, + "speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"}, + "inversions": { + "min": 0, + "max": 20, + "step": 1, + "unit": "inversions", + }, + "ride_time": { + "min": 0, + "max": 600, + "step": 10, + "unit": "seconds", + }, + "max_drop_height_ft": { + "min": 0, + "max": 500, + "step": 10, + "unit": "feet", + }, + "trains_count": { + "min": 1, + "max": 10, + "step": 1, + "unit": "trains", + }, + "cars_per_train": { + "min": 1, + "max": 20, + "step": 1, + "unit": "cars", + }, + "seats_per_car": { + "min": 1, + "max": 8, + "step": 1, + "unit": "seats", + }, + "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_coordinates", + "label": "Has Location Coordinates", + "description": "Filter rides with GPS coordinates", + }, + { + "key": "has_ride_model", + "label": "Has Ride Model", + "description": "Filter rides with specified ride model", + }, + { + "key": "has_manufacturer", + "label": "Has Manufacturer", + "description": "Filter rides with specified manufacturer", + }, + { + "key": "has_designer", + "label": "Has Designer", + "description": "Filter rides with specified designer", + }, + ], + "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": "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": "ride_duration_seconds", + "label": "Duration (Shortest First)", + }, + { + "value": "-ride_duration_seconds", + "label": "Duration (Longest First)", + }, + {"value": "height_ft", "label": "Height (Shortest First)"}, + {"value": "-height_ft", "label": "Height (Tallest First)"}, + {"value": "length_ft", "label": "Length (Shortest First)"}, + {"value": "-length_ft", "label": "Length (Longest First)"}, + {"value": "speed_mph", "label": "Speed (Slowest First)"}, + {"value": "-speed_mph", "label": "Speed (Fastest First)"}, + {"value": "inversions", "label": "Inversions (Fewest First)"}, + {"value": "-inversions", "label": "Inversions (Most First)"}, + {"value": "created_at", "label": "Date Added (Oldest First)"}, + {"value": "-created_at", "label": "Date Added (Newest First)"}, + {"value": "updated_at", "label": "Last Updated (Oldest First)"}, + { + "value": "-updated_at", + "label": "Last Updated (Newest First)", + }, + ], + } + ) # Get static choice definitions from Rich Choice Objects (primary source) # Get dynamic data from database queries - + # Get rich choice objects from registry - categories = get_choices('categories', 'rides') - statuses = get_choices('statuses', 'rides') - post_closing_statuses = get_choices('post_closing_statuses', 'rides') - track_materials = get_choices('track_materials', 'rides') - coaster_types = get_choices('coaster_types', 'rides') - propulsion_systems = get_choices('propulsion_systems', 'rides') - target_markets = get_choices('target_markets', 'rides') - + categories = get_choices("categories", "rides") + statuses = get_choices("statuses", "rides") + post_closing_statuses = get_choices("post_closing_statuses", "rides") + track_materials = get_choices("track_materials", "rides") + coaster_types = get_choices("coaster_types", "rides") + propulsion_systems = get_choices("propulsion_systems", "rides") + target_markets = get_choices("target_markets", "rides") + # Convert Rich Choice Objects to frontend format with metadata categories_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in categories ] - + statuses_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in statuses ] - + post_closing_statuses_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in post_closing_statuses ] - + track_materials_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in track_materials ] - + coaster_types_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in coaster_types ] - + propulsion_systems_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in propulsion_systems ] - + target_markets_data = [ { "value": choice.value, "label": choice.label, "description": choice.description, - "color": choice.metadata.get('color'), - "icon": choice.metadata.get('icon'), - "css_class": choice.metadata.get('css_class'), - "sort_order": choice.metadata.get('sort_order', 0) + "color": choice.metadata.get("color"), + "icon": choice.metadata.get("icon"), + "css_class": choice.metadata.get("css_class"), + "sort_order": choice.metadata.get("sort_order", 0), } for choice in target_markets ] # Get parks data from database - parks = list(Ride.objects.exclude( - park__isnull=True - ).select_related('park').values( - 'park__id', 'park__name', 'park__slug' - ).distinct().order_by('park__name')) + parks = list( + Ride.objects.exclude(park__isnull=True) + .select_related("park") + .values("park__id", "park__name", "park__slug") + .distinct() + .order_by("park__name") + ) # Get park areas data from database - park_areas = list(Ride.objects.exclude( - park_area__isnull=True - ).select_related('park_area').values( - 'park_area__id', 'park_area__name', 'park_area__slug' - ).distinct().order_by('park_area__name')) + park_areas = list( + Ride.objects.exclude(park_area__isnull=True) + .select_related("park_area") + .values("park_area__id", "park_area__name", "park_area__slug") + .distinct() + .order_by("park_area__name") + ) # Get manufacturers (companies with MANUFACTURER role) - manufacturers = list(Company.objects.filter( - roles__contains=['MANUFACTURER'] - ).values('id', 'name', 'slug').order_by('name')) + manufacturers = list( + Company.objects.filter(roles__contains=["MANUFACTURER"]) + .values("id", "name", "slug") + .order_by("name") + ) # Get designers (companies with DESIGNER role) - designers = list(Company.objects.filter( - roles__contains=['DESIGNER'] - ).values('id', 'name', 'slug').order_by('name')) + designers = list( + Company.objects.filter(roles__contains=["DESIGNER"]) + .values("id", "name", "slug") + .order_by("name") + ) # Get ride models data from database - ride_models = list(RideModel.objects.select_related( - 'manufacturer' - ).values( - 'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category' - ).order_by('manufacturer__name', 'name')) + ride_models = list( + RideModel.objects.select_related("manufacturer") + .values( + "id", + "name", + "slug", + "manufacturer__name", + "manufacturer__slug", + "category", + ) + .order_by("manufacturer__name", "name") + ) # Calculate ranges from actual data ride_stats = Ride.objects.aggregate( - min_rating=models.Min('average_rating'), - max_rating=models.Max('average_rating'), - min_height_req=models.Min('min_height_in'), - max_height_req=models.Max('max_height_in'), - min_capacity=models.Min('capacity_per_hour'), - max_capacity=models.Max('capacity_per_hour'), - min_duration=models.Min('ride_duration_seconds'), - max_duration=models.Max('ride_duration_seconds'), - min_year=models.Min('opening_date__year'), - max_year=models.Max('opening_date__year'), + min_rating=models.Min("average_rating"), + max_rating=models.Max("average_rating"), + min_height_req=models.Min("min_height_in"), + max_height_req=models.Max("max_height_in"), + min_capacity=models.Min("capacity_per_hour"), + max_capacity=models.Max("capacity_per_hour"), + min_duration=models.Min("ride_duration_seconds"), + max_duration=models.Max("ride_duration_seconds"), + min_year=models.Min("opening_date__year"), + max_year=models.Max("opening_date__year"), ) # Calculate roller coaster specific ranges coaster_stats = RollerCoasterStats.objects.aggregate( - min_height_ft=models.Min('height_ft'), - max_height_ft=models.Max('height_ft'), - min_length_ft=models.Min('length_ft'), - max_length_ft=models.Max('length_ft'), - min_speed_mph=models.Min('speed_mph'), - max_speed_mph=models.Max('speed_mph'), - min_inversions=models.Min('inversions'), - max_inversions=models.Max('inversions'), - min_ride_time=models.Min('ride_time_seconds'), - max_ride_time=models.Max('ride_time_seconds'), - min_drop_height=models.Min('max_drop_height_ft'), - max_drop_height=models.Max('max_drop_height_ft'), - min_trains=models.Min('trains_count'), - max_trains=models.Max('trains_count'), - min_cars=models.Min('cars_per_train'), - max_cars=models.Max('cars_per_train'), - min_seats=models.Min('seats_per_car'), - max_seats=models.Max('seats_per_car'), + min_height_ft=models.Min("height_ft"), + max_height_ft=models.Max("height_ft"), + min_length_ft=models.Min("length_ft"), + max_length_ft=models.Max("length_ft"), + min_speed_mph=models.Min("speed_mph"), + max_speed_mph=models.Max("speed_mph"), + min_inversions=models.Min("inversions"), + max_inversions=models.Max("inversions"), + min_ride_time=models.Min("ride_time_seconds"), + max_ride_time=models.Max("ride_time_seconds"), + min_drop_height=models.Min("max_drop_height_ft"), + max_drop_height=models.Max("max_drop_height_ft"), + min_trains=models.Min("trains_count"), + max_trains=models.Max("trains_count"), + min_cars=models.Min("cars_per_train"), + max_cars=models.Max("cars_per_train"), + min_seats=models.Min("seats_per_car"), + max_seats=models.Max("seats_per_car"), ) ranges = { - "rating": { - "min": float(ride_stats['min_rating'] or 1), - "max": float(ride_stats['max_rating'] or 10), - "step": 0.1, - "unit": "stars" - }, - "height_requirement": { - "min": ride_stats['min_height_req'] or 30, - "max": ride_stats['max_height_req'] or 90, - "step": 1, - "unit": "inches" - }, - "capacity": { - "min": ride_stats['min_capacity'] or 0, - "max": ride_stats['max_capacity'] or 5000, - "step": 50, - "unit": "riders/hour" - }, - "ride_duration": { - "min": ride_stats['min_duration'] or 0, - "max": ride_stats['max_duration'] or 600, - "step": 10, - "unit": "seconds" - }, - "height_ft": { - "min": float(coaster_stats['min_height_ft'] or 0), - "max": float(coaster_stats['max_height_ft'] or 500), - "step": 5, - "unit": "feet" - }, - "length_ft": { - "min": float(coaster_stats['min_length_ft'] or 0), - "max": float(coaster_stats['max_length_ft'] or 10000), - "step": 100, - "unit": "feet" - }, - "speed_mph": { - "min": float(coaster_stats['min_speed_mph'] or 0), - "max": float(coaster_stats['max_speed_mph'] or 150), - "step": 5, - "unit": "mph" - }, - "inversions": { - "min": coaster_stats['min_inversions'] or 0, - "max": coaster_stats['max_inversions'] or 20, - "step": 1, - "unit": "inversions" - }, - "ride_time": { - "min": coaster_stats['min_ride_time'] or 0, - "max": coaster_stats['max_ride_time'] or 600, - "step": 10, - "unit": "seconds" - }, - "max_drop_height_ft": { - "min": float(coaster_stats['min_drop_height'] or 0), - "max": float(coaster_stats['max_drop_height'] or 500), - "step": 10, - "unit": "feet" - }, - "trains_count": { - "min": coaster_stats['min_trains'] or 1, - "max": coaster_stats['max_trains'] or 10, - "step": 1, - "unit": "trains" - }, - "cars_per_train": { - "min": coaster_stats['min_cars'] or 1, - "max": coaster_stats['max_cars'] or 20, - "step": 1, - "unit": "cars" - }, - "seats_per_car": { - "min": coaster_stats['min_seats'] or 1, - "max": coaster_stats['max_seats'] or 8, - "step": 1, - "unit": "seats" - }, - "opening_year": { - "min": ride_stats['min_year'] or 1800, - "max": ride_stats['max_year'] or 2030, - "step": 1, - "unit": "year" - }, - } + "rating": { + "min": float(ride_stats["min_rating"] or 1), + "max": float(ride_stats["max_rating"] or 10), + "step": 0.1, + "unit": "stars", + }, + "height_requirement": { + "min": ride_stats["min_height_req"] or 30, + "max": ride_stats["max_height_req"] or 90, + "step": 1, + "unit": "inches", + }, + "capacity": { + "min": ride_stats["min_capacity"] or 0, + "max": ride_stats["max_capacity"] or 5000, + "step": 50, + "unit": "riders/hour", + }, + "ride_duration": { + "min": ride_stats["min_duration"] or 0, + "max": ride_stats["max_duration"] or 600, + "step": 10, + "unit": "seconds", + }, + "height_ft": { + "min": float(coaster_stats["min_height_ft"] or 0), + "max": float(coaster_stats["max_height_ft"] or 500), + "step": 5, + "unit": "feet", + }, + "length_ft": { + "min": float(coaster_stats["min_length_ft"] or 0), + "max": float(coaster_stats["max_length_ft"] or 10000), + "step": 100, + "unit": "feet", + }, + "speed_mph": { + "min": float(coaster_stats["min_speed_mph"] or 0), + "max": float(coaster_stats["max_speed_mph"] or 150), + "step": 5, + "unit": "mph", + }, + "inversions": { + "min": coaster_stats["min_inversions"] or 0, + "max": coaster_stats["max_inversions"] or 20, + "step": 1, + "unit": "inversions", + }, + "ride_time": { + "min": coaster_stats["min_ride_time"] or 0, + "max": coaster_stats["max_ride_time"] or 600, + "step": 10, + "unit": "seconds", + }, + "max_drop_height_ft": { + "min": float(coaster_stats["min_drop_height"] or 0), + "max": float(coaster_stats["max_drop_height"] or 500), + "step": 10, + "unit": "feet", + }, + "trains_count": { + "min": coaster_stats["min_trains"] or 1, + "max": coaster_stats["max_trains"] or 10, + "step": 1, + "unit": "trains", + }, + "cars_per_train": { + "min": coaster_stats["min_cars"] or 1, + "max": coaster_stats["max_cars"] or 20, + "step": 1, + "unit": "cars", + }, + "seats_per_car": { + "min": coaster_stats["min_seats"] or 1, + "max": coaster_stats["max_seats"] or 8, + "step": 1, + "unit": "seats", + }, + "opening_year": { + "min": ride_stats["min_year"] or 1800, + "max": ride_stats["max_year"] or 2030, + "step": 1, + "unit": "year", + }, + } - return Response({ - "categories": categories_data, - "statuses": statuses_data, - "post_closing_statuses": post_closing_statuses_data, - "roller_coaster_types": coaster_types_data, - "track_materials": track_materials_data, - "propulsion_systems": propulsion_systems_data, - "ride_model_target_markets": target_markets_data, - "parks": parks, - "park_areas": park_areas, - "manufacturers": manufacturers, - "designers": designers, - "ride_models": ride_models, - "ranges": ranges, - "boolean_filters": [ - {"key": "has_inversions", "label": "Has Inversions", - "description": "Filter roller coasters with or without inversions"}, - {"key": "has_coordinates", "label": "Has Location Coordinates", - "description": "Filter rides with GPS coordinates"}, - {"key": "has_ride_model", "label": "Has Ride Model", - "description": "Filter rides with specified ride model"}, - {"key": "has_manufacturer", "label": "Has Manufacturer", - "description": "Filter rides with specified manufacturer"}, - {"key": "has_designer", "label": "Has Designer", - "description": "Filter rides with specified designer"}, + return Response( + { + "categories": categories_data, + "statuses": statuses_data, + "post_closing_statuses": post_closing_statuses_data, + "roller_coaster_types": coaster_types_data, + "track_materials": track_materials_data, + "propulsion_systems": propulsion_systems_data, + "ride_model_target_markets": target_markets_data, + "parks": parks, + "park_areas": park_areas, + "manufacturers": manufacturers, + "designers": designers, + "ride_models": ride_models, + "ranges": ranges, + "boolean_filters": [ + { + "key": "has_inversions", + "label": "Has Inversions", + "description": "Filter roller coasters with or without inversions", + }, + { + "key": "has_coordinates", + "label": "Has Location Coordinates", + "description": "Filter rides with GPS coordinates", + }, + { + "key": "has_ride_model", + "label": "Has Ride Model", + "description": "Filter rides with specified ride model", + }, + { + "key": "has_manufacturer", + "label": "Has Manufacturer", + "description": "Filter rides with specified manufacturer", + }, + { + "key": "has_designer", + "label": "Has Designer", + "description": "Filter rides with specified designer", + }, ], "ordering_options": [ {"value": "name", "label": "Name (A-Z)"}, @@ -1302,12 +1692,18 @@ 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": "ride_duration_seconds", - "label": "Duration (Shortest First)"}, - {"value": "-ride_duration_seconds", - "label": "Duration (Longest First)"}, + { + "value": "-capacity_per_hour", + "label": "Capacity (Highest First)", + }, + { + "value": "ride_duration_seconds", + "label": "Duration (Shortest First)", + }, + { + "value": "-ride_duration_seconds", + "label": "Duration (Longest First)", + }, {"value": "height_ft", "label": "Height (Shortest First)"}, {"value": "-height_ft", "label": "Height (Tallest First)"}, {"value": "length_ft", "label": "Length (Shortest First)"}, @@ -1321,8 +1717,8 @@ class FilterOptionsAPIView(APIView): {"value": "updated_at", "label": "Last Updated (Oldest First)"}, {"value": "-updated_at", "label": "Last Updated (Newest First)"}, ], - }) - + } + ) # --- Company search (autocomplete) ----------------------------------------- @@ -1477,38 +1873,131 @@ class RideImageSettingsAPIView(APIView): # --- Hybrid Filtering API Views -------------------------------------------- + @extend_schema_view( get=extend_schema( summary="Get rides with hybrid filtering", description="Retrieve rides with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.", parameters=[ - OpenApiParameter("category", OpenApiTypes.STR, description="Filter by ride category (comma-separated for multiple)"), - OpenApiParameter("status", OpenApiTypes.STR, description="Filter by ride status (comma-separated for multiple)"), - OpenApiParameter("park_slug", OpenApiTypes.STR, description="Filter by park slug"), - OpenApiParameter("park_id", OpenApiTypes.INT, description="Filter by park ID"), - OpenApiParameter("manufacturer", OpenApiTypes.STR, description="Filter by manufacturer slug (comma-separated for multiple)"), - OpenApiParameter("designer", OpenApiTypes.STR, description="Filter by designer slug (comma-separated for multiple)"), - OpenApiParameter("ride_model", OpenApiTypes.STR, description="Filter by ride model slug (comma-separated for multiple)"), - OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"), - OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"), - OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"), - OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"), - OpenApiParameter("height_requirement_min", OpenApiTypes.INT, description="Minimum height requirement in inches"), - OpenApiParameter("height_requirement_max", OpenApiTypes.INT, description="Maximum height requirement in inches"), - OpenApiParameter("capacity_min", OpenApiTypes.INT, description="Minimum hourly capacity"), - OpenApiParameter("capacity_max", OpenApiTypes.INT, description="Maximum hourly capacity"), - OpenApiParameter("roller_coaster_type", OpenApiTypes.STR, description="Filter by roller coaster type (comma-separated for multiple)"), - OpenApiParameter("track_material", OpenApiTypes.STR, description="Filter by track material (comma-separated for multiple)"), - OpenApiParameter("propulsion_system", OpenApiTypes.STR, description="Filter by propulsion system (comma-separated for multiple)"), - OpenApiParameter("height_ft_min", OpenApiTypes.NUMBER, description="Minimum roller coaster height in feet"), - OpenApiParameter("height_ft_max", OpenApiTypes.NUMBER, description="Maximum roller coaster height in feet"), - OpenApiParameter("speed_mph_min", OpenApiTypes.NUMBER, description="Minimum roller coaster speed in mph"), - OpenApiParameter("speed_mph_max", OpenApiTypes.NUMBER, description="Maximum roller coaster speed in mph"), - OpenApiParameter("inversions_min", OpenApiTypes.INT, description="Minimum number of inversions"), - OpenApiParameter("inversions_max", OpenApiTypes.INT, description="Maximum number of inversions"), - OpenApiParameter("has_inversions", OpenApiTypes.BOOL, description="Filter rides with inversions (true) or without (false)"), - OpenApiParameter("search", OpenApiTypes.STR, description="Search query for ride names, descriptions, parks, and related data"), - OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"), + OpenApiParameter( + "category", + OpenApiTypes.STR, + description="Filter by ride category (comma-separated for multiple)", + ), + OpenApiParameter( + "status", + OpenApiTypes.STR, + description="Filter by ride status (comma-separated for multiple)", + ), + OpenApiParameter( + "park_slug", OpenApiTypes.STR, description="Filter by park slug" + ), + OpenApiParameter( + "park_id", OpenApiTypes.INT, description="Filter by park ID" + ), + OpenApiParameter( + "manufacturer", + OpenApiTypes.STR, + description="Filter by manufacturer slug (comma-separated for multiple)", + ), + OpenApiParameter( + "designer", + OpenApiTypes.STR, + description="Filter by designer slug (comma-separated for multiple)", + ), + OpenApiParameter( + "ride_model", + OpenApiTypes.STR, + description="Filter by ride model slug (comma-separated for multiple)", + ), + OpenApiParameter( + "opening_year_min", OpenApiTypes.INT, description="Minimum opening year" + ), + OpenApiParameter( + "opening_year_max", OpenApiTypes.INT, description="Maximum opening year" + ), + OpenApiParameter( + "rating_min", OpenApiTypes.NUMBER, description="Minimum average rating" + ), + OpenApiParameter( + "rating_max", OpenApiTypes.NUMBER, description="Maximum average rating" + ), + OpenApiParameter( + "height_requirement_min", + OpenApiTypes.INT, + description="Minimum height requirement in inches", + ), + OpenApiParameter( + "height_requirement_max", + OpenApiTypes.INT, + description="Maximum height requirement in inches", + ), + OpenApiParameter( + "capacity_min", OpenApiTypes.INT, description="Minimum hourly capacity" + ), + OpenApiParameter( + "capacity_max", OpenApiTypes.INT, description="Maximum hourly capacity" + ), + OpenApiParameter( + "roller_coaster_type", + OpenApiTypes.STR, + description="Filter by roller coaster type (comma-separated for multiple)", + ), + OpenApiParameter( + "track_material", + OpenApiTypes.STR, + description="Filter by track material (comma-separated for multiple)", + ), + OpenApiParameter( + "propulsion_system", + OpenApiTypes.STR, + description="Filter by propulsion system (comma-separated for multiple)", + ), + OpenApiParameter( + "height_ft_min", + OpenApiTypes.NUMBER, + description="Minimum roller coaster height in feet", + ), + OpenApiParameter( + "height_ft_max", + OpenApiTypes.NUMBER, + description="Maximum roller coaster height in feet", + ), + OpenApiParameter( + "speed_mph_min", + OpenApiTypes.NUMBER, + description="Minimum roller coaster speed in mph", + ), + OpenApiParameter( + "speed_mph_max", + OpenApiTypes.NUMBER, + description="Maximum roller coaster speed in mph", + ), + OpenApiParameter( + "inversions_min", + OpenApiTypes.INT, + description="Minimum number of inversions", + ), + OpenApiParameter( + "inversions_max", + OpenApiTypes.INT, + description="Maximum number of inversions", + ), + OpenApiParameter( + "has_inversions", + OpenApiTypes.BOOL, + description="Filter rides with inversions (true) or without (false)", + ), + OpenApiParameter( + "search", + OpenApiTypes.STR, + description="Search query for ride names, descriptions, parks, and related data", + ), + OpenApiParameter( + "offset", + OpenApiTypes.INT, + description="Offset for progressive loading (server-side pagination)", + ), ], responses={ 200: { @@ -1520,31 +2009,33 @@ class RideImageSettingsAPIView(APIView): "properties": { "rides": { "type": "array", - "items": {"$ref": "#/components/schemas/HybridRideSerializer"} + "items": { + "$ref": "#/components/schemas/HybridRideSerializer" + }, }, "total_count": {"type": "integer"}, "strategy": { "type": "string", "enum": ["client_side", "server_side"], - "description": "Filtering strategy used" + "description": "Filtering strategy used", }, "has_more": { "type": "boolean", - "description": "Whether more data is available for progressive loading" + "description": "Whether more data is available for progressive loading", }, "next_offset": { "type": "integer", "nullable": True, - "description": "Next offset for progressive loading" + "description": "Next offset for progressive loading", }, "filter_metadata": { "type": "object", - "description": "Available filter options and ranges" - } - } + "description": "Available filter options and ranges", + }, + }, } } - } + }, } }, tags=["Rides"], @@ -1553,22 +2044,22 @@ class RideImageSettingsAPIView(APIView): class HybridRideAPIView(APIView): """ Hybrid Ride API View with intelligent filtering strategy. - + Automatically chooses between client-side and server-side filtering based on data size and complexity. Provides progressive loading for large datasets and complete data for smaller sets. """ - + permission_classes = [permissions.AllowAny] - + def get(self, request): """Get rides with hybrid filtering strategy.""" try: # Extract filters from query parameters filters = self._extract_filters(request.query_params) - + # Check if this is a progressive load request - offset = request.query_params.get('offset') + offset = request.query_params.get("offset") if offset is not None: try: offset = int(offset) @@ -1577,64 +2068,77 @@ class HybridRideAPIView(APIView): except ValueError: return Response( {"error": "Invalid offset parameter"}, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_400_BAD_REQUEST, ) else: # Get initial load data data = smart_ride_loader.get_initial_load(filters) - + # Prepare response (rides are already serialized by the service) response_data = { - 'rides': data['rides'], - 'total_count': data['total_count'], - 'strategy': data.get('strategy', 'server_side'), - 'has_more': data.get('has_more', False), - 'next_offset': data.get('next_offset'), + "rides": data["rides"], + "total_count": data["total_count"], + "strategy": data.get("strategy", "server_side"), + "has_more": data.get("has_more", False), + "next_offset": data.get("next_offset"), } - + # Include filter metadata for initial loads - if 'filter_metadata' in data: - response_data['filter_metadata'] = data['filter_metadata'] - + if "filter_metadata" in data: + response_data["filter_metadata"] = data["filter_metadata"] + return Response(response_data, status=status.HTTP_200_OK) - + except Exception as e: logger.error(f"Error in HybridRideAPIView: {e}") return Response( {"error": "Internal server error"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - + def _extract_filters(self, query_params): """Extract and parse filters from query parameters.""" filters = {} - + # Handle comma-separated list parameters - list_params = ['category', 'status', 'manufacturer', 'designer', 'ride_model', 'roller_coaster_type', 'track_material', 'propulsion_system'] + list_params = [ + "category", + "status", + "manufacturer", + "designer", + "ride_model", + "roller_coaster_type", + "track_material", + "propulsion_system", + ] for param in list_params: value = query_params.get(param) if value: - filters[param] = [v.strip() for v in value.split(',') if v.strip()] - + filters[param] = [v.strip() for v in value.split(",") if v.strip()] + # Handle single value parameters - single_params = ['park_slug', 'park_id'] + single_params = ["park_slug", "park_id"] for param in single_params: value = query_params.get(param) if value: - if param == 'park_id': + if param == "park_id": try: filters[param] = int(value) except ValueError: pass else: filters[param] = value - + # Handle integer parameters int_params = [ - 'opening_year_min', 'opening_year_max', - 'height_requirement_min', 'height_requirement_max', - 'capacity_min', 'capacity_max', - 'inversions_min', 'inversions_max' + "opening_year_min", + "opening_year_max", + "height_requirement_min", + "height_requirement_max", + "capacity_min", + "capacity_max", + "inversions_min", + "inversions_max", ] for param in int_params: value = query_params.get(param) @@ -1643,9 +2147,16 @@ class HybridRideAPIView(APIView): filters[param] = int(value) except ValueError: pass # Skip invalid integer values - + # Handle float parameters - float_params = ['rating_min', 'rating_max', 'height_ft_min', 'height_ft_max', 'speed_mph_min', 'speed_mph_max'] + float_params = [ + "rating_min", + "rating_max", + "height_ft_min", + "height_ft_max", + "speed_mph_min", + "speed_mph_max", + ] for param in float_params: value = query_params.get(param) if value: @@ -1653,20 +2164,20 @@ class HybridRideAPIView(APIView): filters[param] = float(value) except ValueError: pass # Skip invalid float values - + # Handle boolean parameters - has_inversions = query_params.get('has_inversions') + has_inversions = query_params.get("has_inversions") if has_inversions is not None: - if has_inversions.lower() in ['true', '1', 'yes']: - filters['has_inversions'] = True - elif has_inversions.lower() in ['false', '0', 'no']: - filters['has_inversions'] = False - + if has_inversions.lower() in ["true", "1", "yes"]: + filters["has_inversions"] = True + elif has_inversions.lower() in ["false", "0", "no"]: + filters["has_inversions"] = False + # Handle search parameter - search = query_params.get('search') + search = query_params.get("search") if search: - filters['search'] = search.strip() - + filters["search"] = search.strip() + return filters @@ -1675,7 +2186,11 @@ class HybridRideAPIView(APIView): summary="Get ride filter metadata", description="Get available filter options and ranges for rides filtering.", parameters=[ - OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"), + OpenApiParameter( + "scoped", + OpenApiTypes.BOOL, + description="Whether to scope metadata to current filters", + ), ], responses={ 200: { @@ -1688,20 +2203,35 @@ class HybridRideAPIView(APIView): "categorical": { "type": "object", "properties": { - "categories": {"type": "array", "items": {"type": "string"}}, - "statuses": {"type": "array", "items": {"type": "string"}}, - "roller_coaster_types": {"type": "array", "items": {"type": "string"}}, - "track_materials": {"type": "array", "items": {"type": "string"}}, - "propulsion_systems": {"type": "array", "items": {"type": "string"}}, + "categories": { + "type": "array", + "items": {"type": "string"}, + }, + "statuses": { + "type": "array", + "items": {"type": "string"}, + }, + "roller_coaster_types": { + "type": "array", + "items": {"type": "string"}, + }, + "track_materials": { + "type": "array", + "items": {"type": "string"}, + }, + "propulsion_systems": { + "type": "array", + "items": {"type": "string"}, + }, "parks": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, - "slug": {"type": "string"} - } - } + "slug": {"type": "string"}, + }, + }, }, "manufacturers": { "type": "array", @@ -1709,9 +2239,9 @@ class HybridRideAPIView(APIView): "type": "object", "properties": { "name": {"type": "string"}, - "slug": {"type": "string"} - } - } + "slug": {"type": "string"}, + }, + }, }, "designers": { "type": "array", @@ -1719,11 +2249,11 @@ class HybridRideAPIView(APIView): "type": "object", "properties": { "name": {"type": "string"}, - "slug": {"type": "string"} - } - } - } - } + "slug": {"type": "string"}, + }, + }, + }, + }, }, "ranges": { "type": "object", @@ -1731,59 +2261,101 @@ class HybridRideAPIView(APIView): "opening_year": { "type": "object", "properties": { - "min": {"type": "integer", "nullable": True}, - "max": {"type": "integer", "nullable": True} - } + "min": { + "type": "integer", + "nullable": True, + }, + "max": { + "type": "integer", + "nullable": True, + }, + }, }, "rating": { "type": "object", "properties": { - "min": {"type": "number", "nullable": True}, - "max": {"type": "number", "nullable": True} - } + "min": { + "type": "number", + "nullable": True, + }, + "max": { + "type": "number", + "nullable": True, + }, + }, }, "height_requirement": { "type": "object", "properties": { - "min": {"type": "integer", "nullable": True}, - "max": {"type": "integer", "nullable": True} - } + "min": { + "type": "integer", + "nullable": True, + }, + "max": { + "type": "integer", + "nullable": True, + }, + }, }, "capacity": { "type": "object", "properties": { - "min": {"type": "integer", "nullable": True}, - "max": {"type": "integer", "nullable": True} - } + "min": { + "type": "integer", + "nullable": True, + }, + "max": { + "type": "integer", + "nullable": True, + }, + }, }, "height_ft": { "type": "object", "properties": { - "min": {"type": "number", "nullable": True}, - "max": {"type": "number", "nullable": True} - } + "min": { + "type": "number", + "nullable": True, + }, + "max": { + "type": "number", + "nullable": True, + }, + }, }, "speed_mph": { "type": "object", "properties": { - "min": {"type": "number", "nullable": True}, - "max": {"type": "number", "nullable": True} - } + "min": { + "type": "number", + "nullable": True, + }, + "max": { + "type": "number", + "nullable": True, + }, + }, }, "inversions": { "type": "object", "properties": { - "min": {"type": "integer", "nullable": True}, - "max": {"type": "integer", "nullable": True} - } - } - } + "min": { + "type": "integer", + "nullable": True, + }, + "max": { + "type": "integer", + "nullable": True, + }, + }, + }, + }, }, - "total_count": {"type": "integer"} - } + "total_count": {"type": "integer"}, + }, } } - } + }, } }, tags=["Rides"], @@ -1792,35 +2364,35 @@ class HybridRideAPIView(APIView): class RideFilterMetadataAPIView(APIView): """ API view for getting ride filter metadata. - + Provides information about available filter options and ranges to help build dynamic filter interfaces. """ - + permission_classes = [permissions.AllowAny] - + def get(self, request): """Get ride filter metadata.""" try: # Check if metadata should be scoped to current filters - scoped = request.query_params.get('scoped', '').lower() == 'true' + scoped = request.query_params.get("scoped", "").lower() == "true" filters = None - + if scoped: filters = self._extract_filters(request.query_params) - + # Get filter metadata metadata = smart_ride_loader.get_filter_metadata(filters) - + return Response(metadata, status=status.HTTP_200_OK) - + except Exception as e: logger.error(f"Error in RideFilterMetadataAPIView: {e}") return Response( {"error": "Internal server error"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - + def _extract_filters(self, query_params): """Extract and parse filters from query parameters.""" # Reuse the same filter extraction logic diff --git a/backend/apps/api/v1/serializers/maps.py b/backend/apps/api/v1/serializers/maps.py index 3ae5f118..b3114128 100644 --- a/backend/apps/api/v1/serializers/maps.py +++ b/backend/apps/api/v1/serializers/maps.py @@ -365,7 +365,7 @@ class MapLocationDetailSerializer(serializers.Serializer): @extend_schema_field(serializers.ListField(child=serializers.DictField())) def get_nearby_locations(self, obj) -> list: """Get nearby locations (placeholder for now).""" - # TODO: Implement nearby location logic + # TODO(THRILLWIKI-107): Implement nearby location logic using spatial queries return [] diff --git a/backend/apps/core/apps.py b/backend/apps/core/apps.py index ab0051ed..445afcc7 100644 --- a/backend/apps/core/apps.py +++ b/backend/apps/core/apps.py @@ -4,3 +4,12 @@ from django.apps import AppConfig class CoreConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.core" + + def ready(self): + """ + Application initialization. + + Imports security checks to register them with Django's check framework. + """ + # Import security checks to register them + from . import checks # noqa: F401 diff --git a/backend/apps/core/checks.py b/backend/apps/core/checks.py new file mode 100644 index 00000000..10b57ccc --- /dev/null +++ b/backend/apps/core/checks.py @@ -0,0 +1,372 @@ +""" +Django System Checks for Security Configuration. + +This module implements Django system checks that validate security settings +at startup. These checks help catch security misconfigurations before +deployment. + +Usage: + These checks run automatically when Django starts. They can also be run + manually with: python manage.py check --tag=security + +Security checks included: +- SECRET_KEY validation (not default, sufficient entropy) +- DEBUG mode check (should be False in production) +- ALLOWED_HOSTS check (should be configured in production) +- Security headers validation +- HTTPS settings validation +- Cookie security settings +""" + +import os +import re +from django.conf import settings +from django.core.checks import Error, Warning, register, Tags + + +# ============================================================================= +# Secret Key Validation +# ============================================================================= + +@register(Tags.security) +def check_secret_key(app_configs, **kwargs): + """ + Check that SECRET_KEY is properly configured. + + Validates: + - Key is not a known default/placeholder value + - Key has sufficient entropy (length and character variety) + """ + errors = [] + secret_key = getattr(settings, 'SECRET_KEY', '') + + # Check for empty or missing key + if not secret_key: + errors.append( + Error( + 'SECRET_KEY is not set.', + hint='Set a strong, random SECRET_KEY in your environment.', + id='security.E001', + ) + ) + return errors + + # Check for known insecure default values + insecure_defaults = [ + 'django-insecure', + 'your-secret-key', + 'change-me', + 'changeme', + 'secret', + 'xxx', + 'test', + 'development', + 'dev-key', + ] + + key_lower = secret_key.lower() + for default in insecure_defaults: + if default in key_lower: + errors.append( + Error( + f'SECRET_KEY appears to contain an insecure default value: "{default}"', + hint='Generate a new secret key using: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"', + id='security.E002', + ) + ) + break + + # Check minimum length (Django recommends at least 50 characters) + if len(secret_key) < 50: + errors.append( + Warning( + f'SECRET_KEY is only {len(secret_key)} characters long.', + hint='A secret key should be at least 50 characters for proper security.', + id='security.W001', + ) + ) + + # Check for sufficient character variety + has_upper = bool(re.search(r'[A-Z]', secret_key)) + has_lower = bool(re.search(r'[a-z]', secret_key)) + has_digit = bool(re.search(r'[0-9]', secret_key)) + has_special = bool(re.search(r'[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]', secret_key)) + + char_types = sum([has_upper, has_lower, has_digit, has_special]) + if char_types < 3: + errors.append( + Warning( + 'SECRET_KEY lacks character variety.', + hint='A good secret key should contain uppercase, lowercase, digits, and special characters.', + id='security.W002', + ) + ) + + return errors + + +# ============================================================================= +# Debug Mode Check +# ============================================================================= + +@register(Tags.security) +def check_debug_mode(app_configs, **kwargs): + """ + Check that DEBUG is False in production-like environments. + """ + errors = [] + + # Check if we're in a production-like environment + env = os.environ.get('DJANGO_SETTINGS_MODULE', '') + is_production = 'production' in env.lower() or 'prod' in env.lower() + + if is_production and settings.DEBUG: + errors.append( + Error( + 'DEBUG is True in what appears to be a production environment.', + hint='Set DEBUG=False in production settings.', + id='security.E003', + ) + ) + + # Also check if DEBUG is True with ALLOWED_HOSTS configured + # (indicates possible production deployment with debug on) + if settings.DEBUG and settings.ALLOWED_HOSTS and '*' not in settings.ALLOWED_HOSTS: + if len(settings.ALLOWED_HOSTS) > 0 and 'localhost' not in settings.ALLOWED_HOSTS[0]: + errors.append( + Warning( + 'DEBUG is True but ALLOWED_HOSTS contains non-localhost values.', + hint='This may indicate DEBUG is accidentally enabled in a deployed environment.', + id='security.W003', + ) + ) + + return errors + + +# ============================================================================= +# ALLOWED_HOSTS Check +# ============================================================================= + +@register(Tags.security) +def check_allowed_hosts(app_configs, **kwargs): + """ + Check ALLOWED_HOSTS configuration. + """ + errors = [] + allowed_hosts = getattr(settings, 'ALLOWED_HOSTS', []) + + if not settings.DEBUG: + # In non-debug mode, ALLOWED_HOSTS must be set + if not allowed_hosts: + errors.append( + Error( + 'ALLOWED_HOSTS is empty but DEBUG is False.', + hint='Set ALLOWED_HOSTS to a list of allowed hostnames.', + id='security.E004', + ) + ) + elif '*' in allowed_hosts: + errors.append( + Error( + 'ALLOWED_HOSTS contains "*" which allows all hosts.', + hint='Specify explicit hostnames instead of wildcards.', + id='security.E005', + ) + ) + + return errors + + +# ============================================================================= +# Security Headers Check +# ============================================================================= + +@register(Tags.security) +def check_security_headers(app_configs, **kwargs): + """ + Check that security headers are properly configured. + """ + errors = [] + + # Check X-Frame-Options + x_frame_options = getattr(settings, 'X_FRAME_OPTIONS', None) + if x_frame_options not in ('DENY', 'SAMEORIGIN'): + errors.append( + Warning( + f'X_FRAME_OPTIONS is set to "{x_frame_options}" or not set.', + hint='Set X_FRAME_OPTIONS to "DENY" or "SAMEORIGIN" to prevent clickjacking.', + id='security.W004', + ) + ) + + # Check content type sniffing protection + if not getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False): + errors.append( + Warning( + 'SECURE_CONTENT_TYPE_NOSNIFF is not enabled.', + hint='Set SECURE_CONTENT_TYPE_NOSNIFF = True to prevent MIME type sniffing.', + id='security.W005', + ) + ) + + # Check referrer policy + referrer_policy = getattr(settings, 'SECURE_REFERRER_POLICY', None) + if not referrer_policy: + errors.append( + Warning( + 'SECURE_REFERRER_POLICY is not set.', + hint='Set SECURE_REFERRER_POLICY to control referrer header behavior.', + id='security.W006', + ) + ) + + return errors + + +# ============================================================================= +# HTTPS Settings Check +# ============================================================================= + +@register(Tags.security) +def check_https_settings(app_configs, **kwargs): + """ + Check HTTPS-related security settings for production. + """ + errors = [] + + # Skip these checks in debug mode + if settings.DEBUG: + return errors + + # Check SSL redirect + if not getattr(settings, 'SECURE_SSL_REDIRECT', False): + errors.append( + Warning( + 'SECURE_SSL_REDIRECT is not enabled.', + hint='Set SECURE_SSL_REDIRECT = True to redirect HTTP to HTTPS.', + id='security.W007', + ) + ) + + # Check HSTS settings + hsts_seconds = getattr(settings, 'SECURE_HSTS_SECONDS', 0) + if hsts_seconds < 31536000: # Less than 1 year + errors.append( + Warning( + f'SECURE_HSTS_SECONDS is {hsts_seconds} (less than 1 year).', + hint='Set SECURE_HSTS_SECONDS to at least 31536000 (1 year) for HSTS preload eligibility.', + id='security.W008', + ) + ) + + if not getattr(settings, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False): + errors.append( + Warning( + 'SECURE_HSTS_INCLUDE_SUBDOMAINS is not enabled.', + hint='Set SECURE_HSTS_INCLUDE_SUBDOMAINS = True to include all subdomains in HSTS.', + id='security.W009', + ) + ) + + return errors + + +# ============================================================================= +# Cookie Security Check +# ============================================================================= + +@register(Tags.security) +def check_cookie_security(app_configs, **kwargs): + """ + Check cookie security settings for production. + """ + errors = [] + + # Skip in debug mode + if settings.DEBUG: + return errors + + # Check session cookie security + if not getattr(settings, 'SESSION_COOKIE_SECURE', False): + errors.append( + Warning( + 'SESSION_COOKIE_SECURE is not enabled.', + hint='Set SESSION_COOKIE_SECURE = True to only send session cookies over HTTPS.', + id='security.W010', + ) + ) + + if not getattr(settings, 'SESSION_COOKIE_HTTPONLY', True): + errors.append( + Warning( + 'SESSION_COOKIE_HTTPONLY is disabled.', + hint='Set SESSION_COOKIE_HTTPONLY = True to prevent JavaScript access to session cookies.', + id='security.W011', + ) + ) + + # Check CSRF cookie security + if not getattr(settings, 'CSRF_COOKIE_SECURE', False): + errors.append( + Warning( + 'CSRF_COOKIE_SECURE is not enabled.', + hint='Set CSRF_COOKIE_SECURE = True to only send CSRF cookies over HTTPS.', + id='security.W012', + ) + ) + + # Check SameSite attributes + session_samesite = getattr(settings, 'SESSION_COOKIE_SAMESITE', 'Lax') + if session_samesite not in ('Strict', 'Lax'): + errors.append( + Warning( + f'SESSION_COOKIE_SAMESITE is set to "{session_samesite}".', + hint='Set SESSION_COOKIE_SAMESITE to "Strict" or "Lax" for CSRF protection.', + id='security.W013', + ) + ) + + return errors + + +# ============================================================================= +# Database Security Check +# ============================================================================= + +@register(Tags.security) +def check_database_security(app_configs, **kwargs): + """ + Check database connection security settings. + """ + errors = [] + + # Skip in debug mode + if settings.DEBUG: + return errors + + databases = getattr(settings, 'DATABASES', {}) + default_db = databases.get('default', {}) + + # Check for empty password + if not default_db.get('PASSWORD') and default_db.get('ENGINE', '').endswith('postgresql'): + errors.append( + Warning( + 'Database password is empty.', + hint='Set a strong password for database authentication.', + id='security.W014', + ) + ) + + # Check for SSL mode in PostgreSQL + options = default_db.get('OPTIONS', {}) + if 'sslmode' not in str(options) and default_db.get('ENGINE', '').endswith('postgresql'): + errors.append( + Warning( + 'Database SSL mode is not explicitly configured.', + hint='Consider setting sslmode in database OPTIONS for encrypted connections.', + id='security.W015', + ) + ) + + return errors diff --git a/backend/apps/core/exceptions.py b/backend/apps/core/exceptions.py index 2a8c9e9e..5a31445f 100644 --- a/backend/apps/core/exceptions.py +++ b/backend/apps/core/exceptions.py @@ -65,6 +65,14 @@ class BusinessLogicError(ThrillWikiException): status_code = 400 +class ServiceError(ThrillWikiException): + """Raised when a service operation fails.""" + + default_message = "Service operation failed" + error_code = "SERVICE_ERROR" + status_code = 500 + + class ExternalServiceError(ThrillWikiException): """Raised when external service calls fail.""" diff --git a/backend/apps/core/management/commands/security_audit.py b/backend/apps/core/management/commands/security_audit.py new file mode 100644 index 00000000..fa36c521 --- /dev/null +++ b/backend/apps/core/management/commands/security_audit.py @@ -0,0 +1,240 @@ +""" +Security Audit Management Command. + +Runs comprehensive security checks on the Django application and generates +a security audit report. + +Usage: + python manage.py security_audit + python manage.py security_audit --output report.txt + python manage.py security_audit --verbose +""" + +from django.core.management.base import BaseCommand +from django.core.checks import registry, Tags +from django.conf import settings + + +class Command(BaseCommand): + help = 'Run security audit and generate a report' + + def add_arguments(self, parser): + parser.add_argument( + '--output', + type=str, + help='Output file for the security report', + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Show detailed information for each check', + ) + + def handle(self, *args, **options): + self.verbose = options.get('verbose', False) + output_file = options.get('output') + + report_lines = [] + + self.log("=" * 60, report_lines) + self.log("ThrillWiki Security Audit Report", report_lines) + self.log("=" * 60, report_lines) + self.log("", report_lines) + + # Run Django's built-in security checks + self.log("Running Django Security Checks...", report_lines) + self.log("-" * 40, report_lines) + self.run_django_checks(report_lines) + + # Run custom configuration checks + self.log("", report_lines) + self.log("Configuration Analysis...", report_lines) + self.log("-" * 40, report_lines) + self.check_configuration(report_lines) + + # Run middleware checks + self.log("", report_lines) + self.log("Middleware Analysis...", report_lines) + self.log("-" * 40, report_lines) + self.check_middleware(report_lines) + + # Summary + self.log("", report_lines) + self.log("=" * 60, report_lines) + self.log("Audit Complete", report_lines) + self.log("=" * 60, report_lines) + + # Write to file if specified + if output_file: + with open(output_file, 'w') as f: + f.write('\n'.join(report_lines)) + self.stdout.write( + self.style.SUCCESS(f'\nReport saved to: {output_file}') + ) + + def log(self, message, report_lines): + """Log message to both stdout and report.""" + self.stdout.write(message) + report_lines.append(message) + + def run_django_checks(self, report_lines): + """Run Django's security checks.""" + errors = registry.run_checks(tags=[Tags.security]) + + if not errors: + self.log( + self.style.SUCCESS(" ✓ All Django security checks passed"), + report_lines + ) + else: + for error in errors: + if error.is_serious(): + prefix = self.style.ERROR(" ✗ ERROR") + else: + prefix = self.style.WARNING(" ! WARNING") + + self.log(f"{prefix}: {error.msg}", report_lines) + if error.hint and self.verbose: + self.log(f" Hint: {error.hint}", report_lines) + + def check_configuration(self, report_lines): + """Check various configuration settings.""" + checks = [ + ('DEBUG mode', not settings.DEBUG, 'DEBUG should be False'), + ( + 'SECRET_KEY length', + len(settings.SECRET_KEY) >= 50, + f'Length: {len(settings.SECRET_KEY)}' + ), + ( + 'ALLOWED_HOSTS', + bool(settings.ALLOWED_HOSTS) and '*' not in settings.ALLOWED_HOSTS, + str(settings.ALLOWED_HOSTS) + ), + ( + 'CSRF_TRUSTED_ORIGINS', + bool(getattr(settings, 'CSRF_TRUSTED_ORIGINS', [])), + str(getattr(settings, 'CSRF_TRUSTED_ORIGINS', [])) + ), + ( + 'X_FRAME_OPTIONS', + getattr(settings, 'X_FRAME_OPTIONS', '') in ('DENY', 'SAMEORIGIN'), + str(getattr(settings, 'X_FRAME_OPTIONS', 'Not set')) + ), + ( + 'SECURE_CONTENT_TYPE_NOSNIFF', + getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False), + str(getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False)) + ), + ( + 'SECURE_BROWSER_XSS_FILTER', + getattr(settings, 'SECURE_BROWSER_XSS_FILTER', False), + str(getattr(settings, 'SECURE_BROWSER_XSS_FILTER', False)) + ), + ( + 'SESSION_COOKIE_HTTPONLY', + getattr(settings, 'SESSION_COOKIE_HTTPONLY', True), + str(getattr(settings, 'SESSION_COOKIE_HTTPONLY', 'Not set')) + ), + ( + 'CSRF_COOKIE_HTTPONLY', + getattr(settings, 'CSRF_COOKIE_HTTPONLY', True), + str(getattr(settings, 'CSRF_COOKIE_HTTPONLY', 'Not set')) + ), + ] + + # Production-only checks + if not settings.DEBUG: + checks.extend([ + ( + 'SECURE_SSL_REDIRECT', + getattr(settings, 'SECURE_SSL_REDIRECT', False), + str(getattr(settings, 'SECURE_SSL_REDIRECT', False)) + ), + ( + 'SESSION_COOKIE_SECURE', + getattr(settings, 'SESSION_COOKIE_SECURE', False), + str(getattr(settings, 'SESSION_COOKIE_SECURE', False)) + ), + ( + 'CSRF_COOKIE_SECURE', + getattr(settings, 'CSRF_COOKIE_SECURE', False), + str(getattr(settings, 'CSRF_COOKIE_SECURE', False)) + ), + ( + 'SECURE_HSTS_SECONDS', + getattr(settings, 'SECURE_HSTS_SECONDS', 0) >= 31536000, + str(getattr(settings, 'SECURE_HSTS_SECONDS', 0)) + ), + ]) + + for name, is_secure, value in checks: + if is_secure: + status = self.style.SUCCESS("✓") + else: + status = self.style.WARNING("!") + + msg = f" {status} {name}" + if self.verbose: + msg += f" ({value})" + + self.log(msg, report_lines) + + def check_middleware(self, report_lines): + """Check security-related middleware is properly configured.""" + middleware = getattr(settings, 'MIDDLEWARE', []) + + required_middleware = [ + ('django.middleware.security.SecurityMiddleware', 'SecurityMiddleware'), + ('django.middleware.csrf.CsrfViewMiddleware', 'CSRF Middleware'), + ('django.middleware.clickjacking.XFrameOptionsMiddleware', 'X-Frame-Options'), + ] + + custom_security_middleware = [ + ('apps.core.middleware.security_headers.SecurityHeadersMiddleware', 'Security Headers'), + ('apps.core.middleware.rate_limiting.AuthRateLimitMiddleware', 'Rate Limiting'), + ] + + # Check required middleware + for mw_path, mw_name in required_middleware: + if mw_path in middleware: + self.log( + f" {self.style.SUCCESS('✓')} {mw_name} is enabled", + report_lines + ) + else: + self.log( + f" {self.style.ERROR('✗')} {mw_name} is NOT enabled", + report_lines + ) + + # Check custom security middleware + for mw_path, mw_name in custom_security_middleware: + if mw_path in middleware: + self.log( + f" {self.style.SUCCESS('✓')} {mw_name} is enabled", + report_lines + ) + else: + self.log( + f" {self.style.WARNING('!')} {mw_name} is not enabled (optional)", + report_lines + ) + + # Check middleware order + try: + security_idx = middleware.index('django.middleware.security.SecurityMiddleware') + session_idx = middleware.index('django.contrib.sessions.middleware.SessionMiddleware') + + if security_idx < session_idx: + self.log( + f" {self.style.SUCCESS('✓')} Middleware ordering is correct", + report_lines + ) + else: + self.log( + f" {self.style.WARNING('!')} SecurityMiddleware should come before SessionMiddleware", + report_lines + ) + except ValueError: + pass # Middleware not found, already reported above diff --git a/backend/apps/core/middleware/rate_limiting.py b/backend/apps/core/middleware/rate_limiting.py new file mode 100644 index 00000000..defeb407 --- /dev/null +++ b/backend/apps/core/middleware/rate_limiting.py @@ -0,0 +1,253 @@ +""" +Rate Limiting Middleware for ThrillWiki. + +This middleware provides rate limiting for authentication endpoints to prevent +brute force attacks, credential stuffing, and account enumeration. + +Security Note: + Rate limiting is applied at the IP level and user level (if authenticated). + Limits are configurable and should be adjusted based on actual usage patterns. + +Usage: + Add 'apps.core.middleware.rate_limiting.AuthRateLimitMiddleware' + to MIDDLEWARE in settings.py. +""" + +import logging +from typing import Callable, Optional, Tuple + +from django.core.cache import cache +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class AuthRateLimitMiddleware: + """ + Middleware that rate limits authentication-related endpoints. + + Protects against: + - Brute force login attacks + - Password reset abuse + - Account enumeration through timing attacks + """ + + # Endpoints to rate limit + RATE_LIMITED_PATHS = { + # Login endpoints + '/api/v1/auth/login/': {'per_minute': 5, 'per_hour': 30, 'per_day': 100}, + '/accounts/login/': {'per_minute': 5, 'per_hour': 30, 'per_day': 100}, + + # Signup endpoints + '/api/v1/auth/signup/': {'per_minute': 3, 'per_hour': 10, 'per_day': 20}, + '/accounts/signup/': {'per_minute': 3, 'per_hour': 10, 'per_day': 20}, + + # Password reset endpoints + '/api/v1/auth/password-reset/': {'per_minute': 2, 'per_hour': 5, 'per_day': 10}, + '/accounts/password/reset/': {'per_minute': 2, 'per_hour': 5, 'per_day': 10}, + + # Token endpoints + '/api/v1/auth/token/': {'per_minute': 10, 'per_hour': 60, 'per_day': 200}, + '/api/v1/auth/token/refresh/': {'per_minute': 20, 'per_hour': 120, 'per_day': 500}, + } + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + # Only rate limit POST requests to auth endpoints + if request.method != 'POST': + return self.get_response(request) + + # Check if this path should be rate limited + limits = self._get_rate_limits(request.path) + if not limits: + return self.get_response(request) + + # Get client identifier (IP address) + client_ip = self._get_client_ip(request) + + # Check rate limits + is_allowed, message = self._check_rate_limits( + client_ip, request.path, limits + ) + + if not is_allowed: + logger.warning( + f"Rate limit exceeded for {client_ip} on {request.path}" + ) + return self._rate_limit_response(message) + + # Process request + response = self.get_response(request) + + # Only increment counter for failed auth attempts (non-2xx responses) + if response.status_code >= 400: + self._increment_counters(client_ip, request.path) + + return response + + def _get_rate_limits(self, path: str) -> Optional[dict]: + """Get rate limits for a path, if any.""" + # Exact match + if path in self.RATE_LIMITED_PATHS: + return self.RATE_LIMITED_PATHS[path] + + # Prefix match (for paths with trailing slashes) + path_without_slash = path.rstrip('/') + for limited_path, limits in self.RATE_LIMITED_PATHS.items(): + if path_without_slash == limited_path.rstrip('/'): + return limits + + return None + + def _get_client_ip(self, request: HttpRequest) -> str: + """ + Get the client's IP address from the request. + + Handles common proxy headers (X-Forwarded-For, X-Real-IP). + """ + # Check for forwarded headers (set by reverse proxies) + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + # Take the first IP in the chain (client IP) + return x_forwarded_for.split(',')[0].strip() + + x_real_ip = request.META.get('HTTP_X_REAL_IP') + if x_real_ip: + return x_real_ip + + return request.META.get('REMOTE_ADDR', 'unknown') + + def _check_rate_limits( + self, + client_ip: str, + path: str, + limits: dict + ) -> Tuple[bool, str]: + """ + Check if the client has exceeded rate limits. + + Returns: + Tuple of (is_allowed, reason_if_blocked) + """ + # Create a safe cache key from path + path_key = path.replace('/', '_').strip('_') + + # Check per-minute limit + minute_key = f"auth_rate:{client_ip}:{path_key}:minute" + minute_count = cache.get(minute_key, 0) + if minute_count >= limits.get('per_minute', 10): + return False, "Too many requests. Please wait a minute before trying again." + + # Check per-hour limit + hour_key = f"auth_rate:{client_ip}:{path_key}:hour" + hour_count = cache.get(hour_key, 0) + if hour_count >= limits.get('per_hour', 60): + return False, "Too many requests. Please try again later." + + # Check per-day limit + day_key = f"auth_rate:{client_ip}:{path_key}:day" + day_count = cache.get(day_key, 0) + if day_count >= limits.get('per_day', 200): + return False, "Daily limit exceeded. Please try again tomorrow." + + return True, "" + + def _increment_counters(self, client_ip: str, path: str) -> None: + """Increment rate limit counters.""" + path_key = path.replace('/', '_').strip('_') + + # Increment per-minute counter + minute_key = f"auth_rate:{client_ip}:{path_key}:minute" + try: + cache.incr(minute_key) + except ValueError: + cache.set(minute_key, 1, 60) + + # Increment per-hour counter + hour_key = f"auth_rate:{client_ip}:{path_key}:hour" + try: + cache.incr(hour_key) + except ValueError: + cache.set(hour_key, 1, 3600) + + # Increment per-day counter + day_key = f"auth_rate:{client_ip}:{path_key}:day" + try: + cache.incr(day_key) + except ValueError: + cache.set(day_key, 1, 86400) + + def _rate_limit_response(self, message: str) -> JsonResponse: + """Generate a rate limit exceeded response.""" + return JsonResponse( + { + 'error': message, + 'code': 'RATE_LIMIT_EXCEEDED', + }, + status=429, # Too Many Requests + ) + + +class SecurityEventLogger: + """ + Utility class for logging security-relevant events. + + Use this to log: + - Failed authentication attempts + - Permission denied events + - Suspicious activity + """ + + @staticmethod + def log_failed_login( + request: HttpRequest, + username: str, + reason: str = "Invalid credentials" + ) -> None: + """Log a failed login attempt.""" + client_ip = AuthRateLimitMiddleware._get_client_ip( + AuthRateLimitMiddleware, request + ) + logger.warning( + f"Failed login attempt - IP: {client_ip}, Username: {username}, " + f"Reason: {reason}, User-Agent: {request.META.get('HTTP_USER_AGENT', 'unknown')}" + ) + + @staticmethod + def log_permission_denied( + request: HttpRequest, + resource: str, + action: str = "access" + ) -> None: + """Log a permission denied event.""" + client_ip = AuthRateLimitMiddleware._get_client_ip( + AuthRateLimitMiddleware, request + ) + user = getattr(request, 'user', None) + username = user.username if user and user.is_authenticated else 'anonymous' + + logger.warning( + f"Permission denied - IP: {client_ip}, User: {username}, " + f"Resource: {resource}, Action: {action}" + ) + + @staticmethod + def log_suspicious_activity( + request: HttpRequest, + activity_type: str, + details: str = "" + ) -> None: + """Log suspicious activity.""" + client_ip = AuthRateLimitMiddleware._get_client_ip( + AuthRateLimitMiddleware, request + ) + user = getattr(request, 'user', None) + username = user.username if user and user.is_authenticated else 'anonymous' + + logger.error( + f"Suspicious activity detected - Type: {activity_type}, " + f"IP: {client_ip}, User: {username}, Details: {details}" + ) diff --git a/backend/apps/core/middleware/request_logging.py b/backend/apps/core/middleware/request_logging.py index db4cb391..b4e65611 100644 --- a/backend/apps/core/middleware/request_logging.py +++ b/backend/apps/core/middleware/request_logging.py @@ -114,18 +114,52 @@ class RequestLoggingMiddleware(MiddlewareMixin): return response + # Sensitive field patterns that should be masked in logs + # Security: Comprehensive list of sensitive data patterns + SENSITIVE_PATTERNS = [ + 'password', + 'passwd', + 'pwd', + 'token', + 'secret', + 'key', + 'api_key', + 'apikey', + 'auth', + 'authorization', + 'credential', + 'ssn', + 'social_security', + 'credit_card', + 'creditcard', + 'card_number', + 'cvv', + 'cvc', + 'pin', + 'access_token', + 'refresh_token', + 'jwt', + 'session', + 'cookie', + 'private', + ] + def _safe_log_data(self, data): - """Safely log data, truncating if too large and masking sensitive fields.""" + """ + Safely log data, truncating if too large and masking sensitive fields. + + Security measures: + - Masks all sensitive field names + - Masks email addresses (shows only domain) + - Truncates long values to prevent log flooding + - Recursively processes nested dictionaries and lists + """ try: - # Convert to string representation if isinstance(data, dict): - # Mask sensitive fields - safe_data = {} - for key, value in data.items(): - if any(sensitive in key.lower() for sensitive in ['password', 'token', 'secret', 'key']): - safe_data[key] = '***MASKED***' - else: - safe_data[key] = value + safe_data = self._mask_sensitive_dict(data) + data_str = json.dumps(safe_data, indent=2, default=str) + elif isinstance(data, list): + safe_data = [self._mask_sensitive_value(item) for item in data] data_str = json.dumps(safe_data, indent=2, default=str) else: data_str = json.dumps(data, indent=2, default=str) @@ -136,3 +170,37 @@ class RequestLoggingMiddleware(MiddlewareMixin): return data_str except Exception: return str(data)[:500] + '...[ERROR_LOGGING]' + + def _mask_sensitive_dict(self, data, depth=0): + """Recursively mask sensitive fields in a dictionary.""" + if depth > 5: # Prevent infinite recursion + return '***DEPTH_LIMIT***' + + safe_data = {} + for key, value in data.items(): + key_lower = str(key).lower() + + # Check if key contains any sensitive pattern + if any(pattern in key_lower for pattern in self.SENSITIVE_PATTERNS): + safe_data[key] = '***MASKED***' + else: + safe_data[key] = self._mask_sensitive_value(value, depth) + + return safe_data + + def _mask_sensitive_value(self, value, depth=0): + """Mask a single value, handling different types.""" + if isinstance(value, dict): + return self._mask_sensitive_dict(value, depth + 1) + elif isinstance(value, list): + return [self._mask_sensitive_value(item, depth + 1) for item in value[:10]] # Limit list items + elif isinstance(value, str): + # Mask email addresses (show only domain) + if '@' in value and '.' in value.split('@')[-1]: + parts = value.split('@') + if len(parts) == 2: + return f"***@{parts[1]}" + # Truncate long strings + if len(value) > 200: + return value[:200] + '...[TRUNCATED]' + return value diff --git a/backend/apps/core/middleware/security_headers.py b/backend/apps/core/middleware/security_headers.py new file mode 100644 index 00000000..67cf50bb --- /dev/null +++ b/backend/apps/core/middleware/security_headers.py @@ -0,0 +1,196 @@ +""" +Security Headers Middleware for ThrillWiki. + +This middleware adds additional security headers to all HTTP responses, +providing defense-in-depth against common web vulnerabilities. + +Headers added: +- Content-Security-Policy: Controls resource loading to prevent XSS +- Permissions-Policy: Restricts browser feature access +- Cross-Origin-Embedder-Policy: Prevents cross-origin embedding +- Cross-Origin-Resource-Policy: Restricts cross-origin resource access + +Usage: + Add 'apps.core.middleware.security_headers.SecurityHeadersMiddleware' + to MIDDLEWARE in settings.py (after SecurityMiddleware). +""" + +from django.conf import settings + + +class SecurityHeadersMiddleware: + """ + Middleware that adds security headers to HTTP responses. + + This provides defense-in-depth by adding headers that Django's + SecurityMiddleware doesn't handle. + """ + + def __init__(self, get_response): + self.get_response = get_response + # Build CSP header at startup for performance + self._csp_header = self._build_csp_header() + self._permissions_policy_header = self._build_permissions_policy_header() + + def __call__(self, request): + response = self.get_response(request) + return self._add_security_headers(response, request) + + def _add_security_headers(self, response, request): + """Add security headers to the response.""" + # Content-Security-Policy + # Only add CSP for HTML responses to avoid breaking API/JSON responses + content_type = response.get("Content-Type", "") + if "text/html" in content_type: + if not response.get("Content-Security-Policy"): + response["Content-Security-Policy"] = self._csp_header + + # Permissions-Policy (successor to Feature-Policy) + if not response.get("Permissions-Policy"): + response["Permissions-Policy"] = self._permissions_policy_header + + # Cross-Origin-Embedder-Policy + # Requires resources to be CORS-enabled or same-origin + # Using 'unsafe-none' for now as 'require-corp' can break third-party resources + if not response.get("Cross-Origin-Embedder-Policy"): + response["Cross-Origin-Embedder-Policy"] = "unsafe-none" + + # Cross-Origin-Resource-Policy + # Controls how resources can be shared with other origins + if not response.get("Cross-Origin-Resource-Policy"): + response["Cross-Origin-Resource-Policy"] = "same-origin" + + return response + + def _build_csp_header(self): + """ + Build the Content-Security-Policy header value. + + CSP directives explained: + - default-src: Fallback for other fetch directives + - script-src: Sources for JavaScript + - style-src: Sources for CSS + - img-src: Sources for images + - font-src: Sources for fonts + - connect-src: Sources for fetch, XHR, WebSocket + - frame-ancestors: Controls framing (replaces X-Frame-Options) + - form-action: Valid targets for form submissions + - base-uri: Restricts base element URLs + - object-src: Sources for plugins (Flash, etc.) + """ + # Check if we're in debug mode + debug = getattr(settings, "DEBUG", False) + + # Base directives (production-focused) + directives = { + "default-src": ["'self'"], + "script-src": [ + "'self'", + # Allow HTMX inline scripts with nonce (would need nonce middleware) + # For now, using 'unsafe-inline' for HTMX compatibility + "'unsafe-inline'" if debug else "'self'", + # CDNs for external scripts + "https://cdn.jsdelivr.net", + "https://unpkg.com", + "https://challenges.cloudflare.com", # Turnstile + ], + "style-src": [ + "'self'", + "'unsafe-inline'", # Required for Tailwind and inline styles + "https://cdn.jsdelivr.net", + "https://fonts.googleapis.com", + ], + "img-src": [ + "'self'", + "data:", + "blob:", + "https:", # Allow HTTPS images (needed for user uploads, maps, etc.) + ], + "font-src": [ + "'self'", + "https://fonts.gstatic.com", + "https://cdn.jsdelivr.net", + ], + "connect-src": [ + "'self'", + "https://api.forwardemail.net", + "https://challenges.cloudflare.com", + "https://*.cloudflare.com", + # Map tile servers + "https://*.openstreetmap.org", + "https://*.tile.openstreetmap.org", + ], + "frame-src": [ + "'self'", + "https://challenges.cloudflare.com", # Turnstile widget + ], + "frame-ancestors": ["'self'"], + "form-action": ["'self'"], + "base-uri": ["'self'"], + "object-src": ["'none'"], + "upgrade-insecure-requests": [], # Upgrade HTTP to HTTPS + } + + # Add debug-specific relaxations + if debug: + # Allow webpack dev server connections in development + directives["connect-src"].extend([ + "ws://localhost:*", + "http://localhost:*", + "http://127.0.0.1:*", + ]) + + # Build header string + parts = [] + for directive, sources in directives.items(): + if sources: + parts.append(f"{directive} {' '.join(sources)}") + else: + # Directives like upgrade-insecure-requests don't need values + parts.append(directive) + + return "; ".join(parts) + + def _build_permissions_policy_header(self): + """ + Build the Permissions-Policy header value. + + This header controls which browser features the page can use. + """ + # Get permissions policy from settings or use defaults + policy = getattr(settings, "PERMISSIONS_POLICY", { + "accelerometer": [], + "ambient-light-sensor": [], + "autoplay": [], + "camera": [], + "display-capture": [], + "document-domain": [], + "encrypted-media": [], + "fullscreen": ["self"], + "geolocation": ["self"], + "gyroscope": [], + "interest-cohort": [], + "magnetometer": [], + "microphone": [], + "midi": [], + "payment": [], + "picture-in-picture": [], + "publickey-credentials-get": [], + "screen-wake-lock": [], + "sync-xhr": [], + "usb": [], + "web-share": ["self"], + "xr-spatial-tracking": [], + }) + + parts = [] + for feature, allowlist in policy.items(): + if not allowlist: + # Empty list means disallow completely + parts.append(f"{feature}=()") + else: + # Convert allowlist to proper format + formatted = " ".join(allowlist) + parts.append(f"{feature}=({formatted})") + + return ", ".join(parts) diff --git a/backend/apps/core/services/data_structures.py b/backend/apps/core/services/data_structures.py index 40fbb2a6..6fad33c9 100644 --- a/backend/apps/core/services/data_structures.py +++ b/backend/apps/core/services/data_structures.py @@ -223,7 +223,7 @@ class MapResponse: "query_time_ms": self.query_time_ms, "filters_applied": self.filters_applied, "pagination": { - "has_more": False, # TODO: Implement pagination + "has_more": False, # TODO(THRILLWIKI-102): Implement pagination for map data "total_pages": 1, }, }, diff --git a/backend/apps/core/services/location_adapters.py b/backend/apps/core/services/location_adapters.py index f6f905f8..cd8f6c8a 100644 --- a/backend/apps/core/services/location_adapters.py +++ b/backend/apps/core/services/location_adapters.py @@ -297,7 +297,7 @@ class CompanyLocationAdapter(BaseLocationAdapter): """Convert CompanyHeadquarters to UnifiedLocation.""" # Note: CompanyHeadquarters doesn't have coordinates, so we need to geocode # For now, we'll skip companies without coordinates - # TODO: Implement geocoding service integration + # TODO(THRILLWIKI-101): Implement geocoding service integration for company HQs return None def get_queryset( diff --git a/backend/apps/core/templatetags/safe_html.py b/backend/apps/core/templatetags/safe_html.py new file mode 100644 index 00000000..6575ce9c --- /dev/null +++ b/backend/apps/core/templatetags/safe_html.py @@ -0,0 +1,275 @@ +""" +Safe HTML Template Tags and Filters for ThrillWiki. + +This module provides template tags and filters for safely rendering +HTML content without XSS vulnerabilities. + +Security Note: + Always use these filters instead of |safe for user-generated content. + The |safe filter should only be used for content that has been + pre-sanitized in the view layer. + +Usage: + {% load safe_html %} + + {# Sanitize user content #} + {{ user_description|sanitize }} + + {# Minimal sanitization for comments #} + {{ comment_text|sanitize_minimal }} + + {# Strip all HTML #} + {{ raw_text|strip_html }} + + {# Safe JSON for JavaScript #} + {{ data|json_safe }} + + {# Render trusted icon SVG #} + {% icon "check" class="w-4 h-4" %} +""" + +import json +from django import template +from django.utils.safestring import mark_safe + +from apps.core.utils.html_sanitizer import ( + sanitize_html, + sanitize_minimal as _sanitize_minimal, + sanitize_svg, + strip_html as _strip_html, + sanitize_for_json, + escape_js_string as _escape_js_string, + sanitize_url as _sanitize_url, + sanitize_attribute_value, +) + +register = template.Library() + + +# ============================================================================= +# HTML Sanitization Filters +# ============================================================================= + +@register.filter(name='sanitize', is_safe=True) +def sanitize_filter(value): + """ + Sanitize HTML content to prevent XSS attacks. + + Allows common formatting tags while stripping dangerous content. + + Usage: + {{ user_content|sanitize }} + """ + if not value: + return '' + return mark_safe(sanitize_html(str(value))) + + +@register.filter(name='sanitize_minimal', is_safe=True) +def sanitize_minimal_filter(value): + """ + Sanitize HTML with minimal allowed tags. + + Only allows basic text formatting: p, br, strong, em, i, b, a + + Usage: + {{ comment|sanitize_minimal }} + """ + if not value: + return '' + return mark_safe(_sanitize_minimal(str(value))) + + +@register.filter(name='sanitize_svg', is_safe=True) +def sanitize_svg_filter(value): + """ + Sanitize SVG content for safe inline rendering. + + Usage: + {{ icon_svg|sanitize_svg }} + """ + if not value: + return '' + return mark_safe(sanitize_svg(str(value))) + + +@register.filter(name='strip_html') +def strip_html_filter(value): + """ + Remove all HTML tags from content. + + Usage: + {{ html_content|strip_html }} + """ + if not value: + return '' + return _strip_html(str(value)) + + +# ============================================================================= +# JavaScript/JSON Context Filters +# ============================================================================= + +@register.filter(name='json_safe', is_safe=True) +def json_safe_filter(value): + """ + Safely serialize data for embedding in JavaScript. + + This is safer than using |safe for JSON data as it properly + escapes and other dangerous sequences. + + Usage: + + """ + if value is None: + return 'null' + return mark_safe(sanitize_for_json(value)) + + +@register.filter(name='escapejs_safe') +def escapejs_safe_filter(value): + """ + Escape a string for safe use in JavaScript string literals. + + Usage: + + """ + if not value: + return '' + return _escape_js_string(str(value)) + + +# ============================================================================= +# URL and Attribute Filters +# ============================================================================= + +@register.filter(name='sanitize_url') +def sanitize_url_filter(value): + """ + Sanitize a URL to prevent javascript: and other dangerous protocols. + + Usage: + Link + """ + if not value: + return '' + return _sanitize_url(str(value)) + + +@register.filter(name='attr_safe') +def attr_safe_filter(value): + """ + Escape a value for safe use in HTML attributes. + + Usage: +
Hello
') + 'Hello
' + """ + if not html: + return '' + + if not isinstance(html, str): + html = str(html) + + if not BLEACH_AVAILABLE: + # Fallback: escape all HTML if bleach is not available + return html_escape(html) + + tags = allowed_tags if allowed_tags is not None else ALLOWED_TAGS + attrs = allowed_attributes if allowed_attributes is not None else ALLOWED_ATTRIBUTES + protocols = allowed_protocols if allowed_protocols is not None else ALLOWED_PROTOCOLS + + return bleach.clean( + html, + tags=tags, + attributes=attrs, + protocols=protocols, + strip=strip, + ) + + +def sanitize_minimal(html: str | None) -> str: + """ + Sanitize HTML with minimal allowed tags. + + Use this for user comments, short descriptions, etc. + + Args: + html: The HTML string to sanitize + + Returns: + Sanitized HTML with only basic formatting tags allowed + """ + return sanitize_html( + html, + allowed_tags=MINIMAL_TAGS, + allowed_attributes={'a': ['href', 'title']}, + ) + + +def sanitize_svg(svg: str | None) -> str: + """ + Sanitize SVG content for safe inline rendering. + + This is specifically for icon SVGs that need to be rendered inline. + Removes potentially dangerous elements while preserving SVG structure. + + Args: + svg: The SVG string to sanitize + + Returns: + Sanitized SVG string safe for inline rendering + """ + if not svg: + return '' + + if not isinstance(svg, str): + svg = str(svg) + + if not BLEACH_AVAILABLE: + # Fallback: escape all if bleach is not available + return html_escape(svg) + + return bleach.clean( + svg, + tags=SVG_TAGS, + attributes=SVG_ATTRIBUTES, + strip=True, + ) + + +def strip_html(html: str | None) -> str: + """ + Remove all HTML tags from a string. + + Use this for contexts where no HTML is allowed at all. + + Args: + html: The HTML string to strip + + Returns: + Plain text with all HTML tags removed + """ + if not html: + return '' + + if not isinstance(html, str): + html = str(html) + + if BLEACH_AVAILABLE: + return bleach.clean(html, tags=[], strip=True) + else: + # Fallback: use regex to strip tags + return re.sub(r'<[^>]+>', '', html) + + +# ============================================================================= +# JSON/JavaScript Context Sanitization +# ============================================================================= + +def sanitize_for_json(data: Any) -> str: + """ + Safely serialize data for embedding in JavaScript/JSON contexts. + + This prevents XSS when embedding data in +{# Security: Safely embed JSON data using Django's json_script tag to prevent XSS #} +{% if nearby_locations %} +{{ nearby_locations|json_script:"nearby-locations-data" }} +{% endif %} +