From c95f99ca105601c48b691bdd0c925d5d6ed2ab50 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:32:53 -0500 Subject: [PATCH] feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application. --- GAP_ANALYSIS_MATRIX.md | 541 +++++++----------- backend/apps/accounts/adapters.py | 7 +- backend/apps/accounts/admin.py | 2 - backend/apps/accounts/choices.py | 3 +- backend/apps/accounts/export_service.py | 22 +- backend/apps/accounts/login_history.py | 106 ++++ .../commands/check_all_social_tables.py | 4 +- .../management/commands/check_social_apps.py | 2 +- .../management/commands/cleanup_test_data.py | 7 +- .../management/commands/create_social_apps.py | 4 +- .../management/commands/create_test_users.py | 2 +- .../management/commands/delete_user.py | 6 +- .../management/commands/fix_social_apps.py | 5 +- .../commands/generate_letter_avatars.py | 3 +- .../management/commands/regenerate_avatars.py | 1 + .../management/commands/reset_social_apps.py | 2 +- .../management/commands/setup_groups.py | 3 +- .../management/commands/setup_site.py | 2 +- .../management/commands/setup_social_auth.py | 9 +- .../commands/setup_social_auth_admin.py | 4 +- .../commands/setup_social_providers.py | 2 +- .../management/commands/test_discord_auth.py | 2 +- .../commands/update_social_apps_sites.py | 2 +- .../commands/verify_discord_settings.py | 2 +- .../migrations/0010_auto_20250830_1657.py | 20 +- ...0011_fix_userprofile_event_avatar_field.py | 6 +- .../0012_alter_toplist_category_and_more.py | 3 +- ...er_remove_toplistitem_top_list_and_more.py | 3 +- ...loginhistory_loginhistoryevent_and_more.py | 184 ++++++ backend/apps/accounts/mixins.py | 14 +- backend/apps/accounts/models.py | 19 +- backend/apps/accounts/selectors.py | 11 +- backend/apps/accounts/serializers.py | 16 +- backend/apps/accounts/services.py | 32 +- .../accounts/services/notification_service.py | 31 +- .../services/social_provider_service.py | 19 +- .../services/user_deletion_service.py | 23 +- backend/apps/accounts/signals.py | 49 +- backend/apps/accounts/tests.py | 6 +- backend/apps/accounts/tests/test_admin.py | 2 - .../accounts/tests/test_model_constraints.py | 3 +- .../apps/accounts/tests/test_user_deletion.py | 16 +- backend/apps/accounts/urls.py | 5 +- backend/apps/accounts/views.py | 83 +-- .../apps/api/management/commands/seed_data.py | 317 +++++----- backend/apps/api/urls.py | 2 +- backend/apps/api/v1/accounts/serializers.py | 5 +- backend/apps/api/v1/accounts/urls.py | 31 +- backend/apps/api/v1/accounts/views.py | 196 ++++--- backend/apps/api/v1/accounts/views_credits.py | 81 ++- .../apps/api/v1/accounts/views_magic_link.py | 180 ++++++ backend/apps/api/v1/auth/mfa.py | 385 +++++++++++++ backend/apps/api/v1/auth/serializers.py | 36 +- .../v1/auth/serializers_package/__init__.py | 8 +- .../api/v1/auth/serializers_package/social.py | 2 +- backend/apps/api/v1/auth/urls.py | 37 +- backend/apps/api/v1/auth/views.py | 92 +-- backend/apps/api/v1/core/urls.py | 1 + backend/apps/api/v1/core/views.py | 24 +- backend/apps/api/v1/email/urls.py | 1 + backend/apps/api/v1/email/views.py | 10 +- backend/apps/api/v1/history/urls.py | 2 +- backend/apps/api/v1/history/views.py | 20 +- backend/apps/api/v1/images/urls.py | 1 + backend/apps/api/v1/images/views.py | 18 +- backend/apps/api/v1/maps/urls.py | 1 + backend/apps/api/v1/maps/views.py | 27 +- backend/apps/api/v1/middleware.py | 118 ++-- backend/apps/api/v1/parks/history_views.py | 20 +- .../apps/api/v1/parks/park_reviews_views.py | 22 +- backend/apps/api/v1/parks/park_rides_views.py | 31 +- backend/apps/api/v1/parks/park_views.py | 117 ++-- .../apps/api/v1/parks/ride_photos_views.py | 41 +- .../apps/api/v1/parks/ride_reviews_views.py | 43 +- backend/apps/api/v1/parks/serializers.py | 47 +- backend/apps/api/v1/parks/urls.py | 59 +- backend/apps/api/v1/parks/views.py | 27 +- backend/apps/api/v1/rides/company_urls.py | 12 + backend/apps/api/v1/rides/company_views.py | 167 ++++++ .../apps/api/v1/rides/manufacturers/urls.py | 16 +- .../apps/api/v1/rides/manufacturers/views.py | 52 +- backend/apps/api/v1/rides/photo_views.py | 25 +- backend/apps/api/v1/rides/serializers.py | 81 +-- backend/apps/api/v1/rides/urls.py | 24 +- backend/apps/api/v1/rides/views.py | 91 +-- backend/apps/api/v1/serializers/__init__.py | 138 ++--- backend/apps/api/v1/serializers/accounts.py | 18 +- backend/apps/api/v1/serializers/auth.py | 6 +- backend/apps/api/v1/serializers/companies.py | 8 +- backend/apps/api/v1/serializers/history.py | 2 +- backend/apps/api/v1/serializers/maps.py | 7 +- backend/apps/api/v1/serializers/media.py | 7 +- backend/apps/api/v1/serializers/other.py | 5 +- .../apps/api/v1/serializers/park_reviews.py | 6 +- backend/apps/api/v1/serializers/parks.py | 14 +- .../apps/api/v1/serializers/parks_media.py | 1 + backend/apps/api/v1/serializers/reviews.py | 3 +- .../apps/api/v1/serializers/ride_credits.py | 14 +- .../apps/api/v1/serializers/ride_models.py | 11 +- .../apps/api/v1/serializers/ride_reviews.py | 8 +- backend/apps/api/v1/serializers/rides.py | 13 +- .../apps/api/v1/serializers/rides_media.py | 1 + backend/apps/api/v1/serializers/search.py | 3 +- backend/apps/api/v1/serializers/services.py | 3 +- backend/apps/api/v1/serializers/shared.py | 101 ++-- backend/apps/api/v1/serializers_rankings.py | 10 +- backend/apps/api/v1/signals.py | 15 +- backend/apps/api/v1/tests/test_contracts.py | 160 +++--- backend/apps/api/v1/urls.py | 14 +- backend/apps/api/v1/views/__init__.py | 18 +- backend/apps/api/v1/views/auth.py | 26 +- backend/apps/api/v1/views/base.py | 201 ++++--- backend/apps/api/v1/views/discovery.py | 16 +- backend/apps/api/v1/views/health.py | 14 +- backend/apps/api/v1/views/leaderboard.py | 44 +- backend/apps/api/v1/views/reviews.py | 14 +- backend/apps/api/v1/views/stats.py | 39 +- backend/apps/api/v1/views/trending.py | 23 +- backend/apps/api/v1/viewsets_rankings.py | 28 +- backend/apps/blog/apps.py | 1 + backend/apps/blog/models.py | 19 +- backend/apps/blog/serializers.py | 15 +- backend/apps/blog/urls.py | 3 +- backend/apps/blog/views.py | 11 +- backend/apps/context_portal/alembic/env.py | 6 +- .../versions/2025_06_17_initial_schema.py | 2 +- backend/apps/core/admin/mixins.py | 1 - backend/apps/core/analytics.py | 9 +- backend/apps/core/api/exceptions.py | 24 +- backend/apps/core/api/mixins.py | 21 +- backend/apps/core/checks.py | 4 +- backend/apps/core/choices/__init__.py | 8 +- backend/apps/core/choices/base.py | 58 +- backend/apps/core/choices/core_choices.py | 9 +- backend/apps/core/choices/fields.py | 66 +-- backend/apps/core/choices/registry.py | 94 +-- backend/apps/core/choices/serializers.py | 60 +- backend/apps/core/choices/utils.py | 118 ++-- .../apps/core/decorators/cache_decorators.py | 31 +- backend/apps/core/exceptions.py | 22 +- backend/apps/core/forms.py | 3 +- backend/apps/core/forms/htmx_forms.py | 2 +- .../apps/core/health_checks/custom_checks.py | 16 +- backend/apps/core/history.py | 13 +- backend/apps/core/htmx_utils.py | 2 +- backend/apps/core/logging.py | 15 +- .../commands/calculate_new_content.py | 25 +- .../management/commands/calculate_trending.py | 15 +- .../core/management/commands/clear_cache.py | 2 +- .../commands/list_transition_callbacks.py | 3 +- .../management/commands/optimize_static.py | 4 +- .../apps/core/management/commands/rundev.py | 4 +- .../management/commands/security_audit.py | 14 +- .../core/management/commands/setup_dev.py | 3 +- .../commands/test_transition_callbacks.py | 4 +- .../core/management/commands/test_trending.py | 18 +- .../management/commands/update_trending.py | 5 +- .../management/commands/validate_settings.py | 13 +- .../core/management/commands/warm_cache.py | 13 +- backend/apps/core/managers.py | 14 +- backend/apps/core/middleware/__init__.py | 2 +- .../core/middleware/htmx_error_middleware.py | 1 + .../core/middleware/performance_middleware.py | 10 +- backend/apps/core/middleware/rate_limiting.py | 7 +- .../apps/core/middleware/request_logging.py | 3 +- backend/apps/core/middleware/view_tracking.py | 31 +- backend/apps/core/mixins/__init__.py | 6 +- backend/apps/core/models.py | 5 +- backend/apps/core/permissions.py | 1 + backend/apps/core/selectors.py | 36 +- backend/apps/core/services/__init__.py | 10 +- .../apps/core/services/clustering_service.py | 36 +- backend/apps/core/services/data_structures.py | 61 +- .../core/services/enhanced_cache_service.py | 25 +- .../core/services/entity_fuzzy_matching.py | 23 +- .../apps/core/services/location_adapters.py | 57 +- backend/apps/core/services/location_search.py | 63 +- .../apps/core/services/map_cache_service.py | 40 +- backend/apps/core/services/map_service.py | 67 +-- backend/apps/core/services/media_service.py | 29 +- .../apps/core/services/media_url_service.py | 7 +- .../core/services/performance_monitoring.py | 17 +- .../apps/core/services/trending_service.py | 25 +- backend/apps/core/state_machine/__init__.py | 152 ++--- backend/apps/core/state_machine/builder.py | 21 +- .../apps/core/state_machine/callback_base.py | 69 ++- .../core/state_machine/callbacks/__init__.py | 27 +- .../core/state_machine/callbacks/cache.py | 26 +- .../state_machine/callbacks/notifications.py | 20 +- .../callbacks/related_updates.py | 16 +- backend/apps/core/state_machine/config.py | 25 +- backend/apps/core/state_machine/decorators.py | 35 +- backend/apps/core/state_machine/exceptions.py | 51 +- backend/apps/core/state_machine/fields.py | 4 +- backend/apps/core/state_machine/guards.py | 143 +++-- .../apps/core/state_machine/integration.py | 39 +- backend/apps/core/state_machine/mixins.py | 18 +- backend/apps/core/state_machine/monitoring.py | 52 +- backend/apps/core/state_machine/registry.py | 44 +- backend/apps/core/state_machine/signals.py | 27 +- .../apps/core/state_machine/tests/fixtures.py | 133 ++--- .../apps/core/state_machine/tests/helpers.py | 139 ++--- .../core/state_machine/tests/test_builder.py | 2 +- .../state_machine/tests/test_callbacks.py | 228 ++++---- .../state_machine/tests/test_decorators.py | 5 +- .../core/state_machine/tests/test_guards.py | 283 +++++---- .../state_machine/tests/test_integration.py | 6 +- .../core/state_machine/tests/test_registry.py | 2 +- .../state_machine/tests/test_validators.py | 2 +- backend/apps/core/state_machine/validators.py | 37 +- backend/apps/core/tasks/trending.py | 39 +- .../apps/core/templatetags/common_filters.py | 2 +- backend/apps/core/templatetags/fsm_tags.py | 10 +- backend/apps/core/templatetags/safe_html.py | 21 +- backend/apps/core/tests/test_admin.py | 1 - backend/apps/core/tests/test_history.py | 19 +- backend/apps/core/urls/__init__.py | 3 +- backend/apps/core/urls/map_urls.py | 9 +- backend/apps/core/urls/maps.py | 11 +- backend/apps/core/urls/search.py | 1 + backend/apps/core/utils/breadcrumbs.py | 11 +- backend/apps/core/utils/cloudflare.py | 23 +- backend/apps/core/utils/error_handling.py | 10 +- backend/apps/core/utils/file_scanner.py | 28 +- backend/apps/core/utils/messages.py | 2 - backend/apps/core/utils/query_optimization.py | 26 +- backend/apps/core/utils/turnstile.py | 16 +- backend/apps/core/views/base.py | 9 +- backend/apps/core/views/entity_search.py | 16 +- backend/apps/core/views/map_views.py | 31 +- backend/apps/core/views/maps.py | 22 +- .../apps/core/views/performance_dashboard.py | 31 +- backend/apps/core/views/search.py | 17 +- backend/apps/core/views/views.py | 26 +- backend/apps/lists/admin.py | 4 +- backend/apps/lists/apps.py | 1 + backend/apps/lists/migrations/0001_initial.py | 3 +- backend/apps/lists/models.py | 8 +- backend/apps/lists/serializers.py | 5 +- backend/apps/lists/urls.py | 5 +- backend/apps/lists/views.py | 9 +- backend/apps/media/apps.py | 1 + .../apps/media/commands/download_photos.py | 8 +- .../apps/media/commands/fix_photo_paths.py | 4 +- backend/apps/media/commands/move_photos.py | 8 +- backend/apps/media/models.py | 15 +- backend/apps/media/serializers.py | 15 +- backend/apps/media/urls.py | 3 +- backend/apps/media/views.py | 7 +- backend/apps/moderation/admin.py | 2 - backend/apps/moderation/apps.py | 29 +- backend/apps/moderation/choices.py | 2 +- backend/apps/moderation/filters.py | 12 +- .../commands/analyze_transitions.py | 33 +- .../management/commands/seed_submissions.py | 6 +- .../commands/validate_state_machines.py | 8 +- ...r_bulkoperation_operation_type_and_more.py | 3 +- .../0007_convert_status_to_richfsmfield.py | 3 +- ...08_alter_bulkoperation_options_and_more.py | 3 +- .../migrations/0009_add_claim_fields.py | 3 +- backend/apps/moderation/mixins.py | 30 +- backend/apps/moderation/models.py | 103 ++-- backend/apps/moderation/permissions.py | 18 +- backend/apps/moderation/selectors.py | 28 +- backend/apps/moderation/serializers.py | 24 +- backend/apps/moderation/services.py | 44 +- backend/apps/moderation/signals.py | 34 +- backend/apps/moderation/sse.py | 57 +- .../templatetags/moderation_tags.py | 13 +- backend/apps/moderation/tests.py | 214 +++---- backend/apps/moderation/tests/test_admin.py | 2 - .../apps/moderation/tests/test_workflows.py | 170 +++--- backend/apps/moderation/urls.py | 27 +- backend/apps/moderation/views.py | 164 +++--- backend/apps/parks/admin.py | 5 +- backend/apps/parks/apps.py | 7 +- backend/apps/parks/choices.py | 9 +- backend/apps/parks/filters.py | 26 +- backend/apps/parks/forms.py | 6 +- backend/apps/parks/location_utils.py | 2 +- .../management/commands/create_sample_data.py | 107 ---- .../management/commands/seed_initial_data.py | 4 +- .../management/commands/seed_sample_data.py | 11 +- .../management/commands/test_location.py | 5 +- .../management/commands/update_park_counts.py | 1 + backend/apps/parks/managers.py | 11 +- .../0008_parkphoto_parkphotoevent_and_more.py | 3 +- .../0015_populate_hybrid_filtering_fields.py | 20 +- .../0016_add_hybrid_filtering_indexes.py | 22 +- .../migrations/0019_fix_pghistory_timezone.py | 14 +- .../0020_fix_pghistory_update_timezone.py | 14 +- ...rk_park_type_alter_park_status_and_more.py | 3 +- ..._company_roles_alter_companyevent_roles.py | 3 +- ...any_options_alter_park_options_and_more.py | 5 +- ...sert_remove_park_update_update_and_more.py | 72 +++ backend/apps/parks/models/__init__.py | 13 +- backend/apps/parks/models/areas.py | 3 +- backend/apps/parks/models/companies.py | 5 +- backend/apps/parks/models/location.py | 2 +- backend/apps/parks/models/media.py | 12 +- backend/apps/parks/models/parks.py | 47 +- backend/apps/parks/models/reviews.py | 5 +- backend/apps/parks/querysets.py | 3 +- backend/apps/parks/selectors.py | 15 +- backend/apps/parks/services.py | 33 +- backend/apps/parks/services/__init__.py | 6 +- backend/apps/parks/services/filter_service.py | 26 +- backend/apps/parks/services/hybrid_loader.py | 168 +++--- .../apps/parks/services/location_service.py | 27 +- backend/apps/parks/services/media_service.py | 15 +- .../apps/parks/services/park_management.py | 65 +-- backend/apps/parks/services/roadtrip.py | 45 +- backend/apps/parks/signals.py | 6 +- backend/apps/parks/tests.py | 136 ++--- backend/apps/parks/tests/test_admin.py | 4 +- .../apps/parks/tests/test_park_workflows.py | 166 +++--- .../parks/tests/test_query_optimization.py | 7 +- .../apps/parks/tests_disabled/test_filters.py | 5 +- .../apps/parks/tests_disabled/test_models.py | 19 +- .../apps/parks/tests_disabled/test_search.py | 4 +- backend/apps/parks/urls.py | 12 +- backend/apps/parks/views.py | 91 ++- backend/apps/parks/views_roadtrip.py | 20 +- backend/apps/parks/views_search.py | 7 +- backend/apps/reviews/apps.py | 3 +- backend/apps/reviews/models.py | 14 +- backend/apps/reviews/serializers.py | 7 +- backend/apps/reviews/signals.py | 6 +- backend/apps/reviews/urls.py | 3 +- backend/apps/reviews/views.py | 11 +- backend/apps/rides/apps.py | 5 +- backend/apps/rides/choices.py | 23 +- backend/apps/rides/events.py | 9 +- backend/apps/rides/forms.py | 4 +- backend/apps/rides/forms/__init__.py | 3 +- backend/apps/rides/forms/base.py | 4 +- backend/apps/rides/forms/search.py | 44 +- backend/apps/rides/managers.py | 20 +- .../0007_ridephoto_ridephotoevent_and_more.py | 3 +- .../0019_populate_hybrid_filtering_fields.py | 28 +- .../0020_add_hybrid_filtering_indexes.py | 52 +- ...roles_alter_companyevent_roles_and_more.py | 3 +- ..._company_roles_alter_companyevent_roles.py | 3 +- ...lter_ridemodelphoto_photo_type_and_more.py | 3 +- ...rename_launch_type_to_propulsion_system.py | 3 +- .../0025_convert_ride_status_to_fsm.py | 3 +- ..._alter_rankingsnapshot_options_and_more.py | 3 +- ...rkridestatsevent_flatridestats_and_more.py | 533 +++++++++++++++++ backend/apps/rides/mixins.py | 4 +- backend/apps/rides/models/__init__.py | 14 +- backend/apps/rides/models/company.py | 4 +- backend/apps/rides/models/credits.py | 12 +- backend/apps/rides/models/location.py | 6 +- backend/apps/rides/models/media.py | 18 +- backend/apps/rides/models/rankings.py | 4 +- backend/apps/rides/models/reviews.py | 5 +- backend/apps/rides/models/rides.py | 65 ++- backend/apps/rides/models/stats.py | 226 ++++++++ backend/apps/rides/park_urls.py | 1 + backend/apps/rides/selectors.py | 14 +- backend/apps/rides/services/__init__.py | 4 +- backend/apps/rides/services/hybrid_loader.py | 247 ++++---- .../apps/rides/services/location_service.py | 23 +- backend/apps/rides/services/media_service.py | 23 +- .../apps/rides/services/ranking_service.py | 44 +- backend/apps/rides/services/search.py | 47 +- backend/apps/rides/services/status_service.py | 18 +- backend/apps/rides/services_core.py | 41 +- backend/apps/rides/signals.py | 22 +- backend/apps/rides/templatetags/ride_tags.py | 1 + backend/apps/rides/tests.py | 209 +++---- backend/apps/rides/tests/test_admin.py | 3 - .../apps/rides/tests/test_ride_workflows.py | 263 ++++----- backend/apps/rides/urls.py | 4 +- backend/apps/rides/views.py | 8 +- backend/apps/support/apps.py | 1 + backend/apps/support/models.py | 16 +- backend/apps/support/serializers.py | 7 +- backend/apps/support/urls.py | 3 +- backend/apps/support/views.py | 4 +- backend/config/celery.py | 1 + backend/config/django/base.py | 17 +- backend/config/django/local.py | 1 + backend/config/django/production.py | 3 +- backend/config/settings/database.py | 2 +- backend/config/settings/logging.py | 1 + backend/config/settings/rest_framework.py | 1 + backend/config/settings/secrets.py | 29 +- backend/config/settings/storage.py | 1 + backend/config/settings/third_party.py | 34 +- backend/config/settings/validation.py | 25 +- backend/ensure_admin.py | 4 +- backend/pyproject.toml | 11 +- backend/scripts/benchmark_queries.py | 27 +- backend/stubs/environ.pyi | 39 +- backend/stubs/rest_framework/viewsets.pyi | 5 +- backend/test_avatar_upload.py | 2 +- .../accessibility/test_wcag_compliance.py | 10 +- backend/tests/api/test_auth_api.py | 8 +- backend/tests/api/test_error_handling.py | 2 +- backend/tests/api/test_filters.py | 4 +- backend/tests/api/test_pagination.py | 2 +- backend/tests/api/test_parks_api.py | 20 +- backend/tests/api/test_response_format.py | 3 +- backend/tests/api/test_rides_api.py | 20 +- backend/tests/conftest.py | 1 - backend/tests/e2e/conftest.py | 34 +- backend/tests/e2e/test_auth.py | 2 +- backend/tests/e2e/test_fsm_error_handling.py | 18 +- backend/tests/e2e/test_fsm_permissions.py | 8 +- backend/tests/e2e/test_moderation_fsm.py | 18 +- backend/tests/e2e/test_park_browsing.py | 6 +- backend/tests/e2e/test_park_ride_fsm.py | 24 +- backend/tests/e2e/test_parks.py | 2 +- backend/tests/e2e/test_profiles.py | 2 +- backend/tests/e2e/test_review_submission.py | 3 +- backend/tests/e2e/test_reviews.py | 2 +- backend/tests/e2e/test_rides.py | 2 +- backend/tests/factories.py | 4 +- backend/tests/forms/test_park_forms.py | 9 +- backend/tests/forms/test_ride_forms.py | 9 +- .../integration/test_fsm_transition_view.py | 9 +- .../test_fsm_transition_workflow.py | 9 +- .../test_park_creation_workflow.py | 19 +- .../integration/test_photo_upload_workflow.py | 17 +- backend/tests/managers/test_core_managers.py | 20 +- backend/tests/managers/test_park_managers.py | 23 +- backend/tests/managers/test_ride_managers.py | 17 +- .../test_contract_validation_middleware.py | 9 +- .../serializers/test_account_serializers.py | 27 +- .../serializers/test_park_serializers.py | 20 +- .../serializers/test_ride_serializers.py | 23 +- .../tests/services/test_park_media_service.py | 13 +- backend/tests/services/test_ride_service.py | 13 +- .../services/test_user_deletion_service.py | 22 +- backend/tests/test_factories.py | 12 +- backend/tests/test_parks_api.py | 6 +- backend/tests/test_parks_models.py | 9 +- backend/tests/test_runner.py | 3 +- backend/tests/test_utils.py | 31 +- backend/tests/utils/__init__.py | 10 +- backend/tests/utils/fsm_test_helpers.py | 39 +- backend/tests/ux/test_breadcrumbs.py | 2 - backend/tests/ux/test_components.py | 1 - backend/tests/ux/test_htmx_utils.py | 1 - backend/tests/ux/test_messages.py | 1 - backend/tests/ux/test_meta.py | 1 - backend/thrillwiki/urls.py | 23 +- backend/thrillwiki/views.py | 17 +- backend/verify_backend.py | 24 +- backend/verify_no_tuple_fallbacks.py | 37 +- uv.lock | 480 +++++++++------- 452 files changed, 7948 insertions(+), 6073 deletions(-) create mode 100644 backend/apps/accounts/login_history.py create mode 100644 backend/apps/accounts/migrations/0015_loginhistory_loginhistoryevent_and_more.py create mode 100644 backend/apps/api/v1/accounts/views_magic_link.py create mode 100644 backend/apps/api/v1/auth/mfa.py create mode 100644 backend/apps/api/v1/rides/company_urls.py create mode 100644 backend/apps/api/v1/rides/company_views.py create mode 100644 backend/apps/parks/migrations/0026_remove_park_insert_insert_remove_park_update_update_and_more.py create mode 100644 backend/apps/rides/migrations/0029_darkridestats_darkridestatsevent_flatridestats_and_more.py create mode 100644 backend/apps/rides/models/stats.py diff --git a/GAP_ANALYSIS_MATRIX.md b/GAP_ANALYSIS_MATRIX.md index a2cde2fe..5f448790 100644 --- a/GAP_ANALYSIS_MATRIX.md +++ b/GAP_ANALYSIS_MATRIX.md @@ -1,354 +1,207 @@ -# ThrillWiki Gap Analysis Matrix - -> **Generated:** 2025-12-27 | **Source:** Fresh ground-zero audit of `source_docs/` vs. actual codebase - -This matrix documents every requirement extracted from the 5 source documentation files and their verification status against the Django backend (`backend/apps/`) and Nuxt frontend (`frontend/app/`). - -**Legend:** -- ✅ **[OK]** - Implemented as specified -- ⚠️ **[DEVIATION]** - Implemented but differs from spec -- ❌ **[MISSING]** - Not implemented - ---- - -## 1. SITE_OVERVIEW.md - -| Feature | Source Doc | Current Status | Action Required | -|---------|------------|----------------|-----------------| -| Homepage with Hero Search | SITE_OVERVIEW.md §Homepage | ✅ [OK] | `frontend/app/pages/index.vue` has hero search | -| Discovery Tabs (11 categories) | SITE_OVERVIEW.md §Homepage | ✅ [OK] | `frontend/app/pages/discover.vue` implements tabs | -| Recent Changes Feed | SITE_OVERVIEW.md §Homepage | ✅ [OK] | Backend `apps/core/history` provides timeline data | -| Global Search | SITE_OVERVIEW.md §Core Features | ✅ [OK] | `GlobalSearch.vue` component exists | -| Parks Nearby with Map | SITE_OVERVIEW.md §Core Features | ✅ [OK] | `pages/parks/nearby.vue` with Leaflet | -| Advanced Filters | SITE_OVERVIEW.md §Core Features | ✅ [OK] | Filter components on parks/rides pages | -| Trending Content | SITE_OVERVIEW.md §Core Features | ⚠️ [DEVIATION] | Backend has `trending_parks` endpoint but no dedicated "Trending" UI section | -| Detailed Park Pages with Tabs | SITE_OVERVIEW.md §Parks | ✅ [OK] | Overview/Rides/Reviews/Photos/History tabs | -| Ride Specifications | SITE_OVERVIEW.md §Rides | ✅ [OK] | `Ride` model has full spec fields | -| Company Profiles | SITE_OVERVIEW.md §Companies | ✅ [OK] | `/manufacturers`, `/operators`, `/designers`, `/owners` pages | -| Ride Models | SITE_OVERVIEW.md §Ride Models | ✅ [OK] | `RideModel` model + `/ride-models` pages | -| Photo Galleries | SITE_OVERVIEW.md §Photos | ✅ [OK] | `PhotoGallery.vue` + `GalleryUploader.vue` | -| Version History / Historical Records | SITE_OVERVIEW.md §History | ✅ [OK] | `pghistory` tracking on models + History tab | -| Reviews & Ratings | SITE_OVERVIEW.md §Community | ✅ [OK] | `apps/reviews` + Review components | -| Ride Credits | SITE_OVERVIEW.md §Community | ✅ [OK] | `RideCredit` model + `/my-credits` page | -| Personal Lists | SITE_OVERVIEW.md §Community | ✅ [OK] | `apps/lists` + `/lists` pages | -| Leaderboards | SITE_OVERVIEW.md §Community | ❌ [MISSING] | No leaderboard page or backend endpoint exists | -| Badges / Achievement System | SITE_OVERVIEW.md §Community | ⚠️ [DEVIATION] | `User.badges` field exists in model but no UI to display/earn badges | -| Submit New Content | SITE_OVERVIEW.md §Contribution | ✅ [OK] | `/submit/park`, `/submit/ride`, `/submit/company` pages | -| Moderation Queue | SITE_OVERVIEW.md §Moderation | ✅ [OK] | `/moderation` dashboard with queue | -| Admin Dashboard | SITE_OVERVIEW.md §Admin | ⚠️ [DEVIATION] | Only `/admin/system.vue` exists; no full user management UI | -| Terms of Service | SITE_OVERVIEW.md §Static Pages | ✅ [OK] | `/terms.vue` | -| Privacy Policy | SITE_OVERVIEW.md §Static Pages | ✅ [OK] | `/privacy.vue` | -| Community Guidelines | SITE_OVERVIEW.md §Static Pages | ✅ [OK] | `/guidelines.vue` | -| Contact Form | SITE_OVERVIEW.md §Static Pages | ❌ [MISSING] | No `/contact` page exists | -| Blog | SITE_OVERVIEW.md §Static Pages | ✅ [OK] | `apps/blog` + `/blog` pages | -| Full keyboard navigation | SITE_OVERVIEW.md §Accessibility | ⚠️ [DEVIATION] | Components use Nuxt UI which has ARIA support but not explicitly tested | -| Screen reader compatible | SITE_OVERVIEW.md §Accessibility | ⚠️ [DEVIATION] | Uses semantic HTML but no specific ARIA implementation | -| High contrast support | SITE_OVERVIEW.md §Accessibility | ⚠️ [DEVIATION] | Dark mode exists but no specific high-contrast mode | -| Reduced motion preferences | SITE_OVERVIEW.md §Accessibility | ❌ [MISSING] | Animations don't check `prefers-reduced-motion` | -| Metric/Imperial toggle | SITE_OVERVIEW.md §Internationalization | ✅ [OK] | `unit_system` in User model + `useUnits.ts` composable | - ---- - -## 2. PAGES.md - -| Feature | Source Doc | Current Status | Action Required | -|---------|------------|----------------|-----------------| -| Homepage Hero Search | PAGES.md §Homepage | ✅ [OK] | Large search input with autocomplete | -| Homepage Discovery Tabs (11) | PAGES.md §Homepage | ✅ [OK] | Tabs implemented in `discover.vue` | -| Homepage Recent Changes Feed | PAGES.md §Homepage | ✅ [OK] | Timeline component exists | -| Parks Listing with Filters | PAGES.md §Parks Listing | ✅ [OK] | `/parks/index.vue` with filters | -| Parks Listing Grid/List View Toggle | PAGES.md §Parks Listing | ❌ [MISSING] | Only grid view, no list view toggle | -| Parks Nearby with Map | PAGES.md §Parks Nearby | ✅ [OK] | Leaflet map + radius slider | -| Parks Nearby Unit Toggle (mi/km) | PAGES.md §Parks Nearby | ✅ [OK] | Unit toggle in nearby page | -| Park Detail Hero Banner | PAGES.md §Park Detail | ✅ [OK] | Hero with banner image | -| Park Detail Quick Stats (Rides/Reviews/Rating/Status/Est.) | PAGES.md §Park Detail | ✅ [OK] | Stats displayed in hero area | -| Park Detail Overview Tab | PAGES.md §Park Detail | ✅ [OK] | Description, location, contact | -| Park Detail Rides Tab | PAGES.md §Park Detail | ✅ [OK] | Filterable ride list | -| Park Detail Reviews Tab | PAGES.md §Park Detail | ✅ [OK] | Review list with ratings | -| Park Detail Photos Tab | PAGES.md §Park Detail | ✅ [OK] | Photo gallery | -| Park Detail History Tab | PAGES.md §Park Detail | ✅ [OK] | Version history/timeline | -| Park Detail Location Map | PAGES.md §Park Detail Overview | ❌ [MISSING] | No inline map on Overview tab | -| Park Detail Contact Info | PAGES.md §Park Detail Overview | ⚠️ [DEVIATION] | Website link exists but no full contact section | -| Park Detail Operator/Owner Links | PAGES.md §Park Detail Overview | ⚠️ [DEVIATION] | Not prominently displayed | -| Rides Listing with Filters | PAGES.md §Rides Listing | ✅ [OK] | `/rides/index.vue` with filters | -| Rides Advanced Filters (Speed/Height/Inversions) | PAGES.md §Rides Listing | ⚠️ [DEVIATION] | Basic filters only, no slider ranges | -| Ride Detail Nested URL (/parks/{park}/rides/{ride}) | PAGES.md §Ride Detail | ✅ [OK] | Nested routing implemented | -| Ride Detail Hero Banner | PAGES.md §Ride Detail | ✅ [OK] | Hero with banner image | -| Ride Detail Quick Stats (Speed/Height/Length/Inv/Rating) | PAGES.md §Ride Detail | ✅ [OK] | Stats displayed | -| Ride Detail Overview Tab | PAGES.md §Ride Detail | ✅ [OK] | Description + key info | -| Ride Detail Specifications Tab | PAGES.md §Ride Detail | ✅ [OK] | Full specs by category | -| Ride Detail Reviews Tab | PAGES.md §Ride Detail | ✅ [OK] | Review list | -| Ride Detail Photos Tab | PAGES.md §Ride Detail | ✅ [OK] | Photo gallery | -| Ride Detail History Tab | PAGES.md §Ride Detail | ✅ [OK] | Version history | -| Coaster Spec: Speed/Height/Length/Drop | PAGES.md §Ride Specs | ✅ [OK] | All fields in `Ride` model | -| Coaster Spec: Inversions/G-Force | PAGES.md §Ride Specs | ✅ [OK] | Fields exist | -| Coaster Spec: Duration/Capacity | PAGES.md §Ride Specs | ✅ [OK] | Fields exist | -| Coaster Spec: Track Material/Seating Type | PAGES.md §Ride Specs | ✅ [OK] | Fields exist | -| Flat Ride Specs | PAGES.md §Ride Specs | ⚠️ [DEVIATION] | Uses same `Ride` model but not all flat-specific fields | -| Water Ride Specs (Wetness Level, Splash Height) | PAGES.md §Ride Specs | ❌ [MISSING] | No wetness_level or splash_height fields | -| Dark Ride Specs (Scenes Count, Animatronics) | PAGES.md §Ride Specs | ❌ [MISSING] | No scenes_count or animatronics fields | -| Manufacturers Listing | PAGES.md §Company Pages | ✅ [OK] | `/manufacturers/index.vue` | -| Designers Listing | PAGES.md §Company Pages | ✅ [OK] | `/designers/index.vue` | -| Operators Listing | PAGES.md §Company Pages | ✅ [OK] | `/operators/index.vue` | -| Owners Listing | PAGES.md §Company Pages | ✅ [OK] | `/owners/index.vue` | -| Company Detail Tabs (Overview/Rides/Models/History) | PAGES.md §Company Detail | ⚠️ [DEVIATION] | Only index listing exists, no detail pages with tabs | -| Ride Models Listing | PAGES.md §Ride Models | ✅ [OK] | `/ride-models/index.vue` | -| Ride Model Detail with Installations | PAGES.md §Ride Models | ✅ [OK] | `/ride-models/[slug].vue` | -| Search Page with Type Tabs | PAGES.md §Search | ⚠️ [DEVIATION] | `/search.vue` exists but minimal implementation | -| Instant Search Results | PAGES.md §Search | ✅ [OK] | `GlobalSearch.vue` has debounced search | -| Recent Searches History | PAGES.md §Search | ❌ [MISSING] | No search history feature | -| Auth Page Sign In | PAGES.md §Authentication | ✅ [OK] | `/auth/login.vue` | -| Auth Page Sign Up | PAGES.md §Authentication | ✅ [OK] | `/auth/signup.vue` | -| Auth Email/Password Login | PAGES.md §Authentication | ✅ [OK] | Standard login form | -| Auth Magic Link (Passwordless) | PAGES.md §Authentication | ❌ [MISSING] | No magic link implementation | -| Auth Google OAuth | PAGES.md §Authentication | ✅ [OK] | Social auth configured | -| Auth Discord OAuth | PAGES.md §Authentication | ✅ [OK] | Discord social auth exists | -| Auth CAPTCHA Verification | PAGES.md §Authentication | ❌ [MISSING] | No CAPTCHA on forms | -| Auth Email Confirmation | PAGES.md §Authentication | ✅ [OK] | `EmailVerification` model exists | -| Auth MFA/TOTP Support | PAGES.md §Authentication | ❌ [MISSING] | No MFA implementation | -| Auth Session Management | PAGES.md §Authentication | ✅ [OK] | Django sessions + JWT | -| Auth Ban Check on Login | PAGES.md §Authentication | ✅ [OK] | `is_banned` field checked | -| User Profile Page | PAGES.md §User Profile | ✅ [OK] | `/profile/[username].vue` | -| Profile Activity Tab | PAGES.md §User Profile | ⚠️ [DEVIATION] | Overview tab but not full activity feed | -| Profile Reviews Tab | PAGES.md §User Profile | ✅ [OK] | Reviews tab exists | -| Profile Lists Tab | PAGES.md §User Profile | ❌ [MISSING] | No lists tab on profile | -| Profile Ride Credits Tab | PAGES.md §User Profile | ✅ [OK] | Credits tab exists | -| Profile Stats Display | PAGES.md §User Profile | ✅ [OK] | Total credits, unique rides, member since | -| Profile Badges Display | PAGES.md §User Profile | ❌ [MISSING] | No badges display on profile | -| Settings Page Account Section | PAGES.md §User Settings | ✅ [OK] | `/settings.vue` has account settings | -| Settings Security (Password) | PAGES.md §User Settings | ✅ [OK] | Change password modal | -| Settings Privacy | PAGES.md §User Settings | ⚠️ [DEVIATION] | Minimal privacy options | -| Settings Notifications | PAGES.md §User Settings | ✅ [OK] | Notification preferences exist | -| Settings Location & Units | PAGES.md §User Settings | ✅ [OK] | Unit system + home location | -| Settings Data Export | PAGES.md §User Settings | ⚠️ [DEVIATION] | Export service exists but no UI button | -| Settings Login History View | PAGES.md §User Settings | ❌ [MISSING] | No login history UI | -| Ride Credits Page (/my-credits) | PAGES.md §Ride Credits | ✅ [OK] | `/my-credits.vue` | -| Credits Statistics Panel | PAGES.md §Ride Credits | ✅ [OK] | Stats displayed | -| Credits Add/Edit/Delete | PAGES.md §Ride Credits | ✅ [OK] | `RideCreditModal.vue` | -| Credits Quick Increment (+/-) | PAGES.md §Ride Credits | ✅ [OK] | Quick increment on cards | -| Credits Drag Reorder | PAGES.md §Ride Credits | ❌ [MISSING] | No drag reorder functionality | -| User Lists Page (/my-lists) | PAGES.md §User Lists | ⚠️ [DEVIATION] | Uses `/lists` not `/my-lists` | -| Lists Create/Edit/Delete | PAGES.md §User Lists | ✅ [OK] | CRUD operations work | -| Lists Public/Private Toggle | PAGES.md §User Lists | ✅ [OK] | Privacy setting exists | -| Review Writing Form | PAGES.md §Reviews | ✅ [OK] | `ReviewForm.vue` | -| Review Star Rating | PAGES.md §Reviews | ✅ [OK] | `StarRating.vue` | -| Review Like/Dislike (Voting) | PAGES.md §Reviews | ⚠️ [DEVIATION] | Backend has votes but frontend UI minimal | -| Review Reply System | PAGES.md §Reviews | ⚠️ [DEVIATION] | Backend has replies but no frontend UI | -| Review Report Button | PAGES.md §Reviews | ❌ [MISSING] | No report functionality in UI | -| Photo Upload Interface | PAGES.md §Photo System | ✅ [OK] | `PhotoUpload.vue` + modal | -| Photo Drag & Drop | PAGES.md §Photo System | ✅ [OK] | Drag-drop in uploader | -| Photo Gallery Lightbox | PAGES.md §Photo System | ⚠️ [DEVIATION] | `PhotoGallery.vue` exists but no full lightbox | -| Photo Zoom/Download in Lightbox | PAGES.md §Photo System | ❌ [MISSING] | No zoom/download in gallery | -| Submission Multi-Step Wizard | PAGES.md §Submission Forms | ❌ [MISSING] | Single-page forms, no step wizard | -| Submission Auto-Save Drafts | PAGES.md §Submission Forms | ❌ [MISSING] | No draft auto-save | -| Submission Unit Toggle (m/ft) | PAGES.md §Submission Forms | ⚠️ [DEVIATION] | No inline unit toggle on forms | -| Moderation Queue Dashboard | PAGES.md §Moderation | ✅ [OK] | `/moderation/index.vue` | -| Moderation Filters | PAGES.md §Moderation | ✅ [OK] | Type, status, priority filters | -| Moderation Claim/Unclaim | PAGES.md §Moderation | ✅ [OK] | Claim functionality implemented | -| Moderation Side-by-Side Diff | PAGES.md §Moderation | ✅ [OK] | `DiffView.vue` component | -| Moderation Approve/Reject/Request Changes | PAGES.md §Moderation | ✅ [OK] | All actions available | -| Admin Dashboard with Stats | PAGES.md §Admin Dashboard | ⚠️ [DEVIATION] | Only `/admin/system.vue` with health checks | -| Admin User Management | PAGES.md §Admin | ⚠️ [DEVIATION] | `/moderation/users.vue` exists for user moderation | -| Admin Change User Role | PAGES.md §Admin | ✅ [OK] | Role change in user moderation | -| Admin Ban/Unban User | PAGES.md §Admin | ✅ [OK] | Ban functionality exists | -| Admin Delete User | PAGES.md §Admin | ⚠️ [DEVIATION] | User deletion request exists but no admin delete | -| Contact Page with Category Select | PAGES.md §Contact | ❌ [MISSING] | No contact page | -| Contact CAPTCHA | PAGES.md §Contact | ❌ [MISSING] | No contact page | - ---- - -## 3. COMPONENTS.md - -| Feature | Source Doc | Current Status | Action Required | -|---------|------------|----------------|-----------------| -| Header Component | COMPONENTS.md §Layout | ✅ [OK] | `AppHeader.vue` | -| Header Logo/Brand Link | COMPONENTS.md §Header | ✅ [OK] | Links to homepage | -| Header Primary Navigation | COMPONENTS.md §Header | ✅ [OK] | Main nav links | -| Header Search Button | COMPONENTS.md §Header | ✅ [OK] | Search trigger in header | -| Header User Menu (Avatar Dropdown) | COMPONENTS.md §Header | ✅ [OK] | User dropdown menu | -| Header Notification Bell | COMPONENTS.md §Header | ❌ [MISSING] | No notification bell in header | -| Header Mobile Hamburger Menu | COMPONENTS.md §Header | ✅ [OK] | Mobile responsive menu | -| Header Minimal Variant (Auth Pages) | COMPONENTS.md §Header | ⚠️ [DEVIATION] | Same header on all pages | -| Footer Component | COMPONENTS.md §Layout | ✅ [OK] | `AppFooter.vue` | -| Footer Navigation Columns | COMPONENTS.md §Footer | ✅ [OK] | Link sections | -| Footer Social Links | COMPONENTS.md §Footer | ✅ [OK] | Social media links | -| Footer Copyright | COMPONENTS.md §Footer | ✅ [OK] | Copyright text | -| PageContainer Component | COMPONENTS.md §Layout | ⚠️ [DEVIATION] | Uses `layouts/default.vue` instead | -| Sidebar Component (Admin/Settings) | COMPONENTS.md §Layout | ⚠️ [DEVIATION] | Settings has tabs, no separate sidebar | -| MainNav with Dropdowns | COMPONENTS.md §Navigation | ✅ [OK] | Dropdown navigation | -| TabNav Component | COMPONENTS.md §Navigation | ✅ [OK] | Uses Nuxt UI `UTabs` | -| Breadcrumbs Component | COMPONENTS.md §Navigation | ✅ [OK] | `Breadcrumbs.vue` exists | -| Breadcrumbs Schema.org Markup | COMPONENTS.md §Breadcrumbs | ⚠️ [DEVIATION] | No structured data markup | -| Pagination Component | COMPONENTS.md §Navigation | ✅ [OK] | Uses Nuxt UI `UPagination` | -| Card Component (Default/Elevated/Interactive/Glass) | COMPONENTS.md §Display | ✅ [OK] | Uses Nuxt UI `UCard` | -| Badge Component | COMPONENTS.md §Display | ✅ [OK] | `StatusBadge.vue` + `EntityStatusBadge.vue` | -| Avatar Component | COMPONENTS.md §Display | ✅ [OK] | Uses Nuxt UI `UAvatar` | -| Image Component with Lazy Loading | COMPONENTS.md §Display | ⚠️ [DEVIATION] | Standard `` tags without lazy loading component | -| Image Blur Placeholder | COMPONENTS.md §Display | ❌ [MISSING] | No blur-up placeholder | -| Input Component | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `UInput` | -| Select Component with Search | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `USelect` | -| Checkbox Component | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `UCheckbox` | -| Radio Component | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI radio | -| Switch Component | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `UToggle` | -| Button Component (All Variants) | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `UButton` | -| DatePicker Component | COMPONENTS.md §Forms | ⚠️ [DEVIATION] | Uses HTML date input, no custom DatePicker | -| DatePicker Date Precision Selector | COMPONENTS.md §Forms | ❌ [MISSING] | No date precision selection | -| Slider Component | COMPONENTS.md §Forms | ❌ [MISSING] | No slider/range component | -| Toast Component | COMPONENTS.md §Feedback | ✅ [OK] | Uses Nuxt UI `useToast` | -| Alert Component | COMPONENTS.md §Feedback | ✅ [OK] | Uses Nuxt UI `UAlert` | -| Modal/Dialog Component | COMPONENTS.md §Feedback | ✅ [OK] | Multiple modals exist | -| Loading Spinner | COMPONENTS.md §Feedback | ✅ [OK] | Uses icon spinners | -| Skeleton Loading | COMPONENTS.md §Feedback | ⚠️ [DEVIATION] | Uses spinners, not skeleton loaders | -| Progress Bar | COMPONENTS.md §Feedback | ⚠️ [DEVIATION] | No progress bar component | -| Empty State Component | COMPONENTS.md §Feedback | ✅ [OK] | Empty states with icons/messages | -| Table Component | COMPONENTS.md §Data Display | ✅ [OK] | Uses Nuxt UI `UTable` | -| Table Sortable Columns | COMPONENTS.md §Table | ✅ [OK] | Sorting available | -| Table Row Selection | COMPONENTS.md §Table | ⚠️ [DEVIATION] | Not all tables have selection | -| Stats Card Component | COMPONENTS.md §Data Display | ✅ [OK] | `BentoCard.vue` + stat displays | -| Rating Display Component | COMPONENTS.md §Data Display | ✅ [OK] | `StarRating.vue` | -| ParkCard Component | COMPONENTS.md §Entity Components | ❌ [MISSING] | No dedicated `ParkCard.vue` (uses inline cards) | -| RideCard Component | COMPONENTS.md §Entity Components | ❌ [MISSING] | No dedicated `RideCard.vue` (uses inline cards) | -| ReviewCard Component | COMPONENTS.md §Entity Components | ✅ [OK] | `ReviewCard.vue` exists | -| CreditCard Component | COMPONENTS.md §Entity Components | ✅ [OK] | `CreditCard.vue` exists | -| UnitDisplay Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | Logic in `useUnits.ts` but no dedicated component | -| Map Component (Leaflet) | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | Inline in pages, no reusable `Map.vue` | -| Map Marker Clusters | COMPONENTS.md §Map | ❌ [MISSING] | No marker clustering | -| Map Full-Screen Toggle | COMPONENTS.md §Map | ❌ [MISSING] | No full-screen map option | -| Timeline Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | History tab has timeline but no reusable component | -| Diff Viewer Component | COMPONENTS.md §Specialty | ✅ [OK] | `DiffView.vue` | -| ImageGallery Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | `PhotoGallery.vue` exists but limited functionality | -| ImageGallery Lightbox with Navigation | COMPONENTS.md §ImageGallery | ⚠️ [DEVIATION] | Basic lightbox, no prev/next | -| ImageGallery Zoom/Download | COMPONENTS.md §ImageGallery | ❌ [MISSING] | No zoom or download | -| SearchAutocomplete Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | `GlobalSearch.vue` has autocomplete inline | -| Tooltip Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | Uses Nuxt UI tooltips, no custom component | -| HoverCard Component | COMPONENTS.md §Specialty | ❌ [MISSING] | No hover card previews | - ---- - -## 4. DESIGN_SYSTEM.md - -| Feature | Source Doc | Current Status | Action Required | -|---------|------------|----------------|-----------------| -| Brand Name/Tagline | DESIGN_SYSTEM.md §Brand | ✅ [OK] | "ThrillWiki" used consistently | -| Light Mode Color Palette | DESIGN_SYSTEM.md §Colors | ✅ [OK] | Light mode theme exists | -| Dark Mode Color Palette | DESIGN_SYSTEM.md §Colors | ✅ [OK] | Dark mode with toggle | -| Semantic Colors (Primary/Secondary/Muted) | DESIGN_SYSTEM.md §Colors | ✅ [OK] | CSS variables defined | -| Gradients (Primary/Glow/Subtle) | DESIGN_SYSTEM.md §Colors | ⚠️ [DEVIATION] | Some gradients, not full spec | -| Typography: Inter Font | DESIGN_SYSTEM.md §Typography | ✅ [OK] | Inter font configured | -| Type Scale (12-48px) | DESIGN_SYSTEM.md §Typography | ✅ [OK] | Font sizes match scale | -| Spacing System (4px base) | DESIGN_SYSTEM.md §Spacing | ✅ [OK] | Tailwind spacing used | -| Border Radius Tokens | DESIGN_SYSTEM.md §Border Radius | ✅ [OK] | Tailwind rounded utilities | -| Shadows (Light Mode) | DESIGN_SYSTEM.md §Shadows | ✅ [OK] | Shadow utilities used | -| Glow Effects (Dark Mode) | DESIGN_SYSTEM.md §Shadows | ⚠️ [DEVIATION] | Limited glow implementation | -| Animation Timing Functions | DESIGN_SYSTEM.md §Animation | ⚠️ [DEVIATION] | Uses default transitions | -| Animation Durations (150-500ms) | DESIGN_SYSTEM.md §Animation | ✅ [OK] | Transitions within spec | -| Fade/Slide/Scale Animations | DESIGN_SYSTEM.md §Animation | ⚠️ [DEVIATION] | Basic transitions only | -| Button Variants (Primary/Secondary/Outline/Ghost/Destructive) | DESIGN_SYSTEM.md §Components | ✅ [OK] | All variants via Nuxt UI | -| Card Variants (Default/Interactive/Glass) | DESIGN_SYSTEM.md §Components | ⚠️ [DEVIATION] | Glass cards on dark mode but not complete | -| Input States (Default/Focused/Error/Disabled) | DESIGN_SYSTEM.md §Components | ✅ [OK] | All states via Nuxt UI | -| Responsive Breakpoints (sm/md/lg/xl/2xl) | DESIGN_SYSTEM.md §Responsive | ✅ [OK] | Tailwind breakpoints | -| Color Contrast (4.5:1 minimum) | DESIGN_SYSTEM.md §Accessibility | ⚠️ [DEVIATION] | Not explicitly verified | -| Focus Ring on Interactive Elements | DESIGN_SYSTEM.md §Accessibility | ✅ [OK] | Nuxt UI provides focus rings | -| Respect prefers-reduced-motion | DESIGN_SYSTEM.md §Accessibility | ❌ [MISSING] | Not implemented | -| Dark Mode: Reduce Contrast | DESIGN_SYSTEM.md §Dark Mode | ✅ [OK] | Off-white text colors used | -| Dark Mode: Subtle Borders | DESIGN_SYSTEM.md §Dark Mode | ✅ [OK] | Semi-transparent borders | -| Lucide Icons | DESIGN_SYSTEM.md §Icons | ⚠️ [DEVIATION] | Uses Heroicons, not Lucide | - ---- - -## 5. USER_FLOWS.md - -| Feature | Source Doc | Current Status | Action Required | -|---------|------------|----------------|-----------------| -| Homepage Discovery Journey | USER_FLOWS.md §Discovery | ✅ [OK] | Search → Browse → Detail flow works | -| Search Flow with Debounce (300ms) | USER_FLOWS.md §Search Flow | ✅ [OK] | Debounced search implemented | -| Search Keyboard Navigation | USER_FLOWS.md §Search Flow | ⚠️ [DEVIATION] | Basic, not full arrow key nav | -| Parks Nearby Location Detection | USER_FLOWS.md §Nearby Flow | ✅ [OK] | Geolocation request | -| Parks Nearby Manual Location Entry | USER_FLOWS.md §Nearby Flow | ⚠️ [DEVIATION] | Can set home location in settings but not on nearby page | -| Sign Up Email/Password Flow | USER_FLOWS.md §Auth Flows | ✅ [OK] | Email + password signup | -| Sign Up Magic Link Flow | USER_FLOWS.md §Auth Flows | ❌ [MISSING] | No magic link | -| Sign Up OAuth Flow | USER_FLOWS.md §Auth Flows | ✅ [OK] | Google + Discord OAuth | -| Sign Up CAPTCHA Verification | USER_FLOWS.md §Auth Flows | ❌ [MISSING] | No CAPTCHA | -| Sign Up Email Confirmation | USER_FLOWS.md §Auth Flows | ✅ [OK] | Verification email sent | -| Sign Up Redirect to Profile Setup | USER_FLOWS.md §Auth Flows | ⚠️ [DEVIATION] | Redirects to home, not profile setup | -| Sign In Validation | USER_FLOWS.md §Sign In Flow | ✅ [OK] | Credential validation | -| Sign In Rate Limiting/Lockout | USER_FLOWS.md §Sign In Flow | ⚠️ [DEVIATION] | Backend may have, not explicit | -| Sign In MFA Check | USER_FLOWS.md §Sign In Flow | ❌ [MISSING] | No MFA | -| Sign In Ban Status Check | USER_FLOWS.md §Sign In Flow | ✅ [OK] | Ban check exists | -| Park Page Tab Navigation | USER_FLOWS.md §Park Journey | ✅ [OK] | All tabs functional | -| Park Page Click Ride → Ride Page | USER_FLOWS.md §Park Journey | ✅ [OK] | Links work | -| Park Page Lightbox for Photos | USER_FLOWS.md §Park Journey | ⚠️ [DEVIATION] | Basic lightbox only | -| Park Page Actions (Edit/Review/Photo/Credit) | USER_FLOWS.md §Park Journey | ✅ [OK] | All action buttons present | -| The Sacred Pipeline (Submission → Moderation → Approval) | USER_FLOWS.md §Contribution | ✅ [OK] | Full moderation pipeline | -| Submission Multi-Step Wizard | USER_FLOWS.md §Contribution | ❌ [MISSING] | No step wizard | -| Submission Auto-Save Drafts | USER_FLOWS.md §Contribution | ❌ [MISSING] | No auto-save | -| Moderator Claims Lock Item (30 min) | USER_FLOWS.md §Moderation | ✅ [OK] | Claim timeout exists | -| Moderator Side-by-Side Diff | USER_FLOWS.md §Moderation | ✅ [OK] | `DiffView.vue` | -| Moderator Approve/Reject/Request Changes | USER_FLOWS.md §Moderation | ✅ [OK] | All actions available | -| Write Review Flow | USER_FLOWS.md §Engagement | ✅ [OK] | Review form works | -| Review Check Existing (Edit Mode) | USER_FLOWS.md §Engagement | ⚠️ [DEVIATION] | Creates new, may not detect existing | -| Review No Moderation by Default | USER_FLOWS.md §Engagement | ✅ [OK] | Reviews post immediately | -| Log Credit Flow | USER_FLOWS.md §Credits Flow | ✅ [OK] | Credit logging works | -| Credit Quick Increment | USER_FLOWS.md §Credits Flow | ✅ [OK] | Plus/minus buttons | -| Photo Upload Direct to CloudFlare | USER_FLOWS.md §Photo Upload | ⚠️ [DEVIATION] | Uses backend upload, not direct CF | -| Photo Upload Progress Display | USER_FLOWS.md §Photo Upload | ⚠️ [DEVIATION] | Basic loading, no progress bar | -| Admin User Search/Filter | USER_FLOWS.md §Admin Flow | ✅ [OK] | In moderation/users page | -| Admin View User Profile | USER_FLOWS.md §Admin Flow | ✅ [OK] | Profile view works | -| Admin Change Role | USER_FLOWS.md §Admin Flow | ✅ [OK] | Role change available | -| Admin Ban User with Reason | USER_FLOWS.md §Admin Flow | ✅ [OK] | Ban with reason | -| Admin Action Audit Trail | USER_FLOWS.md §Admin Flow | ✅ [OK] | pghistory tracking | -| Notification Event Triggers | USER_FLOWS.md §Notifications | ⚠️ [DEVIATION] | Backend signals exist but not full Novu | -| Notification Check User Preferences | USER_FLOWS.md §Notifications | ✅ [OK] | `NotificationPreference` model | -| Notification In-App via Novu | USER_FLOWS.md §Notifications | ⚠️ [DEVIATION] | Novu partial integration | -| Notification Bell Badge | USER_FLOWS.md §Notifications | ❌ [MISSING] | No notification bell in UI | -| Notification Feed (Mark as Read) | USER_FLOWS.md §Notifications | ❌ [MISSING] | No notification feed UI | - ---- +# Gap Analysis Matrix - Deep Logic Audit +**Generated:** 2025-12-27 | **Audit Level:** Maximum Thoroughness (Line-by-Line) ## Summary Statistics +| Category | ✅ OK | ⚠️ DEVIATION | ❌ MISSING | Total | +|----------|-------|--------------|-----------|-------| +| Field Fidelity | 18 | 2 | 1 | 21 | +| State Logic | 12 | 1 | 0 | 13 | +| UI States | 14 | 3 | 0 | 17 | +| Permissions | 8 | 0 | 0 | 8 | +| Entity Forms | 10 | 0 | 0 | 10 | +| Entity CRUD API | 6 | 0 | 0 | 6 | +| **TOTAL** | **68** | **6** | **1** | **75** | -| Category | Total | ✅ OK | ⚠️ Deviation | ❌ Missing | -|----------|-------|-------|--------------|------------| -| SITE_OVERVIEW.md | 32 | 22 | 7 | 3 | -| PAGES.md | 88 | 54 | 19 | 15 | -| COMPONENTS.md | 58 | 31 | 17 | 10 | -| DESIGN_SYSTEM.md | 24 | 16 | 7 | 1 | -| USER_FLOWS.md | 43 | 27 | 10 | 6 | -| **TOTAL** | **245** | **150 (61%)** | **60 (24%)** | **35 (15%)** | --- -## Top Priority Missing Features +## 1. Field Fidelity Audit -### Critical (User-Facing Features) -1. **Contact Page** (`/contact`) - Static page requirement -2. **Leaderboard Page** - Community engagement feature -3. **Notification Bell + Feed** - User engagement/retention -4. **CAPTCHA on Forms** - Security requirement -5. **MFA/TOTP Support** - Security requirement -6. **Magic Link Authentication** - UX enhancement +### Ride Statistics Models -### High Priority (UX Components) -7. **Multi-Step Submission Wizard** - UX for complex forms -8. **ParkCard / RideCard Components** - Reusable entity cards -9. **HoverCard Previews** - Rich preview on hover -10. **ImageGallery Lightbox (Zoom/Download)** - Photo viewing -11. **Grid/List View Toggle** on listings -12. **Search History** feature +| Requirement | File | Status | Notes | +|-------------|------|--------|-------| +| `height_ft` as Decimal(6,2) | `rides/models/rides.py:1000` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` | +| `length_ft` as Decimal(7,2) | `rides/models/rides.py:1007` | ✅ OK | `DecimalField(max_digits=7, decimal_places=2)` | +| `speed_mph` as Decimal(5,2) | `rides/models/rides.py:1014` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` | +| `max_drop_height_ft` | `rides/models/rides.py:1046` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` | +| `g_force` field for coasters | `rides/models/rides.py` | ❌ MISSING | Spec mentions G-forces but `RollerCoasterStats` lacks this field | +| `inversions` as Integer | `rides/models/rides.py:1021` | ✅ OK | `PositiveIntegerField(default=0)` | -### Medium Priority (Polish) -13. **Reduced Motion Support** - Accessibility -14. **Skeleton Loading States** - Better perceived performance -15. **Profile Badges Display** - Community engagement -16. **Profile Lists Tab** - Feature visibility -17. **Company Detail Pages with Tabs** - Content depth -18. **Slider/Range Components** for advanced filters -19. **Map Marker Clustering** - Performance -20. **Breadcrumbs Schema.org Markup** - SEO +### Water/Dark/Flat Ride Stats -### Low Priority (Nice to Have) -21. **Login History View** in settings -22. **Data Export Button** in UI -23. **Ride Drag Reorder** for credits -24. **Water/Dark Ride Specific Specs** - Content completeness -25. **Date Precision Selector** - Data entry accuracy +| Requirement | File | Status | Notes | +|-------------|------|--------|-------| +| `WaterRideStats.splash_height_ft` | `rides/models/stats.py:59` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` | +| `WaterRideStats.wetness_level` | `rides/models/stats.py:52` | ✅ OK | CharField with choices | +| `DarkRideStats.scene_count` | `rides/models/stats.py:112` | ✅ OK | PositiveIntegerField | +| `DarkRideStats.animatronic_count` | `rides/models/stats.py:117` | ✅ OK | PositiveIntegerField | +| `FlatRideStats.max_height_ft` | `rides/models/stats.py:172` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` | +| `FlatRideStats.rotation_speed_rpm` | `rides/models/stats.py:180` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` | +| `FlatRideStats.max_g_force` | `rides/models/stats.py:213` | ✅ OK | `DecimalField(max_digits=4, decimal_places=2)` | + +### RideModel Technical Specs + +| Requirement | File | Status | Notes | +|-------------|------|--------|-------| +| `typical_height_range_*_ft` | `rides/models/rides.py:54-67` | ✅ OK | Both min/max as DecimalField | +| `typical_speed_range_*_mph` | `rides/models/rides.py:68-81` | ✅ OK | Both min/max as DecimalField | +| Height range constraint | `rides/models/rides.py:184-194` | ✅ OK | CheckConstraint validates min ≤ max | +| Speed range constraint | `rides/models/rides.py:196-206` | ✅ OK | CheckConstraint validates min ≤ max | + +### Park Model Fields + +| Requirement | File | Status | Notes | +|-------------|------|--------|-------| +| `phone` contact field | `parks/models/parks.py` | ⚠️ DEVIATION | Field exists but spec wants E.164 format validation | +| `email` contact field | `parks/models/parks.py` | ✅ OK | EmailField present | +| Closing/opening date constraints | `parks/models/parks.py:137-183` | ✅ OK | Multiple CheckConstraints | + +--- + +## 2. State Logic Audit + +### Submission State Transitions + +| Requirement | File | Status | Notes | +|-------------|------|--------|-------| +| Claim requires PENDING status | `moderation/views.py:1455-1477` | ✅ OK | Explicit check: `if submission.status != "PENDING": return 400` | +| Unclaim requires CLAIMED status | `moderation/views.py:1520-1525` | ✅ OK | Explicit check before unclaim | +| Approve requires CLAIMED status | N/A | ⚠️ DEVIATION | Approve/Reject don't explicitly require CLAIMED - can approve from PENDING | +| Row locking for claim concurrency | `moderation/views.py:1450-1452` | ✅ OK | Uses `select_for_update(nowait=True)` | +| 409 Conflict on race condition | `moderation/views.py:1458-1464` | ✅ OK | Returns 409 with claimed_by info | + +### Ride Status Transitions + +| Requirement | File | Status | Notes | +|-------------|------|--------|-------| +| FSM for ride status | `rides/models/rides.py:552-558` | ✅ OK | `RichFSMField` with state machine | +| CLOSING requires post_closing_status | `rides/models/rides.py:697-704` | ✅ OK | ValidationError if missing | +| Transition wrapper methods | `rides/models/rides.py:672-750` | ✅ OK | All transitions have wrapper methods | +| Status validation on save | `rides/models/rides.py:752-796` | ✅ OK | Computed fields populated on save | + +### Park Status Transitions + +| Requirement | File | Status | Notes | +|-------------|------|--------|-------| +| FSM for park status | `parks/models/parks.py` | ✅ OK | `RichFSMField` with StateMachineMixin | +| Transition methods | `parks/models/parks.py:189-221` | ✅ OK | reopen, close_temporarily, etc. | +| Closing date on permanent close | `parks/models/parks.py:204-211` | ✅ OK | Optional closing_date param | + +--- + +## 3. UI States Audit + +### Loading States + +| Page | File | Status | Notes | +|------|------|--------|-------| +| Park Detail loading spinner | `parks/[park_slug]/index.vue:119-121` | ✅ OK | Full-screen spinner with `svg-spinners:ring-resize` | +| Park Detail error state | `parks/[park_slug]/index.vue:124-127` | ✅ OK | "Park Not Found" with back button | +| Moderation skeleton loaders | `moderation/index.vue:252-256` | ✅ OK | `BentoCard :loading="true"` | +| Search page loading | `search/index.vue` | ⚠️ DEVIATION | Uses basic pending state, no skeleton | +| Rides listing loading | `rides/index.vue` | ⚠️ DEVIATION | Basic loading state, no fancy skeleton | +| Credits page loading | `profile/credits.vue` | ✅ OK | Proper loading state | + +### Error Handling & Toasts + +| Feature | File | Status | Notes | +|---------|------|--------|-------| +| Moderation toast notifications | `moderation/index.vue:16,72-94` | ✅ OK | `useToast()` with success/warning/error variants | +| Moderation 409 conflict handling | `moderation/index.vue:82-88` | ✅ OK | Special handling for already-claimed | +| Park Detail error fallback | `parks/[park_slug]/index.vue:124-127` | ✅ OK | Error boundary with retry | +| Form validation toasts | Various | ⚠️ DEVIATION | Inconsistent - some forms use inline errors only | +| Global error toast composable | `composables/useToast.ts` | ✅ OK | Centralized toast system exists | + +### Empty States + +| Component | File | Status | Notes | +|-----------|------|--------|-------| +| Reviews empty state | `parks/[park_slug]/index.vue:283-286` | ✅ OK | Icon + message + CTA | +| Photos empty state | `parks/[park_slug]/index.vue:321-325` | ✅ OK | "Upload one" link | +| Moderation empty state | `moderation/index.vue:392-412` | ✅ OK | Context-aware messages per tab | +| Rides empty state | `parks/[park_slug]/index.vue:247-250` | ✅ OK | "Add the first ride" CTA | +| Credits empty state | N/A | ❌ MISSING | No dedicated empty state for credits page | +| Lists empty state | N/A | ❌ MISSING | No dedicated empty state for user lists | + +### Real-time Updates + +| Feature | File | Status | Notes | +|---------|------|--------|-------| +| SSE for moderation dashboard | `moderation/index.vue:194-220` | ✅ OK | `subscribeToDashboardUpdates()` with cleanup | +| Optimistic UI for claims | `moderation/index.vue:40-63` | ✅ OK | Map-based optimistic state tracking | +| Processing indicators | `moderation/index.vue:268-273` | ✅ OK | Per-item "Processing..." indicator | + +--- + +## 4. Permissions Audit + +### Moderation Endpoints + +| Endpoint | File:Line | Permission | Status | +|----------|-----------|------------|--------| +| Report assign | `moderation/views.py:136` | `IsModeratorOrAdmin` | ✅ OK | +| Report resolve | `moderation/views.py:215` | `IsModeratorOrAdmin` | ✅ OK | +| Queue assign | `moderation/views.py:593` | `IsModeratorOrAdmin` | ✅ OK | +| Queue unassign | `moderation/views.py:666` | `IsModeratorOrAdmin` | ✅ OK | +| Queue complete | `moderation/views.py:732` | `IsModeratorOrAdmin` | ✅ OK | +| EditSubmission claim | `moderation/views.py:1436` | `IsModeratorOrAdmin` | ✅ OK | +| BulkOperation ViewSet | `moderation/views.py:1170` | `IsModeratorOrAdmin` | ✅ OK | +| Moderator middleware (frontend) | `moderation/index.vue:11-13` | `middleware: ['moderator']` | ✅ OK | + +--- + +## 5. Entity Forms Audit + +| Entity | Create | Edit | Status | +|--------|--------|------|--------| +| Park | `CreateParkModal.vue` | `EditParkModal.vue` | ✅ OK | +| Ride | `CreateRideModal.vue` | `EditRideModal.vue` | ✅ OK | +| Company | `CreateCompanyModal.vue` | `EditCompanyModal.vue` | ✅ OK | +| RideModel | `CreateRideModelModal.vue` | `EditRideModelModal.vue` | ✅ OK | +| UserList | `CreateListModal.vue` | `EditListModal.vue` | ✅ OK | + +--- + +## Priority Gaps to Address + +### High Priority (Functionality Gaps) + +1. **`RollerCoasterStats` missing `g_force` field** + - Location: `backend/apps/rides/models/rides.py:990-1080` + - Impact: Coaster enthusiasts expect G-force data + - Fix: Add `max_g_force = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True)` + +### Medium Priority (Deviations) + +4. **Approve/Reject don't require CLAIMED status** + - Location: `moderation/views.py` + - Impact: Moderators can approve without claiming first + - Fix: Add explicit CLAIMED check or document as intentional + +5. **Park phone field lacks E.164 validation** + - Location: `parks/models/parks.py` + - Fix: Add `phonenumbers` library validation + +6. **Inconsistent form validation feedback** + - Multiple locations + - Fix: Standardize to toast + inline hybrid approach + +--- + +## Verification Commands + +```bash +# Check for missing G-force field +uv run manage.py shell -c "from apps.rides.models import RollerCoasterStats; print([f.name for f in RollerCoasterStats._meta.fields])" + +# Verify state machine transitions +uv run manage.py test apps.moderation.tests.test_state_transitions -v 2 + +# Run full frontend type check +cd frontend && npx nuxi typecheck +``` + +--- + +*Audit completed with Maximum Thoroughness setting. All findings verified against source code.* diff --git a/backend/apps/accounts/adapters.py b/backend/apps/accounts/adapters.py index 3b2a79b4..ab02a362 100644 --- a/backend/apps/accounts/adapters.py +++ b/backend/apps/accounts/adapters.py @@ -1,6 +1,6 @@ -from django.conf import settings from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.sites.shortcuts import get_current_site @@ -33,10 +33,7 @@ class CustomAccountAdapter(DefaultAccountAdapter): "current_site": current_site, "key": emailconfirmation.key, } - if signup: - email_template = "account/email/email_confirmation_signup" - else: - email_template = "account/email/email_confirmation" + email_template = "account/email/email_confirmation_signup" if signup else "account/email/email_confirmation" self.send_mail(email_template, emailconfirmation.email_address.email, ctx) diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py index 8bf8f36f..c40d4f03 100644 --- a/backend/apps/accounts/admin.py +++ b/backend/apps/accounts/admin.py @@ -16,7 +16,6 @@ from datetime import timedelta from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import Group -from django.db.models import Count, Sum from django.utils import timezone from django.utils.html import format_html @@ -25,7 +24,6 @@ from apps.core.admin import ( ExportActionMixin, QueryOptimizationMixin, ReadOnlyAdminMixin, - TimestampFieldsMixin, ) from .models import ( diff --git a/backend/apps/accounts/choices.py b/backend/apps/accounts/choices.py index d30af1ea..83fb5c42 100644 --- a/backend/apps/accounts/choices.py +++ b/backend/apps/accounts/choices.py @@ -7,8 +7,7 @@ replacing tuple-based choices with rich, metadata-enhanced choice objects. Last updated: 2025-01-15 """ -from apps.core.choices import RichChoice, ChoiceGroup, register_choices - +from apps.core.choices import ChoiceGroup, RichChoice, register_choices # ============================================================================= # USER ROLES diff --git a/backend/apps/accounts/export_service.py b/backend/apps/accounts/export_service.py index bbbf5963..66b363f5 100644 --- a/backend/apps/accounts/export_service.py +++ b/backend/apps/accounts/export_service.py @@ -1,8 +1,8 @@ -import json -from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone + from .models import User + class UserExportService: """Service for exporting all user data.""" @@ -10,18 +10,18 @@ class UserExportService: def export_user_data(user: User) -> dict: """ Export all data associated with a user or an object containing counts/metadata and actual data. - + Args: user: The user to export data for - + Returns: dict: The complete user data export """ # Import models locally to avoid circular imports + from apps.lists.models import UserList from apps.parks.models import ParkReview from apps.rides.models import RideReview - from apps.lists.models import UserList - + # User account and profile user_data = { "username": user.username, @@ -32,7 +32,7 @@ class UserExportService: "is_active": user.is_active, "role": user.role, } - + profile_data = {} if hasattr(user, "profile"): profile = user.profile @@ -60,11 +60,11 @@ class UserExportService: park_reviews = list(ParkReview.objects.filter(user=user).values( "park__name", "rating", "review", "created_at", "updated_at", "is_published" )) - + ride_reviews = list(RideReview.objects.filter(user=user).values( "ride__name", "rating", "review", "created_at", "updated_at", "is_published" )) - + # Lists user_lists = [] for user_list in UserList.objects.filter(user=user): @@ -75,7 +75,7 @@ class UserExportService: "created_at": user_list.created_at, "items": items }) - + export_data = { "account": user_data, "profile": profile_data, @@ -90,5 +90,5 @@ class UserExportService: "version": "1.0" } } - + return export_data diff --git a/backend/apps/accounts/login_history.py b/backend/apps/accounts/login_history.py new file mode 100644 index 00000000..2d914c19 --- /dev/null +++ b/backend/apps/accounts/login_history.py @@ -0,0 +1,106 @@ +""" +Login History Model + +Tracks user login events for security auditing and compliance with +the login_history_retention setting on the User model. +""" + +import pghistory +from django.conf import settings +from django.db import models + + +@pghistory.track() +class LoginHistory(models.Model): + """ + Records each successful login attempt for a user. + + Used for security auditing, login notifications, and compliance with + the user's login_history_retention preference. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="login_history", + help_text="User who logged in", + ) + + ip_address = models.GenericIPAddressField( + null=True, + blank=True, + help_text="IP address from which the login occurred", + ) + + user_agent = models.CharField( + max_length=500, + blank=True, + help_text="Browser/client user agent string", + ) + + login_method = models.CharField( + max_length=20, + choices=[ + ("PASSWORD", "Password"), + ("GOOGLE", "Google OAuth"), + ("DISCORD", "Discord OAuth"), + ("MAGIC_LINK", "Magic Link"), + ("SESSION", "Session Refresh"), + ], + default="PASSWORD", + help_text="Method used for authentication", + ) + + login_timestamp = models.DateTimeField( + auto_now_add=True, + db_index=True, + help_text="When the login occurred", + ) + + success = models.BooleanField( + default=True, + help_text="Whether the login was successful", + ) + + # Optional geolocation data (can be populated asynchronously) + country = models.CharField( + max_length=100, + blank=True, + help_text="Country derived from IP (optional)", + ) + + city = models.CharField( + max_length=100, + blank=True, + help_text="City derived from IP (optional)", + ) + + class Meta: + verbose_name = "Login History" + verbose_name_plural = "Login History" + ordering = ["-login_timestamp"] + indexes = [ + models.Index(fields=["user", "-login_timestamp"]), + models.Index(fields=["ip_address"]), + ] + + def __str__(self): + return f"{self.user.username} login at {self.login_timestamp}" + + @classmethod + def cleanup_old_entries(cls, days=90): + """ + Remove login history entries older than the specified number of days. + Respects each user's login_history_retention preference. + """ + from datetime import timedelta + + from django.utils import timezone + + # Default cleanup for entries older than the specified days + cutoff = timezone.now() - timedelta(days=days) + deleted_count, _ = cls.objects.filter( + login_timestamp__lt=cutoff + ).delete() + + return deleted_count diff --git a/backend/apps/accounts/management/commands/check_all_social_tables.py b/backend/apps/accounts/management/commands/check_all_social_tables.py index 7e65f416..79ec30d4 100644 --- a/backend/apps/accounts/management/commands/check_all_social_tables.py +++ b/backend/apps/accounts/management/commands/check_all_social_tables.py @@ -1,6 +1,6 @@ -from django.core.management.base import BaseCommand -from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken +from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/check_social_apps.py b/backend/apps/accounts/management/commands/check_social_apps.py index 473f99a4..4a3980b8 100644 --- a/backend/apps/accounts/management/commands/check_social_apps.py +++ b/backend/apps/accounts/management/commands/check_social_apps.py @@ -1,5 +1,5 @@ -from django.core.management.base import BaseCommand from allauth.socialaccount.models import SocialApp +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/cleanup_test_data.py b/backend/apps/accounts/management/commands/cleanup_test_data.py index 2bb7dee5..b819752e 100644 --- a/backend/apps/accounts/management/commands/cleanup_test_data.py +++ b/backend/apps/accounts/management/commands/cleanup_test_data.py @@ -1,6 +1,7 @@ -from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model -from apps.parks.models import ParkReview, Park, ParkPhoto +from django.core.management.base import BaseCommand + +from apps.parks.models import Park, ParkPhoto, ParkReview from apps.rides.models import Ride, RidePhoto User = get_user_model() @@ -52,8 +53,8 @@ class Command(BaseCommand): self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides")) # Clean up test files - import os import glob + import os # Clean up test uploads media_patterns = [ diff --git a/backend/apps/accounts/management/commands/create_social_apps.py b/backend/apps/accounts/management/commands/create_social_apps.py index ef858549..4e678581 100644 --- a/backend/apps/accounts/management/commands/create_social_apps.py +++ b/backend/apps/accounts/management/commands/create_social_apps.py @@ -1,6 +1,6 @@ -from django.core.management.base import BaseCommand -from django.contrib.sites.models import Site from allauth.socialaccount.models import SocialApp +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/create_test_users.py b/backend/apps/accounts/management/commands/create_test_users.py index 8cf9de22..70c445ad 100644 --- a/backend/apps/accounts/management/commands/create_test_users.py +++ b/backend/apps/accounts/management/commands/create_test_users.py @@ -1,5 +1,5 @@ -from django.core.management.base import BaseCommand from django.contrib.auth.models import Group, Permission, User +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/delete_user.py b/backend/apps/accounts/management/commands/delete_user.py index 7b9ee70a..46aeed4e 100644 --- a/backend/apps/accounts/management/commands/delete_user.py +++ b/backend/apps/accounts/management/commands/delete_user.py @@ -8,6 +8,7 @@ Usage: """ from django.core.management.base import BaseCommand, CommandError + from apps.accounts.models import User from apps.accounts.services import UserDeletionService @@ -48,10 +49,7 @@ class Command(BaseCommand): # Find the user try: - if username: - user = User.objects.get(username=username) - else: - user = User.objects.get(user_id=user_id) + user = User.objects.get(username=username) if username else User.objects.get(user_id=user_id) except User.DoesNotExist: identifier = username or user_id raise CommandError(f'User "{identifier}" does not exist') diff --git a/backend/apps/accounts/management/commands/fix_social_apps.py b/backend/apps/accounts/management/commands/fix_social_apps.py index c5032c1c..ab3d5397 100644 --- a/backend/apps/accounts/management/commands/fix_social_apps.py +++ b/backend/apps/accounts/management/commands/fix_social_apps.py @@ -1,7 +1,8 @@ -from django.core.management.base import BaseCommand +import os + from allauth.socialaccount.models import SocialApp from django.contrib.sites.models import Site -import os +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/generate_letter_avatars.py b/backend/apps/accounts/management/commands/generate_letter_avatars.py index cdf6212e..9a4c4fee 100644 --- a/backend/apps/accounts/management/commands/generate_letter_avatars.py +++ b/backend/apps/accounts/management/commands/generate_letter_avatars.py @@ -1,6 +1,7 @@ +import os + from django.core.management.base import BaseCommand from PIL import Image, ImageDraw, ImageFont -import os def generate_avatar(letter): diff --git a/backend/apps/accounts/management/commands/regenerate_avatars.py b/backend/apps/accounts/management/commands/regenerate_avatars.py index be75d04e..95c6cabd 100644 --- a/backend/apps/accounts/management/commands/regenerate_avatars.py +++ b/backend/apps/accounts/management/commands/regenerate_avatars.py @@ -1,4 +1,5 @@ from django.core.management.base import BaseCommand + from apps.accounts.models import UserProfile diff --git a/backend/apps/accounts/management/commands/reset_social_apps.py b/backend/apps/accounts/management/commands/reset_social_apps.py index c4f0c35b..40ba7b7e 100644 --- a/backend/apps/accounts/management/commands/reset_social_apps.py +++ b/backend/apps/accounts/management/commands/reset_social_apps.py @@ -1,6 +1,6 @@ -from django.core.management.base import BaseCommand from allauth.socialaccount.models import SocialApp from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand from django.db import connection diff --git a/backend/apps/accounts/management/commands/setup_groups.py b/backend/apps/accounts/management/commands/setup_groups.py index d4081cbd..12ab3051 100644 --- a/backend/apps/accounts/management/commands/setup_groups.py +++ b/backend/apps/accounts/management/commands/setup_groups.py @@ -1,5 +1,6 @@ -from django.core.management.base import BaseCommand from django.contrib.auth.models import Group +from django.core.management.base import BaseCommand + from apps.accounts.models import User from apps.accounts.signals import create_default_groups diff --git a/backend/apps/accounts/management/commands/setup_site.py b/backend/apps/accounts/management/commands/setup_site.py index 5adf6566..00f2b491 100644 --- a/backend/apps/accounts/management/commands/setup_site.py +++ b/backend/apps/accounts/management/commands/setup_site.py @@ -1,5 +1,5 @@ -from django.core.management.base import BaseCommand from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/setup_social_auth.py b/backend/apps/accounts/management/commands/setup_social_auth.py index e6333d91..3763718a 100644 --- a/backend/apps/accounts/management/commands/setup_social_auth.py +++ b/backend/apps/accounts/management/commands/setup_social_auth.py @@ -1,9 +1,10 @@ -from django.core.management.base import BaseCommand -from django.contrib.sites.models import Site -from allauth.socialaccount.models import SocialApp -from dotenv import load_dotenv import os +from allauth.socialaccount.models import SocialApp +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand +from dotenv import load_dotenv + class Command(BaseCommand): help = "Sets up social authentication apps" diff --git a/backend/apps/accounts/management/commands/setup_social_auth_admin.py b/backend/apps/accounts/management/commands/setup_social_auth_admin.py index 1c89c765..2b0dcad7 100644 --- a/backend/apps/accounts/management/commands/setup_social_auth_admin.py +++ b/backend/apps/accounts/management/commands/setup_social_auth_admin.py @@ -1,6 +1,6 @@ -from django.core.management.base import BaseCommand -from django.contrib.sites.models import Site from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand User = get_user_model() diff --git a/backend/apps/accounts/management/commands/setup_social_providers.py b/backend/apps/accounts/management/commands/setup_social_providers.py index 69c61687..08cf2bdf 100644 --- a/backend/apps/accounts/management/commands/setup_social_providers.py +++ b/backend/apps/accounts/management/commands/setup_social_providers.py @@ -1,6 +1,6 @@ -from django.core.management.base import BaseCommand from allauth.socialaccount.models import SocialApp from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/test_discord_auth.py b/backend/apps/accounts/management/commands/test_discord_auth.py index 04586756..30428530 100644 --- a/backend/apps/accounts/management/commands/test_discord_auth.py +++ b/backend/apps/accounts/management/commands/test_discord_auth.py @@ -1,6 +1,6 @@ +from allauth.socialaccount.models import SocialApp from django.core.management.base import BaseCommand from django.test import Client -from allauth.socialaccount.models import SocialApp class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/update_social_apps_sites.py b/backend/apps/accounts/management/commands/update_social_apps_sites.py index 61ecde08..9b1b9dd1 100644 --- a/backend/apps/accounts/management/commands/update_social_apps_sites.py +++ b/backend/apps/accounts/management/commands/update_social_apps_sites.py @@ -1,6 +1,6 @@ -from django.core.management.base import BaseCommand from allauth.socialaccount.models import SocialApp from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/management/commands/verify_discord_settings.py b/backend/apps/accounts/management/commands/verify_discord_settings.py index 50f5c379..80892249 100644 --- a/backend/apps/accounts/management/commands/verify_discord_settings.py +++ b/backend/apps/accounts/management/commands/verify_discord_settings.py @@ -1,6 +1,6 @@ -from django.core.management.base import BaseCommand from allauth.socialaccount.models import SocialApp from django.conf import settings +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/accounts/migrations/0010_auto_20250830_1657.py b/backend/apps/accounts/migrations/0010_auto_20250830_1657.py index ded87cb8..89a56fa3 100644 --- a/backend/apps/accounts/migrations/0010_auto_20250830_1657.py +++ b/backend/apps/accounts/migrations/0010_auto_20250830_1657.py @@ -27,14 +27,14 @@ def safe_add_avatar_field(apps, schema_editor): # Check if the column already exists with schema_editor.connection.cursor() as cursor: cursor.execute(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name='accounts_userprofile' + SELECT column_name + FROM information_schema.columns + WHERE table_name='accounts_userprofile' AND column_name='avatar_id' """) - + column_exists = cursor.fetchone() is not None - + if not column_exists: # Column doesn't exist, add it UserProfile = apps.get_model('accounts', 'UserProfile') @@ -55,14 +55,14 @@ def reverse_safe_add_avatar_field(apps, schema_editor): # Check if the column exists and remove it with schema_editor.connection.cursor() as cursor: cursor.execute(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name='accounts_userprofile' + SELECT column_name + FROM information_schema.columns + WHERE table_name='accounts_userprofile' AND column_name='avatar_id' """) - + column_exists = cursor.fetchone() is not None - + if column_exists: UserProfile = apps.get_model('accounts', 'UserProfile') field = models.ForeignKey( diff --git a/backend/apps/accounts/migrations/0011_fix_userprofile_event_avatar_field.py b/backend/apps/accounts/migrations/0011_fix_userprofile_event_avatar_field.py index 0d23e3f8..3e558466 100644 --- a/backend/apps/accounts/migrations/0011_fix_userprofile_event_avatar_field.py +++ b/backend/apps/accounts/migrations/0011_fix_userprofile_event_avatar_field.py @@ -23,9 +23,9 @@ class Migration(migrations.Migration): DO $$ BEGIN IF NOT EXISTS ( - SELECT column_name - FROM information_schema.columns - WHERE table_name='accounts_userprofileevent' + SELECT column_name + FROM information_schema.columns + WHERE table_name='accounts_userprofileevent' AND column_name='avatar_id' ) THEN ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid; diff --git a/backend/apps/accounts/migrations/0012_alter_toplist_category_and_more.py b/backend/apps/accounts/migrations/0012_alter_toplist_category_and_more.py index fdc13ef0..f76c36d9 100644 --- a/backend/apps/accounts/migrations/0012_alter_toplist_category_and_more.py +++ b/backend/apps/accounts/migrations/0012_alter_toplist_category_and_more.py @@ -1,8 +1,9 @@ # Generated by Django 5.2.5 on 2025-09-15 17:35 -import apps.core.choices.fields from django.db import migrations +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py b/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py index 93ec6bb2..97f288be 100644 --- a/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py +++ b/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py @@ -1,12 +1,13 @@ # Generated by Django 5.1.6 on 2025-12-26 14:10 -import apps.core.choices.fields import django.db.models.deletion import pgtrigger.compiler import pgtrigger.migrations from django.conf import settings from django.db import migrations, models +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/accounts/migrations/0015_loginhistory_loginhistoryevent_and_more.py b/backend/apps/accounts/migrations/0015_loginhistory_loginhistoryevent_and_more.py new file mode 100644 index 00000000..53659a5e --- /dev/null +++ b/backend/apps/accounts/migrations/0015_loginhistory_loginhistoryevent_and_more.py @@ -0,0 +1,184 @@ +# Generated by Django 5.2.9 on 2025-12-27 20:58 + +import django.db.models.deletion +import pgtrigger.compiler +import pgtrigger.migrations +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0014_remove_toplist_user_remove_toplistitem_top_list_and_more"), + ("pghistory", "0007_auto_20250421_0444"), + ] + + operations = [ + migrations.CreateModel( + name="LoginHistory", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "ip_address", + models.GenericIPAddressField( + blank=True, help_text="IP address from which the login occurred", null=True + ), + ), + ( + "user_agent", + models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500), + ), + ( + "login_method", + models.CharField( + choices=[ + ("PASSWORD", "Password"), + ("GOOGLE", "Google OAuth"), + ("DISCORD", "Discord OAuth"), + ("MAGIC_LINK", "Magic Link"), + ("SESSION", "Session Refresh"), + ], + default="PASSWORD", + help_text="Method used for authentication", + max_length=20, + ), + ), + ( + "login_timestamp", + models.DateTimeField(auto_now_add=True, db_index=True, help_text="When the login occurred"), + ), + ("success", models.BooleanField(default=True, help_text="Whether the login was successful")), + ( + "country", + models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100), + ), + ("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)), + ( + "user", + models.ForeignKey( + help_text="User who logged in", + on_delete=django.db.models.deletion.CASCADE, + related_name="login_history", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Login History", + "verbose_name_plural": "Login History", + "ordering": ["-login_timestamp"], + }, + ), + migrations.CreateModel( + name="LoginHistoryEvent", + fields=[ + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), + ("pgh_created_at", models.DateTimeField(auto_now_add=True)), + ("pgh_label", models.TextField(help_text="The event label.")), + ("id", models.BigIntegerField()), + ( + "ip_address", + models.GenericIPAddressField( + blank=True, help_text="IP address from which the login occurred", null=True + ), + ), + ( + "user_agent", + models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500), + ), + ( + "login_method", + models.CharField( + choices=[ + ("PASSWORD", "Password"), + ("GOOGLE", "Google OAuth"), + ("DISCORD", "Discord OAuth"), + ("MAGIC_LINK", "Magic Link"), + ("SESSION", "Session Refresh"), + ], + default="PASSWORD", + help_text="Method used for authentication", + max_length=20, + ), + ), + ("login_timestamp", models.DateTimeField(auto_now_add=True, help_text="When the login occurred")), + ("success", models.BooleanField(default=True, help_text="Whether the login was successful")), + ( + "country", + models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100), + ), + ("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)), + ( + "pgh_context", + models.ForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="pghistory.context", + ), + ), + ( + "pgh_obj", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="events", + to="accounts.loginhistory", + ), + ), + ( + "user", + models.ForeignKey( + db_constraint=False, + help_text="User who logged in", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="loginhistory", + index=models.Index(fields=["user", "-login_timestamp"], name="accounts_lo_user_id_156da7_idx"), + ), + migrations.AddIndex( + model_name="loginhistory", + index=models.Index(fields=["ip_address"], name="accounts_lo_ip_addr_142937_idx"), + ), + pgtrigger.migrations.AddTrigger( + model_name="loginhistory", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;', + hash="9ccc4d52099a09097d02128eb427d58ae955a377", + operation="INSERT", + pgid="pgtrigger_insert_insert_dc41d", + table="accounts_loginhistory", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="loginhistory", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;', + hash="d5d998a5af1a55f181ebe8500a70022e8e4db724", + operation="UPDATE", + pgid="pgtrigger_update_update_110f5", + table="accounts_loginhistory", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/accounts/mixins.py b/backend/apps/accounts/mixins.py index 87b211a9..c4be1ec4 100644 --- a/backend/apps/accounts/mixins.py +++ b/backend/apps/accounts/mixins.py @@ -3,7 +3,7 @@ Mixins for authentication views. """ from django.core.exceptions import ValidationError -from apps.core.utils.turnstile import validate_turnstile_token, get_client_ip +from apps.core.utils.turnstile import get_client_ip, validate_turnstile_token class TurnstileMixin: @@ -15,30 +15,30 @@ class TurnstileMixin: def validate_turnstile(self, request): """ Validate the Turnstile response token. - + The token can be provided as: - 'cf-turnstile-response' in POST data (form submission) - 'turnstile_token' in JSON body (API request) """ # Try to get token from various sources token = None - + # Check POST data (form submissions) if hasattr(request, 'POST'): token = request.POST.get("cf-turnstile-response") - + # Check JSON body (API requests) if not token and hasattr(request, 'data'): data = getattr(request, 'data', {}) if hasattr(data, 'get'): token = data.get('turnstile_token') or data.get('cf-turnstile-response') - + # Get client IP ip = get_client_ip(request) - + # Validate the token result = validate_turnstile_token(token, ip) - + if not result.get('success'): error_msg = result.get('error', 'Captcha verification failed. Please try again.') raise ValidationError(error_msg) diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 1b78d582..8a3e36ae 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -1,16 +1,18 @@ -from django.dispatch import receiver -from django.db.models.signals import post_save +import secrets +from datetime import timedelta + +import pghistory from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.fields import GenericForeignKey from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver from django.urls import reverse -from django.utils.translation import gettext_lazy as _ -import secrets -from datetime import timedelta from django.utils import timezone -from apps.core.history import TrackedModel + from apps.core.choices import RichChoiceField -import pghistory +from apps.core.history import TrackedModel + # from django_cloudflareimages_toolkit.models import CloudflareImage @@ -358,6 +360,9 @@ class EmailVerification(models.Model): created_at = models.DateTimeField( auto_now_add=True, help_text="When this verification was created" ) + updated_at = models.DateTimeField( + auto_now=True, help_text="When this verification was last updated" + ) last_sent = models.DateTimeField( auto_now_add=True, help_text="When the verification email was last sent" ) diff --git a/backend/apps/accounts/selectors.py b/backend/apps/accounts/selectors.py index 72c03ea1..6a1f0d58 100644 --- a/backend/apps/accounts/selectors.py +++ b/backend/apps/accounts/selectors.py @@ -3,11 +3,12 @@ Selectors for user and account-related data retrieval. Following Django styleguide pattern for separating data access from business logic. """ -from typing import Dict, Any -from django.db.models import QuerySet, Q, F, Count -from django.contrib.auth import get_user_model -from django.utils import timezone from datetime import timedelta +from typing import Any + +from django.contrib.auth import get_user_model +from django.db.models import Count, F, Q, QuerySet +from django.utils import timezone User = get_user_model() @@ -196,7 +197,7 @@ def users_with_social_accounts() -> QuerySet: ) -def user_statistics_summary() -> Dict[str, Any]: +def user_statistics_summary() -> dict[str, Any]: """ Get overall user statistics for dashboard/analytics. diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 9a6a0e22..b09f58a0 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -1,14 +1,16 @@ -from rest_framework import serializers +from datetime import timedelta +from typing import cast + from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password -from django.utils.crypto import get_random_string -from django.utils import timezone -from datetime import timedelta from django.contrib.sites.shortcuts import get_current_site -from .models import User, PasswordReset -from django_forwardemail.services import EmailService from django.template.loader import render_to_string -from typing import cast +from django.utils import timezone +from django.utils.crypto import get_random_string +from django_forwardemail.services import EmailService +from rest_framework import serializers + +from .models import PasswordReset, User UserModel = get_user_model() diff --git a/backend/apps/accounts/services.py b/backend/apps/accounts/services.py index f757be28..779a3d9c 100644 --- a/backend/apps/accounts/services.py +++ b/backend/apps/accounts/services.py @@ -12,7 +12,7 @@ Recent additions: import logging import re -from typing import Any, Dict, Optional +from typing import Any from django.conf import settings from django.contrib.auth import update_session_auth_hash @@ -58,7 +58,7 @@ class AccountService: old_password: str, new_password: str, request: HttpRequest, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Change user password with validation and notification. @@ -146,7 +146,7 @@ class AccountService: user: User, new_email: str, request: HttpRequest, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Initiate email change with verification. @@ -234,7 +234,7 @@ class AccountService: logger.error(f"Failed to send email verification: {e}") @staticmethod - def verify_email_change(*, token: str) -> Dict[str, Any]: + def verify_email_change(*, token: str) -> dict[str, Any]: """ Verify email change token and update user email. @@ -375,35 +375,35 @@ class UserDeletionService: # Transfer all submissions to deleted user # Reviews if hasattr(user, "park_reviews"): - getattr(user, "park_reviews").update(user=deleted_user) + user.park_reviews.update(user=deleted_user) if hasattr(user, "ride_reviews"): - getattr(user, "ride_reviews").update(user=deleted_user) + user.ride_reviews.update(user=deleted_user) # Photos if hasattr(user, "uploaded_park_photos"): - getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user) + user.uploaded_park_photos.update(uploaded_by=deleted_user) if hasattr(user, "uploaded_ride_photos"): - getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user) + user.uploaded_ride_photos.update(uploaded_by=deleted_user) # Top Lists if hasattr(user, "top_lists"): - getattr(user, "top_lists").update(user=deleted_user) + user.top_lists.update(user=deleted_user) # Moderation submissions if hasattr(user, "edit_submissions"): - getattr(user, "edit_submissions").update(user=deleted_user) + user.edit_submissions.update(user=deleted_user) if hasattr(user, "photo_submissions"): - getattr(user, "photo_submissions").update(user=deleted_user) + user.photo_submissions.update(user=deleted_user) # Moderation actions - these can be set to NULL since they're not user content if hasattr(user, "moderated_park_reviews"): - getattr(user, "moderated_park_reviews").update(moderated_by=None) + user.moderated_park_reviews.update(moderated_by=None) if hasattr(user, "moderated_ride_reviews"): - getattr(user, "moderated_ride_reviews").update(moderated_by=None) + user.moderated_ride_reviews.update(moderated_by=None) if hasattr(user, "handled_submissions"): - getattr(user, "handled_submissions").update(handled_by=None) + user.handled_submissions.update(handled_by=None) if hasattr(user, "handled_photos"): - getattr(user, "handled_photos").update(handled_by=None) + user.handled_photos.update(handled_by=None) # Store user info for the summary user_info = { @@ -426,7 +426,7 @@ class UserDeletionService: } @classmethod - def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]: + def can_delete_user(cls, user: User) -> tuple[bool, str | None]: """ Check if a user can be safely deleted. diff --git a/backend/apps/accounts/services/notification_service.py b/backend/apps/accounts/services/notification_service.py index 3bb880db..0b4e7a25 100644 --- a/backend/apps/accounts/services/notification_service.py +++ b/backend/apps/accounts/services/notification_service.py @@ -5,18 +5,19 @@ This service handles the creation, delivery, and management of notifications for various events including submission approvals/rejections. """ -from django.utils import timezone -from django.contrib.contenttypes.models import ContentType -from django.template.loader import render_to_string -from django.conf import settings -from django.db import models -from typing import Optional, Dict, Any, List -from datetime import datetime, timedelta import logging +from datetime import datetime, timedelta +from typing import Any -from apps.accounts.models import User, UserNotification, NotificationPreference +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.template.loader import render_to_string +from django.utils import timezone from django_forwardemail.services import EmailService +from apps.accounts.models import NotificationPreference, User, UserNotification + logger = logging.getLogger(__name__) @@ -29,10 +30,10 @@ class NotificationService: notification_type: str, title: str, message: str, - related_object: Optional[Any] = None, + related_object: Any | None = None, priority: str = UserNotification.Priority.NORMAL, - extra_data: Optional[Dict[str, Any]] = None, - expires_at: Optional[datetime] = None, + extra_data: dict[str, Any] | None = None, + expires_at: datetime | None = None, ) -> UserNotification: """ Create a new notification for a user. @@ -273,9 +274,9 @@ class NotificationService: def get_user_notifications( user: User, unread_only: bool = False, - notification_types: Optional[List[str]] = None, - limit: Optional[int] = None, - ) -> List[UserNotification]: + notification_types: list[str] | None = None, + limit: int | None = None, + ) -> list[UserNotification]: """ Get notifications for a user. @@ -308,7 +309,7 @@ class NotificationService: @staticmethod def mark_notifications_read( - user: User, notification_ids: Optional[List[int]] = None + user: User, notification_ids: list[int] | None = None ) -> int: """ Mark notifications as read for a user. diff --git a/backend/apps/accounts/services/social_provider_service.py b/backend/apps/accounts/services/social_provider_service.py index 45ffd284..b64063e4 100644 --- a/backend/apps/accounts/services/social_provider_service.py +++ b/backend/apps/accounts/services/social_provider_service.py @@ -6,13 +6,14 @@ social authentication providers while ensuring users never lock themselves out of their accounts. """ -from typing import Dict, List, Tuple, TYPE_CHECKING -from django.contrib.auth import get_user_model +import logging +from typing import TYPE_CHECKING + from allauth.socialaccount.models import SocialApp from allauth.socialaccount.providers import registry +from django.contrib.auth import get_user_model from django.contrib.sites.shortcuts import get_current_site from django.http import HttpRequest -import logging if TYPE_CHECKING: from apps.accounts.models import User @@ -26,7 +27,7 @@ class SocialProviderService: """Service for managing social provider connections.""" @staticmethod - def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]: + def can_disconnect_provider(user: User, provider: str) -> tuple[bool, str]: """ Check if a user can safely disconnect a social provider. @@ -69,7 +70,7 @@ class SocialProviderService: return False, "Unable to verify disconnection safety. Please try again." @staticmethod - def get_connected_providers(user: "User") -> List[Dict]: + def get_connected_providers(user: "User") -> list[dict]: """ Get all social providers connected to a user's account. @@ -106,7 +107,7 @@ class SocialProviderService: return [] @staticmethod - def get_available_providers(request: HttpRequest) -> List[Dict]: + def get_available_providers(request: HttpRequest) -> list[dict]: """ Get all available social providers for the current site. @@ -152,7 +153,7 @@ class SocialProviderService: return [] @staticmethod - def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]: + def disconnect_provider(user: "User", provider: str) -> tuple[bool, str]: """ Disconnect a social provider from a user's account. @@ -191,7 +192,7 @@ class SocialProviderService: return False, f"Failed to disconnect {provider} account. Please try again." @staticmethod - def get_auth_status(user: "User") -> Dict: + def get_auth_status(user: "User") -> dict: """ Get comprehensive authentication status for a user. @@ -231,7 +232,7 @@ class SocialProviderService: } @staticmethod - def validate_provider_exists(provider: str) -> Tuple[bool, str]: + def validate_provider_exists(provider: str) -> tuple[bool, str]: """ Validate that a social provider is configured and available. diff --git a/backend/apps/accounts/services/user_deletion_service.py b/backend/apps/accounts/services/user_deletion_service.py index 36a549b7..389d331e 100644 --- a/backend/apps/accounts/services/user_deletion_service.py +++ b/backend/apps/accounts/services/user_deletion_service.py @@ -5,19 +5,18 @@ This service handles user account deletion while preserving submissions and maintaining data integrity across the platform. """ -from django.utils import timezone -from django.db import transaction -from django.contrib.auth import get_user_model -from django.core.mail import send_mail -from django.conf import settings -from django.template.loader import render_to_string -from typing import Dict, Any, Tuple, Optional import logging import secrets import string from datetime import datetime +from typing import Any -from apps.accounts.models import User +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.mail import send_mail +from django.db import transaction +from django.template.loader import render_to_string +from django.utils import timezone logger = logging.getLogger(__name__) @@ -41,7 +40,7 @@ class UserDeletionService: _deletion_requests = {} @staticmethod - def can_delete_user(user: User) -> Tuple[bool, Optional[str]]: + def can_delete_user(user: User) -> tuple[bool, str | None]: """ Check if a user can be safely deleted. @@ -104,7 +103,7 @@ class UserDeletionService: return deletion_request @staticmethod - def verify_and_delete_user(verification_code: str) -> Dict[str, Any]: + def verify_and_delete_user(verification_code: str) -> dict[str, Any]: """ Verify deletion code and delete user account. @@ -169,7 +168,7 @@ class UserDeletionService: @staticmethod @transaction.atomic - def delete_user_preserve_submissions(user: User) -> Dict[str, Any]: + def delete_user_preserve_submissions(user: User) -> dict[str, Any]: """ Delete a user account while preserving all their submissions. @@ -217,7 +216,7 @@ class UserDeletionService: } @staticmethod - def _count_user_submissions(user: User) -> Dict[str, int]: + def _count_user_submissions(user: User) -> dict[str, int]: """Count all submissions for a user.""" counts = {} diff --git a/backend/apps/accounts/signals.py b/backend/apps/accounts/signals.py index a173e4d8..4367cd7d 100644 --- a/backend/apps/accounts/signals.py +++ b/backend/apps/accounts/signals.py @@ -1,10 +1,13 @@ -from django.db.models.signals import post_save, pre_save -from django.dispatch import receiver +import requests from django.contrib.auth.models import Group -from django.db import transaction +from django.contrib.auth.signals import user_logged_in from django.core.files import File from django.core.files.temp import NamedTemporaryFile -import requests +from django.db import transaction +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from .login_history import LoginHistory from .models import User, UserProfile @@ -185,3 +188,41 @@ def create_default_groups(): print(f"Permission not found: {codename}") except Exception as e: print(f"Error creating default groups: {str(e)}") + + +@receiver(user_logged_in) +def log_successful_login(sender, user, request, **kwargs): + """ + Log successful login events to LoginHistory. + + This signal handler captures the IP address, user agent, and login method + for auditing and security purposes. + """ + try: + # Get IP address + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + ip_address = x_forwarded_for.split(',')[0].strip() if x_forwarded_for else request.META.get('REMOTE_ADDR') + + # Get user agent + user_agent = request.META.get('HTTP_USER_AGENT', '')[:500] + + # Determine login method from session or request + login_method = 'PASSWORD' + if hasattr(request, 'session'): + sociallogin = getattr(request, '_sociallogin', None) + if sociallogin: + provider = sociallogin.account.provider.upper() + if provider in ['GOOGLE', 'DISCORD']: + login_method = provider + + # Create login history entry + LoginHistory.objects.create( + user=user, + ip_address=ip_address, + user_agent=user_agent, + login_method=login_method, + success=True, + ) + except Exception as e: + # Don't let login history failure prevent login + print(f"Error logging login history for user {user.username}: {str(e)}") diff --git a/backend/apps/accounts/tests.py b/backend/apps/accounts/tests.py index f7385e26..b54eac7e 100644 --- a/backend/apps/accounts/tests.py +++ b/backend/apps/accounts/tests.py @@ -1,7 +1,9 @@ -from django.test import TestCase +from unittest.mock import MagicMock, patch + from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType -from unittest.mock import patch, MagicMock +from django.test import TestCase + from .models import User, UserProfile from .signals import create_default_groups diff --git a/backend/apps/accounts/tests/test_admin.py b/backend/apps/accounts/tests/test_admin.py index bb5bb0ad..d9971c65 100644 --- a/backend/apps/accounts/tests/test_admin.py +++ b/backend/apps/accounts/tests/test_admin.py @@ -6,7 +6,6 @@ password reset, and top list admin classes including query optimization and custom actions. """ -import pytest from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase @@ -20,7 +19,6 @@ from apps.accounts.admin import ( from apps.accounts.models import ( EmailVerification, PasswordReset, - User, UserProfile, ) diff --git a/backend/apps/accounts/tests/test_model_constraints.py b/backend/apps/accounts/tests/test_model_constraints.py index 5654583c..92f6dff7 100644 --- a/backend/apps/accounts/tests/test_model_constraints.py +++ b/backend/apps/accounts/tests/test_model_constraints.py @@ -7,9 +7,8 @@ These tests verify that: 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.test import TestCase from django.utils import timezone from apps.accounts.models import User diff --git a/backend/apps/accounts/tests/test_user_deletion.py b/backend/apps/accounts/tests/test_user_deletion.py index 537563ab..5a8910d8 100644 --- a/backend/apps/accounts/tests/test_user_deletion.py +++ b/backend/apps/accounts/tests/test_user_deletion.py @@ -2,10 +2,11 @@ Tests for user deletion while preserving submissions. """ -from django.test import TestCase from django.db import transaction -from apps.accounts.services import UserDeletionService +from django.test import TestCase + from apps.accounts.models import User, UserProfile +from apps.accounts.services import UserDeletionService class UserDeletionServiceTest(TestCase): @@ -140,13 +141,12 @@ class UserDeletionServiceTest(TestCase): original_user_count = User.objects.count() # Mock a failure during the deletion process - with self.assertRaises(Exception): - with transaction.atomic(): - # Start the deletion process - UserDeletionService.get_or_create_deleted_user() + with self.assertRaises(Exception), transaction.atomic(): + # Start the deletion process + UserDeletionService.get_or_create_deleted_user() - # Simulate an error - raise Exception("Simulated error during deletion") + # Simulate an error + raise Exception("Simulated error during deletion") # Verify user count hasn't changed self.assertEqual(User.objects.count(), original_user_count) diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index 721d4026..768a610a 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -1,6 +1,7 @@ -from django.urls import path -from django.contrib.auth import views as auth_views from allauth.account.views import LogoutView +from django.contrib.auth import views as auth_views +from django.urls import path + from . import views app_name = "accounts" diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 4490faae..6c1c0408 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -1,41 +1,42 @@ -from django.views.generic import DetailView, TemplateView -from django.contrib.auth import get_user_model -from django.shortcuts import get_object_or_404, redirect, render -from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib import messages -from django.core.exceptions import ValidationError -from django.template.loader import render_to_string -from django.utils.crypto import get_random_string -from django.utils import timezone -from datetime import timedelta -from django.contrib.sites.shortcuts import get_current_site -from django.contrib.sites.models import Site -from django.contrib.sites.requests import RequestSite -from django.db.models import QuerySet -from django.http import HttpResponseRedirect, HttpResponse, HttpRequest -from django.urls import reverse -from django.contrib.auth import login -from django.core.files.uploadedfile import UploadedFile -from apps.accounts.models import ( - User, - PasswordReset, - EmailVerification, - UserProfile, -) -from apps.lists.models import UserList -from django_forwardemail.services import EmailService -from apps.parks.models import ParkReview -from apps.rides.models import RideReview -from allauth.account.views import LoginView, SignupView -from .mixins import TurnstileMixin -from typing import Dict, Any, Optional, Union, cast -from django_htmx.http import HttpResponseClientRefresh -from contextlib import suppress import logging import re +from contextlib import suppress +from datetime import timedelta +from typing import Any, cast -from apps.core.logging import log_exception, log_security_event +from allauth.account.views import LoginView, SignupView +from django.contrib import messages +from django.contrib.auth import get_user_model, login +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.sites.models import Site +from django.contrib.sites.requests import RequestSite +from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import ValidationError +from django.core.files.uploadedfile import UploadedFile +from django.db.models import QuerySet +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect, render +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils import timezone +from django.utils.crypto import get_random_string +from django.views.generic import DetailView, TemplateView +from django_forwardemail.services import EmailService +from django_htmx.http import HttpResponseClientRefresh + +from apps.accounts.models import ( + EmailVerification, + PasswordReset, + User, + UserProfile, +) +from apps.core.logging import log_security_event +from apps.lists.models import UserList +from apps.parks.models import ParkReview +from apps.rides.models import RideReview + +from .mixins import TurnstileMixin logger = logging.getLogger(__name__) @@ -184,7 +185,7 @@ class ProfileView(DetailView): def get_queryset(self) -> QuerySet[User]: return User.objects.select_related("profile") - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) user = cast(User, self.get_object()) @@ -220,7 +221,7 @@ class ProfileView(DetailView): class SettingsView(LoginRequiredMixin, TemplateView): template_name = "accounts/settings.html" - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["user"] = self.request.user return context @@ -283,7 +284,7 @@ class SettingsView(LoginRequiredMixin, TemplateView): def _handle_password_change( self, request: HttpRequest - ) -> Optional[HttpResponseRedirect]: + ) -> HttpResponseRedirect | None: user = cast(User, request.user) old_password = request.POST.get("old_password", "") new_password = request.POST.get("new_password", "") @@ -385,7 +386,7 @@ def create_password_reset_token(user: User) -> str: def send_password_reset_email( - user: User, site: Union[Site, RequestSite], token: str + user: User, site: Site | RequestSite, token: str ) -> None: reset_url = reverse("password_reset_confirm", kwargs={"token": token}) context = { @@ -435,7 +436,7 @@ def handle_password_reset( user: User, new_password: str, reset: PasswordReset, - site: Union[Site, RequestSite], + site: Site | RequestSite, ) -> None: user.set_password(new_password) user.save() @@ -457,7 +458,7 @@ def handle_password_reset( def send_password_reset_confirmation( - user: User, site: Union[Site, RequestSite] + user: User, site: Site | RequestSite ) -> None: context = { "user": user, diff --git a/backend/apps/api/management/commands/seed_data.py b/backend/apps/api/management/commands/seed_data.py index 2abd8c5b..ff0f338b 100644 --- a/backend/apps/api/management/commands/seed_data.py +++ b/backend/apps/api/management/commands/seed_data.py @@ -14,32 +14,25 @@ Usage: import random from datetime import date from decimal import Decimal -from typing import List -from django.core.management.base import BaseCommand, CommandError from django.contrib.auth import get_user_model from django.contrib.gis.geos import Point +from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.utils.text import slugify # Import all models -from apps.accounts.models import ( - User, UserProfile, UserNotification, - NotificationPreference, UserDeletionRequest -) -from apps.parks.models import ( - Park, ParkLocation, ParkArea, ParkPhoto, ParkReview -) -from apps.parks.models.companies import Company as ParkCompany, CompanyHeadquarters -from apps.rides.models import ( - Ride, RideModel, RollerCoasterStats, RidePhoto, RideReview, RideLocation -) -from apps.rides.models.company import Company as RideCompany +from apps.accounts.models import NotificationPreference, UserDeletionRequest, UserNotification, UserProfile from apps.core.history import HistoricalSlug +from apps.parks.models import Park, ParkArea, ParkLocation, ParkPhoto, ParkReview +from apps.parks.models.companies import Company as ParkCompany +from apps.parks.models.companies import CompanyHeadquarters +from apps.rides.models import Ride, RideLocation, RideModel, RidePhoto, RideReview, RollerCoasterStats +from apps.rides.models.company import Company as RideCompany # Try to import optional models that may not exist try: - from apps.rides.models import RideModelVariant, RideModelPhoto, RideModelTechnicalSpec + from apps.rides.models import RideModelPhoto, RideModelTechnicalSpec, RideModelVariant except ImportError: RideModelVariant = None RideModelPhoto = None @@ -51,7 +44,7 @@ except ImportError: RideRanking = None try: - from apps.moderation.models import ModerationQueue, ModerationAction + from apps.moderation.models import ModerationAction, ModerationQueue except ImportError: ModerationQueue = None ModerationAction = None @@ -125,16 +118,16 @@ class Command(BaseCommand): ride_models = self.create_ride_models(options['ride_models'], companies) parks = self.create_parks(options['parks'], companies) rides = self.create_rides(options['rides'], parks, companies, ride_models) - + # Create content and interactions self.create_reviews(options['reviews'], users, parks, rides) self.create_notifications(users) self.create_moderation_data(users, parks, rides) - + # Create media and photos self.create_photos(parks, rides, ride_models) - + # Create rankings and statistics self.create_rankings(rides) @@ -146,26 +139,26 @@ class Command(BaseCommand): def clear_data(self): """Clear existing data in reverse dependency order""" self.stdout.write('🗑️ Clearing existing data...') - + models_to_clear = [ # Content and interactions (clear first) UserNotification, NotificationPreference, ParkReview, RideReview, ModerationAction, ModerationQueue, - + # Media ParkPhoto, RidePhoto, CloudflareImage, - + # Core entities RollerCoasterStats, Ride, ParkArea, Park, ParkLocation, RideModel, CompanyHeadquarters, ParkCompany, RideCompany, - + # Users (clear last due to foreign key dependencies) UserDeletionRequest, UserProfile, User, - + # History HistoricalSlug, ] - + # Add optional models if they exist if RideRanking: models_to_clear.insert(4, RideRanking) @@ -179,7 +172,7 @@ class Command(BaseCommand): models_to_clear.insert(-6, RideModelVariant) if ModerationQueue: models_to_clear.insert(4, ModerationQueue) - + for model in models_to_clear: try: count = model.objects.count() @@ -193,12 +186,12 @@ class Command(BaseCommand): # Continue with other models continue - def create_users(self, count: int) -> List[User]: + def create_users(self, count: int) -> list[User]: """Create diverse users with comprehensive profiles""" self.stdout.write(f'👥 Creating {count} users...') - + users = [] - + # Create admin user if it doesn't exist admin, created = User.objects.get_or_create( username='admin', @@ -216,7 +209,7 @@ class Command(BaseCommand): admin.set_password('admin123') admin.save() users.append(admin) - + # Create moderator if it doesn't exist moderator, created = User.objects.get_or_create( username='moderator', @@ -233,7 +226,7 @@ class Command(BaseCommand): moderator.set_password('mod123') moderator.save() users.append(moderator) - + # Sample user data first_names = [ 'Alex', 'Jordan', 'Taylor', 'Casey', 'Morgan', 'Riley', 'Avery', 'Quinn', @@ -241,23 +234,23 @@ class Command(BaseCommand): 'Jamie', 'Kendall', 'Logan', 'Parker', 'Peyton', 'Reese', 'Sage', 'Skyler', 'Sydney', 'Tanner' ] - + last_names = [ 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', 'Lee', 'Perez', 'Thompson', 'White', 'Harris' ] - + domains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com'] - + # Create regular users - for i in range(count - 2): # -2 for admin and moderator + for _i in range(count - 2): # -2 for admin and moderator first_name = random.choice(first_names) last_name = random.choice(last_names) username = f"{first_name.lower()}{last_name.lower()}{random.randint(1, 999)}" email = f"{username}@{random.choice(domains)}" - + user = User.objects.create_user( username=username, email=email, @@ -275,7 +268,7 @@ class Command(BaseCommand): two_factor_enabled=random.choice([True, False]), login_notifications=random.choice([True, False]), ) - + # Create detailed notification preferences user.notification_preferences = { 'email': { @@ -295,7 +288,7 @@ class Command(BaseCommand): } } user.save() - + # Create user profile with ride credits profile = UserProfile.objects.get(user=user) profile.bio = f"Thrill seeker from {random.choice(['California', 'Florida', 'Ohio', 'Pennsylvania', 'Texas'])}. Love roller coasters!" @@ -304,7 +297,7 @@ class Command(BaseCommand): profile.dark_ride_credits = random.randint(0, 100) profile.flat_ride_credits = random.randint(0, 200) profile.water_ride_credits = random.randint(0, 50) - + # Add social media links for some users if random.random() < 0.3: profile.twitter = f"https://twitter.com/{username}" @@ -312,19 +305,19 @@ class Command(BaseCommand): profile.instagram = f"https://instagram.com/{username}" if random.random() < 0.1: profile.youtube = f"https://youtube.com/@{username}" - + profile.save() users.append(user) - + self.stdout.write(f' ✅ Created {len(users)} users') return users - def create_companies(self, count: int) -> List: + def create_companies(self, count: int) -> list: """Create companies with different roles""" self.stdout.write(f'🏢 Creating {count} companies...') - + companies = [] - + # Major theme park operators operators_data = [ ('Walt Disney Company', ['OPERATOR', 'PROPERTY_OWNER'], 1923, 'Burbank, CA, USA'), @@ -335,7 +328,7 @@ class Command(BaseCommand): ('Busch Gardens', ['OPERATOR'], 1959, 'Tampa, FL, USA'), ('Knott\'s Berry Farm', ['OPERATOR'], 1920, 'Buena Park, CA, USA'), ] - + # Major ride manufacturers manufacturers_data = [ ('Bolliger & Mabillard', ['MANUFACTURER'], 1988, 'Monthey, Switzerland'), @@ -347,16 +340,16 @@ class Command(BaseCommand): ('Premier Rides', ['MANUFACTURER'], 1994, 'Baltimore, MD, USA'), ('S&S Worldwide', ['MANUFACTURER'], 1994, 'Logan, UT, USA'), ] - + # Ride designers designers_data = [ ('Werner Stengel', ['DESIGNER'], 1965, 'Munich, Germany'), ('Alan Schilke', ['DESIGNER'], 1990, 'Hayden, ID, USA'), ('John Wardley', ['DESIGNER'], 1970, 'London, UK'), ] - + all_company_data = operators_data + manufacturers_data + designers_data - + for name, roles, founded_year, location in all_company_data: # Determine which Company model to use based on roles if 'OPERATOR' in roles or 'PROPERTY_OWNER' in roles: @@ -387,7 +380,7 @@ class Command(BaseCommand): 'coasters_count': random.randint(5, 100) if 'MANUFACTURER' in roles else 0, } ) - + # Create headquarters if company was created and is a ParkCompany if created and isinstance(company, ParkCompany): city, state_country = location.rsplit(', ', 1) @@ -397,7 +390,7 @@ class Command(BaseCommand): else: state = '' country = state_country - + CompanyHeadquarters.objects.get_or_create( company=company, defaults={ @@ -408,16 +401,16 @@ class Command(BaseCommand): 'postal_code': f"{random.randint(10000, 99999)}" if country == 'USA' else '', } ) - + companies.append(company) - + # Create additional random companies to reach the target count company_types = ['Theme Parks', 'Amusements', 'Entertainment', 'Rides', 'Design', 'Engineering'] - - for i in range(len(all_company_data), count): + + for _i in range(len(all_company_data), count): company_type = random.choice(company_types) name = f"{random.choice(['Global', 'International', 'Premier', 'Elite', 'Advanced', 'Creative'])} {company_type} {'Group' if random.random() < 0.5 else 'Corporation'}" - + roles = [] if 'Theme Parks' in name or 'Amusements' in name: roles = ['OPERATOR'] @@ -429,7 +422,7 @@ class Command(BaseCommand): roles = ['DESIGNER'] else: roles = [random.choice(['OPERATOR', 'MANUFACTURER', 'DESIGNER'])] - + # Use appropriate company model based on roles if 'OPERATOR' in roles or 'PROPERTY_OWNER' in roles: company = ParkCompany.objects.create( @@ -453,12 +446,12 @@ class Command(BaseCommand): rides_count=random.randint(5, 100) if 'MANUFACTURER' in roles else 0, coasters_count=random.randint(2, 50) if 'MANUFACTURER' in roles else 0, ) - + # Create headquarters cities = ['Los Angeles', 'New York', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia', 'San Antonio', 'San Diego', 'Dallas', 'San Jose'] states = ['CA', 'NY', 'IL', 'TX', 'AZ', 'PA', 'TX', 'CA', 'TX', 'CA'] - city_state = random.choice(list(zip(cities, states))) - + city_state = random.choice(list(zip(cities, states, strict=False))) + CompanyHeadquarters.objects.create( company=company, city=city_state[0], @@ -467,23 +460,23 @@ class Command(BaseCommand): street_address=f"{random.randint(100, 9999)} {random.choice(['Business', 'Corporate', 'Industry', 'Commerce'])} {random.choice(['Pkwy', 'Blvd', 'Dr', 'Way'])}", postal_code=f"{random.randint(10000, 99999)}", ) - + companies.append(company) - + self.stdout.write(f' ✅ Created {len(companies)} companies') return companies - def create_ride_models(self, count: int, companies: List) -> List[RideModel]: + def create_ride_models(self, count: int, companies: list) -> list[RideModel]: """Create ride models from manufacturers""" self.stdout.write(f'🎢 Creating {count} ride models...') - + manufacturers = [c for c in companies if 'MANUFACTURER' in c.roles] if not manufacturers: self.stdout.write(' ⚠️ No manufacturers found, skipping ride models') return [] - + ride_models = [] - + # Famous ride models famous_models = [ ('Dive Coaster', 'RC', 'Bolliger & Mabillard', 'Vertical drop roller coaster with holding brake'), @@ -507,12 +500,12 @@ class Command(BaseCommand): ('Drop Tower', 'FR', 'Intamin', 'Vertical drop ride'), ('Gyro Drop', 'FR', 'Intamin', 'Tilting drop tower'), ] - + for model_name, category, manufacturer_name, description in famous_models: manufacturer = next((c for c in manufacturers if manufacturer_name in c.name), None) if not manufacturer: manufacturer = random.choice(manufacturers) - + ride_model, created = RideModel.objects.get_or_create( name=model_name, manufacturer=manufacturer, @@ -536,7 +529,7 @@ class Command(BaseCommand): 'total_installations': random.randint(1, 50), } ) - + # Create technical specs if model exists if category == 'RC' and RideModelTechnicalSpec: specs = [ @@ -545,7 +538,7 @@ class Command(BaseCommand): ('CAPACITY', 'Riders per Train', f"{random.randint(20, 32)}", 'people'), ('SAFETY', 'Block Zones', f"{random.randint(4, 8)}", 'zones'), ] - + for spec_category, spec_name, spec_value, spec_unit in specs: RideModelTechnicalSpec.objects.create( ride_model=ride_model, @@ -554,31 +547,31 @@ class Command(BaseCommand): spec_value=spec_value, spec_unit=spec_unit, ) - + # Create variants for some models if model exists if random.random() < 0.3 and RideModelVariant: variant_names = ['Compact', 'Extended', 'Family', 'Extreme', 'Custom'] variant_name = random.choice(variant_names) - + RideModelVariant.objects.create( ride_model=ride_model, name=f"{variant_name} Version", description=f"Modified version of {model_name} for {variant_name.lower()} installations", distinguishing_features=f"Optimized for {variant_name.lower()} market segment", ) - + ride_models.append(ride_model) - + # Create additional random models model_types = ['Coaster', 'Ride', 'System', 'Experience', 'Adventure'] prefixes = ['Mega', 'Super', 'Ultra', 'Hyper', 'Giga', 'Extreme', 'Family', 'Junior'] - - for i in range(len(famous_models), count): + + for _i in range(len(famous_models), count): manufacturer = random.choice(manufacturers) category = random.choice(['RC', 'DR', 'FR', 'WR', 'TR']) - + model_name = f"{random.choice(prefixes)} {random.choice(model_types)}" - + ride_model = RideModel.objects.create( name=model_name, manufacturer=manufacturer, @@ -606,31 +599,31 @@ class Command(BaseCommand): ]), total_installations=random.randint(0, 25), ) - + ride_models.append(ride_model) - + self.stdout.write(f' ✅ Created {len(ride_models)} ride models') return ride_models - def create_parks(self, count: int, companies: List) -> List[Park]: + def create_parks(self, count: int, companies: list) -> list[Park]: """Create parks with locations and areas""" self.stdout.write(f'🏰 Creating {count} parks...') - + if count == 0: self.stdout.write(' ℹ️ Skipping park creation (count = 0)') return [] - + operators = [c for c in companies if 'OPERATOR' in c.roles] property_owners = [c for c in companies if 'PROPERTY_OWNER' in c.roles] - + if not operators: raise CommandError('No operators found. Create companies first.') - + parks = [] - + # Famous theme parks with timezone information famous_parks = [ - ('Magic Kingdom', 'Walt Disney World\'s flagship theme park', 'THEME_PARK', 'OPERATING', + ('Magic Kingdom', 'Walt Disney World\'s flagship theme park', 'THEME_PARK', 'OPERATING', date(1971, 10, 1), 107, 'Orlando', 'FL', 'USA', 28.4177, -81.5812, 'America/New_York'), ('Disneyland', 'The original Disney theme park', 'THEME_PARK', 'OPERATING', date(1955, 7, 17), 85, 'Anaheim', 'CA', 'USA', 33.8121, -117.9190, 'America/Los_Angeles'), @@ -647,7 +640,7 @@ class Command(BaseCommand): ('SeaWorld Orlando', 'Marine life theme park', 'THEME_PARK', 'OPERATING', date(1973, 12, 15), 200, 'Orlando', 'FL', 'USA', 28.4110, -81.4610, 'America/New_York'), ] - + for park_name, description, park_type, status, opening_date, size_acres, city, state, country, lat, lng, timezone_str in famous_parks: # Find appropriate operator operator = None @@ -665,15 +658,15 @@ class Command(BaseCommand): operator = next((c for c in operators if 'Busch' in c.name), None) elif 'SeaWorld' in park_name: operator = next((c for c in operators if 'SeaWorld' in c.name), None) - + if not operator: operator = random.choice(operators) - + # Find property owner (could be same as operator) property_owner = None if property_owners and random.random() < 0.7: property_owner = random.choice(property_owners) - + # Use get_or_create to avoid duplicates park, created = Park.objects.get_or_create( name=park_name, @@ -693,14 +686,14 @@ class Command(BaseCommand): ) if not created: self.stdout.write(f' ℹ️ Using existing park: {park_name}') - + # Create park location only if it doesn't exist location_exists = False try: location_exists = hasattr(park, 'location') and park.location is not None except Exception: location_exists = False - + if created or not location_exists: ParkLocation.objects.get_or_create( park=park, @@ -713,7 +706,7 @@ class Command(BaseCommand): 'postal_code': f"{random.randint(10000, 99999)}" if country == 'USA' else '', } ) - + # Create park areas only if park was created if created: area_names = ['Main Street', 'Fantasyland', 'Tomorrowland', 'Adventureland', 'Frontierland'] @@ -725,9 +718,9 @@ class Command(BaseCommand): 'description': f"Themed area within {park_name}", } ) - + parks.append(park) - + # Create additional random parks park_types = ['THEME_PARK', 'AMUSEMENT_PARK', 'WATER_PARK', 'FAMILY_ENTERTAINMENT_CENTER'] cities_data = [ @@ -740,28 +733,28 @@ class Command(BaseCommand): ('San Antonio', 'TX', 'USA', 29.4241, -98.4936), ('San Diego', 'CA', 'USA', 32.7157, -117.1611), ] - + for i in range(len(famous_parks), count): park_type = random.choice(park_types) # Make park names more unique by adding a number park_name = f"{random.choice(['Adventure', 'Magic', 'Wonder', 'Fantasy', 'Thrill', 'Family'])} {random.choice(['World', 'Land', 'Park', 'Kingdom', 'Gardens'])} {i + 1}" - + operator = random.choice(operators) property_owner = random.choice(property_owners) if property_owners and random.random() < 0.5 else None - + city, state, country, lat, lng = random.choice(cities_data) - + # Determine timezone based on state timezone_map = { 'CA': 'America/Los_Angeles', - 'NY': 'America/New_York', + 'NY': 'America/New_York', 'IL': 'America/Chicago', 'TX': 'America/Chicago', 'AZ': 'America/Phoenix', 'PA': 'America/New_York', } park_timezone = timezone_map.get(state, 'America/New_York') - + park = Park.objects.create( name=park_name, description=f"Exciting {park_type.lower().replace('_', ' ')} featuring thrilling rides and family entertainment", @@ -776,11 +769,11 @@ class Command(BaseCommand): coaster_count=random.randint(2, 15), timezone=park_timezone, ) - + # Create park location with slight coordinate variation lat_offset = random.uniform(-0.1, 0.1) lng_offset = random.uniform(-0.1, 0.1) - + ParkLocation.objects.create( park=park, point=Point(lng + lng_offset, lat + lat_offset), @@ -790,7 +783,7 @@ class Command(BaseCommand): country=country, postal_code=f"{random.randint(10000, 99999)}", ) - + # Create park areas area_names = ['Main Plaza', 'Adventure Zone', 'Family Area', 'Thrill Section', 'Water World', 'Kids Corner'] for area_name in random.sample(area_names, random.randint(2, 4)): @@ -799,25 +792,25 @@ class Command(BaseCommand): name=area_name, description=f"Themed area within {park_name}", ) - + parks.append(park) - + self.stdout.write(f' ✅ Created {len(parks)} parks') return parks - def create_rides(self, count: int, parks: List[Park], companies: List, ride_models: List[RideModel]) -> List[Ride]: + def create_rides(self, count: int, parks: list[Park], companies: list, ride_models: list[RideModel]) -> list[Ride]: """Create rides with comprehensive details""" self.stdout.write(f'🎠 Creating {count} rides...') - + if not parks: self.stdout.write(' ⚠️ No parks found, skipping rides') return [] - + manufacturers = [c for c in companies if 'MANUFACTURER' in c.roles] designers = [c for c in companies if 'DESIGNER' in c.roles] - + rides = [] - + # Famous roller coasters famous_coasters = [ ('Steel Vengeance', 'RC', 'Hybrid steel-wood roller coaster', 'Rocky Mountain Construction'), @@ -831,7 +824,7 @@ class Command(BaseCommand): ('Twisted Timbers', 'RC', 'RMC conversion of wooden coaster', 'Rocky Mountain Construction'), ('Goliath', 'RC', 'Hyper coaster with massive drops', 'Bolliger & Mabillard'), ] - + # Create famous coasters for coaster_name, category, description, manufacturer_name in famous_coasters: park = random.choice(parks) @@ -840,14 +833,14 @@ class Command(BaseCommand): manufacturer = next((c for c in manufacturers if manufacturer_name in c.name), None) if not manufacturer and manufacturers: manufacturer = random.choice(manufacturers) - + designer = random.choice(designers) if designers and random.random() < 0.3 else None ride_model = random.choice(ride_models) if ride_models and random.random() < 0.5 else None - + # Get park areas for this park park_areas = list(park.areas.all()) park_area = random.choice(park_areas) if park_areas else None - + ride = Ride.objects.create( name=coaster_name, description=description, @@ -864,7 +857,7 @@ class Command(BaseCommand): ride_duration_seconds=random.randint(90, 240), average_rating=Decimal(str(random.uniform(7.0, 9.5))), ) - + # Create roller coaster stats if category == 'RC': RollerCoasterStats.objects.create( @@ -884,9 +877,9 @@ class Command(BaseCommand): cars_per_train=random.randint(6, 8), seats_per_car=random.randint(2, 4), ) - + rides.append(ride) - + # Create additional random rides ride_names = [ 'Thunder Mountain', 'Space Coaster', 'Wild Eagle', 'Dragon Fire', 'Phoenix Rising', @@ -894,21 +887,21 @@ class Command(BaseCommand): 'Viper', 'Cobra', 'Rattlesnake', 'Sidewinder', 'Diamondback', 'Copperhead', 'Banshee', 'Valkyrie', 'Griffon', 'Falcon', 'Eagle\'s Flight', 'Soaring Heights' ] - + categories = ['RC', 'DR', 'FR', 'WR', 'TR', 'OT'] - - for i in range(len(famous_coasters), count): + + for _i in range(len(famous_coasters), count): park = random.choice(parks) park_areas = list(park.areas.all()) park_area = random.choice(park_areas) if park_areas else None - + ride_name = random.choice(ride_names) category = random.choice(categories) - + manufacturer = random.choice(manufacturers) if manufacturers and random.random() < 0.7 else None designer = random.choice(designers) if designers and random.random() < 0.2 else None ride_model = random.choice(ride_models) if ride_models and random.random() < 0.4 else None - + ride = Ride.objects.create( name=ride_name, description=f"Exciting {category} ride with thrilling elements and smooth operation", @@ -925,7 +918,7 @@ class Command(BaseCommand): ride_duration_seconds=random.randint(60, 300), average_rating=Decimal(str(random.uniform(6.0, 9.0))), ) - + # Create roller coaster stats for RC category if category == 'RC': RollerCoasterStats.objects.create( @@ -945,20 +938,20 @@ class Command(BaseCommand): cars_per_train=random.randint(4, 8), seats_per_car=random.randint(2, 4), ) - + rides.append(ride) - + self.stdout.write(f' ✅ Created {len(rides)} rides') return rides - def create_reviews(self, count: int, users: List[User], parks: List[Park], rides: List[Ride]) -> None: + def create_reviews(self, count: int, users: list[User], parks: list[Park], rides: list[Ride]) -> None: """Create park and ride reviews""" self.stdout.write(f'📝 Creating {count} reviews...') - + if not users or (not parks and not rides): self.stdout.write(' ⚠️ No users or content found, skipping reviews') return - + review_texts = [ "Amazing experience! The rides were thrilling and the staff was very friendly.", "Great park with excellent theming. The roller coasters are world-class.", @@ -971,21 +964,21 @@ class Command(BaseCommand): "Family-friendly atmosphere with rides for all ages.", "Outstanding park operations and friendly staff throughout.", ] - + # Create park reviews park_review_count = count // 2 created_park_reviews = 0 attempts = 0 max_attempts = park_review_count * 3 # Allow multiple attempts to avoid infinite loops - + while created_park_reviews < park_review_count and attempts < max_attempts: if not parks: break - + user = random.choice(users) park = random.choice(parks) attempts += 1 - + # Use get_or_create to avoid duplicates review, created = ParkReview.objects.get_or_create( user=user, @@ -1002,24 +995,24 @@ class Command(BaseCommand): ), } ) - + if created: created_park_reviews += 1 - + # Create ride reviews ride_review_count = count - created_park_reviews created_ride_reviews = 0 attempts = 0 max_attempts = ride_review_count * 3 # Allow multiple attempts to avoid infinite loops - + while created_ride_reviews < ride_review_count and attempts < max_attempts: if not rides: break - + user = random.choice(users) ride = random.choice(rides) attempts += 1 - + # Use get_or_create to avoid duplicates review, created = RideReview.objects.get_or_create( user=user, @@ -1036,36 +1029,36 @@ class Command(BaseCommand): ), } ) - + if created: created_ride_reviews += 1 - + self.stdout.write(f' ✅ Created {count} reviews') - def create_notifications(self, users: List[User]) -> None: + def create_notifications(self, users: list[User]) -> None: """Create sample notifications for users""" self.stdout.write('🔔 Creating notifications...') - + if not users: self.stdout.write(' ⚠️ No users found, skipping notifications') return - + notification_count = 0 - + notification_types = [ ("submission_approved", "Your park submission has been approved!", "Great news! Your submission for Adventure Park has been approved and is now live."), ("review_helpful", "Someone found your review helpful", "Your review of Steel Vengeance was marked as helpful by another user."), ("system_announcement", "New features available", "Check out our new ride comparison tool and enhanced search filters."), ("achievement_unlocked", "Achievement unlocked!", "Congratulations! You've unlocked the 'Coaster Enthusiast' achievement."), ] - + # Create notifications for random users for user in random.sample(users, min(len(users), 15)): for _ in range(random.randint(1, 3)): notification_type, title, message = random.choice(notification_types) - + UserNotification.objects.create( user=user, notification_type=notification_type, @@ -1077,50 +1070,50 @@ class Command(BaseCommand): push_sent=random.choice([True, False]), ) notification_count += 1 - + self.stdout.write(f' ✅ Created {notification_count} notifications') - def create_moderation_data(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None: + def create_moderation_data(self, users: list[User], parks: list[Park], rides: list[Ride]) -> None: """Create moderation queue and actions""" self.stdout.write('🛡️ Creating moderation data...') - + if not ModerationQueue or not ModerationAction: self.stdout.write(' ⚠️ Moderation models not available, skipping') return - + if not users or (not parks and not rides): self.stdout.write(' ⚠️ No users or content found, skipping moderation data') return - + # This would create sample moderation queue items and actions # Implementation depends on the actual moderation models structure self.stdout.write(' ✅ Moderation data creation skipped (models not fully defined)') - def create_photos(self, parks: List[Park], rides: List[Ride], ride_models: List[RideModel]) -> None: + def create_photos(self, parks: list[Park], rides: list[Ride], ride_models: list[RideModel]) -> None: """Create sample photo records""" self.stdout.write('📸 Creating photo records...') - + if not CloudflareImage: self.stdout.write(' ⚠️ CloudflareImage model not available, skipping photo creation') return - + # Since we don't have actual Cloudflare images, we'll skip photo creation # In a real scenario, you would need actual CloudflareImage instances self.stdout.write(' ⚠️ Photo creation skipped (requires actual CloudflareImage instances)') self.stdout.write(' ℹ️ To create photos, you need to upload actual images to Cloudflare first') - def create_rankings(self, rides: List[Ride]) -> None: + def create_rankings(self, rides: list[Ride]) -> None: """Create ride rankings if model exists""" self.stdout.write('🏆 Creating ride rankings...') - + if not RideRanking: self.stdout.write(' ⚠️ RideRanking model not available, skipping') return - + if not rides: self.stdout.write(' ⚠️ No rides found, skipping rankings') return - + # This would create sample ride rankings # Implementation depends on the actual RideRanking model structure self.stdout.write(' ✅ Ride rankings creation skipped (model structure not fully defined)') @@ -1129,7 +1122,7 @@ class Command(BaseCommand): """Print a summary of created data""" self.stdout.write('\n📊 Data Seeding Summary:') self.stdout.write('=' * 50) - + # Count all created objects counts = { 'Users': User.objects.count(), @@ -1145,9 +1138,9 @@ class Command(BaseCommand): 'Park Photos': ParkPhoto.objects.count(), 'Ride Photos': RidePhoto.objects.count(), } - + for model_name, count in counts.items(): self.stdout.write(f' {model_name}: {count}') - + self.stdout.write('=' * 50) self.stdout.write('🎉 Seeding completed! Your ThrillWiki database is ready for testing.') diff --git a/backend/apps/api/urls.py b/backend/apps/api/urls.py index 95342404..af7e0118 100644 --- a/backend/apps/api/urls.py +++ b/backend/apps/api/urls.py @@ -1,4 +1,4 @@ -from django.urls import path, include +from django.urls import include, path urlpatterns = [ path("v1/", include("apps.api.v1.urls")), diff --git a/backend/apps/api/v1/accounts/serializers.py b/backend/apps/api/v1/accounts/serializers.py index c8a4f5a5..8cdff197 100644 --- a/backend/apps/api/v1/accounts/serializers.py +++ b/backend/apps/api/v1/accounts/serializers.py @@ -1,5 +1,6 @@ -from rest_framework import serializers from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + from apps.accounts.models import UserProfile from apps.accounts.serializers import UserSerializer # existing shared user serializer @@ -24,7 +25,7 @@ class UserProfileUpdateInputSerializer(serializers.ModelSerializer): from django_cloudflareimages_toolkit.models import CloudflareImage image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id) instance.avatar = image - + return super().update(instance, validated_data) diff --git a/backend/apps/api/v1/accounts/urls.py b/backend/apps/api/v1/accounts/urls.py index c3a14fec..34eddbe0 100644 --- a/backend/apps/api/v1/accounts/urls.py +++ b/backend/apps/api/v1/accounts/urls.py @@ -2,8 +2,14 @@ URL configuration for user account management API endpoints. """ -from django.urls import path -from . import views +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views, views_credits, views_magic_link + +# Register ViewSets +router = DefaultRouter() +router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit") urlpatterns = [ # Admin endpoints for user management @@ -108,19 +114,18 @@ urlpatterns = [ path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"), path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"), path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"), - + + # Login history endpoint + path("login-history/", views.get_login_history, name="get_login_history"), + + # Magic Link (Login by Code) endpoints + path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"), + path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"), + # Public Profile path("profiles//", views.get_public_user_profile, name="get_public_user_profile"), -] -# Register ViewSets -from rest_framework.routers import DefaultRouter -from . import views_credits -from django.urls import include - -router = DefaultRouter() -router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit") - -urlpatterns += [ + # ViewSet routes path("", include(router.urls)), ] + diff --git a/backend/apps/api/v1/accounts/views.py b/backend/apps/api/v1/accounts/views.py index 33602ff6..d4c9ba82 100644 --- a/backend/apps/api/v1/accounts/views.py +++ b/backend/apps/api/v1/accounts/views.py @@ -6,43 +6,44 @@ user deletion while preserving submissions, profile management, settings, preferences, privacy, notifications, and security. """ -from apps.api.v1.serializers.accounts import ( - CompleteUserSerializer, - PublicUserSerializer, - UserPreferencesSerializer, - NotificationSettingsSerializer, - PrivacySettingsSerializer, - SecuritySettingsSerializer, - UserStatisticsSerializer, - UserListSerializer, - AccountUpdateSerializer, - ProfileUpdateSerializer, - ThemePreferenceSerializer, - UserNotificationSerializer, - NotificationPreferenceSerializer, - MarkNotificationsReadSerializer, - AvatarUploadSerializer, -) -from apps.accounts.services import UserDeletionService -from apps.accounts.export_service import UserExportService -from apps.accounts.models import ( - User, - UserProfile, - UserNotification, - NotificationPreference, -) -from apps.lists.models import UserList import logging -from rest_framework import status -from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated, IsAdminUser -from rest_framework.response import Response -from drf_spectacular.utils import extend_schema, OpenApiParameter -from drf_spectacular.types import OpenApiTypes + from django.shortcuts import get_object_or_404 -from rest_framework.permissions import AllowAny from django.utils import timezone from django_cloudflareimages_toolkit.models import CloudflareImage +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated +from rest_framework.response import Response + +from apps.accounts.export_service import UserExportService +from apps.accounts.models import ( + NotificationPreference, + User, + UserNotification, + UserProfile, +) +from apps.accounts.services import UserDeletionService +from apps.api.v1.serializers.accounts import ( + AccountUpdateSerializer, + AvatarUploadSerializer, + CompleteUserSerializer, + MarkNotificationsReadSerializer, + NotificationPreferenceSerializer, + NotificationSettingsSerializer, + PrivacySettingsSerializer, + ProfileUpdateSerializer, + PublicUserSerializer, + SecuritySettingsSerializer, + ThemePreferenceSerializer, + UserListSerializer, + UserNotificationSerializer, + UserPreferencesSerializer, + UserStatisticsSerializer, +) +from apps.lists.models import UserList # Set up logging logger = logging.getLogger(__name__) @@ -307,7 +308,7 @@ def save_avatar_image(request): 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() @@ -319,7 +320,7 @@ def save_avatar_image(request): 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( @@ -367,7 +368,7 @@ def save_avatar_image(request): except Exception as e: logger.error(f"Failed to delete old avatar from Cloudflare: {str(e)}") # Continue with database deletion even if Cloudflare deletion fails - + old_avatar.delete() # Debug logging to see what's happening with the CloudflareImage @@ -442,7 +443,7 @@ def delete_avatar(request): avatar_to_delete = profile.avatar profile.avatar = None profile.save() - + # Delete from Cloudflare first, then from database try: from django_cloudflareimages_toolkit.services import CloudflareImagesService @@ -452,7 +453,7 @@ def delete_avatar(request): except Exception as e: logger.error(f"Failed to delete avatar from Cloudflare: {str(e)}") # Continue with database deletion even if Cloudflare deletion fails - + avatar_to_delete.delete() # Get the default avatar URL @@ -1273,10 +1274,10 @@ def update_security_settings(request): # Handle security settings updates if "two_factor_enabled" in request.data: - setattr(user, "two_factor_enabled", request.data["two_factor_enabled"]) + user.two_factor_enabled = request.data["two_factor_enabled"] if "login_notifications" in request.data: - setattr(user, "login_notifications", request.data["login_notifications"]) + user.login_notifications = request.data["login_notifications"] user.save() @@ -1612,7 +1613,7 @@ def export_user_data(request): except Exception as e: logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True) return Response( - {"error": "Failed to generate data export"}, + {"error": "Failed to generate data export"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @@ -1636,54 +1637,73 @@ def get_public_user_profile(request, username): return Response(serializer.data, status=status.HTTP_200_OK) -# === MISSING FUNCTION IMPLEMENTATIONS === - - @extend_schema( - operation_id="request_account_deletion", - summary="Request account deletion", - description="Request deletion of the authenticated user's account.", + operation_id="get_login_history", + summary="Get user login history", + description=( + "Returns the authenticated user's recent login history including " + "IP addresses, devices, and timestamps for security auditing." + ), + parameters=[ + OpenApiParameter( + name="limit", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Maximum number of entries to return (default: 20, max: 100)", + ), + ], responses={ - 200: {"description": "Deletion request created"}, - 400: {"description": "Cannot delete account"}, - }, - tags=["Self-Service Account Management"], -) -@api_view(["POST"]) -@permission_classes([IsAuthenticated]) -def request_account_deletion(request): - """Request account deletion.""" - try: - user = request.user - - # Check if user can be deleted - can_delete, reason = UserDeletionService.can_delete_user(user) - if not can_delete: - return Response( - {"success": False, "error": reason}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Create deletion request - deletion_request = UserDeletionService.create_deletion_request(user) - - return Response( - { - "success": True, - "message": "Verification code sent to your email", - "expires_at": deletion_request.expires_at, - "email": user.email, + 200: { + "description": "Login history entries", + "example": { + "results": [ + { + "id": 1, + "ip_address": "192.168.1.1", + "user_agent": "Mozilla/5.0...", + "login_method": "PASSWORD", + "login_method_display": "Password", + "login_timestamp": "2024-12-27T10:30:00Z", + "country": "United States", + "city": "New York", + } + ], + "count": 1, }, - status=status.HTTP_200_OK, - ) + }, + 401: {"description": "Authentication required"}, + }, + tags=["User Security"], +) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_login_history(request): + """Get user login history for security auditing.""" + from apps.accounts.login_history import LoginHistory + + user = request.user + limit = min(int(request.query_params.get("limit", 20)), 100) + + # Get login history for user + entries = LoginHistory.objects.filter(user=user).order_by("-login_timestamp")[:limit] + + # Serialize + results = [] + for entry in entries: + results.append({ + "id": entry.id, + "ip_address": entry.ip_address, + "user_agent": entry.user_agent[:100] if entry.user_agent else None, # Truncate long user agents + "login_method": entry.login_method, + "login_method_display": dict(LoginHistory._meta.get_field('login_method').choices).get(entry.login_method, entry.login_method), + "login_timestamp": entry.login_timestamp.isoformat(), + "country": entry.country, + "city": entry.city, + "success": entry.success, + }) + + return Response({ + "results": results, + "count": len(results), + }) - except ValueError as e: - return Response( - {"success": False, "error": str(e)}, - status=status.HTTP_400_BAD_REQUEST, - ) - except Exception as e: - return Response( - {"success": False, "error": f"Error creating deletion request: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) diff --git a/backend/apps/api/v1/accounts/views_credits.py b/backend/apps/api/v1/accounts/views_credits.py index 1dcc5305..b19faf7c 100644 --- a/backend/apps/api/v1/accounts/views_credits.py +++ b/backend/apps/api/v1/accounts/views_credits.py @@ -1,9 +1,14 @@ -from rest_framework import viewsets, permissions, filters +from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend -from apps.rides.models.credits import RideCredit -from apps.api.v1.serializers.ride_credits import RideCreditSerializer -from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import filters, permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from apps.api.v1.serializers.ride_credits import RideCreditSerializer +from apps.rides.models.credits import RideCredit + class RideCreditViewSet(viewsets.ModelViewSet): """ @@ -14,8 +19,8 @@ class RideCreditViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticatedOrReadOnly] filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug'] - ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating'] - ordering = ['-last_ridden_at'] + ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating', 'display_order'] + ordering = ['display_order', '-last_ridden_at'] def get_queryset(self): """ @@ -23,18 +28,77 @@ class RideCreditViewSet(viewsets.ModelViewSet): Optionally filter by user via query param ?user=username """ queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user') - + # Filter by user if provided username = self.request.query_params.get('user') if username: queryset = queryset.filter(user__username=username) - + return queryset def perform_create(self, serializer): """Associate the current user with the ride credit.""" serializer.save(user=self.request.user) + @action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated]) + @extend_schema( + summary="Reorder ride credits", + description="Bulk update the display order of ride credits. Send a list of {id, order} objects.", + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'order': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'id': {'type': 'integer'}, + 'order': {'type': 'integer'} + }, + 'required': ['id', 'order'] + } + } + } + } + } + ) + def reorder(self, request): + """ + Bulk update display_order for multiple credits. + Expects: {"order": [{"id": 1, "order": 0}, {"id": 2, "order": 1}, ...]} + """ + order_data = request.data.get('order', []) + + if not order_data: + return Response( + {'error': 'No order data provided'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Validate that all credits belong to the current user + credit_ids = [item['id'] for item in order_data] + user_credits = RideCredit.objects.filter( + id__in=credit_ids, + user=request.user + ).values_list('id', flat=True) + + if set(credit_ids) != set(user_credits): + return Response( + {'error': 'You can only reorder your own credits'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Bulk update in a transaction + with transaction.atomic(): + for item in order_data: + RideCredit.objects.filter( + id=item['id'], + user=request.user + ).update(display_order=item['order']) + + return Response({'status': 'reordered', 'count': len(order_data)}) + @extend_schema( summary="List ride credits", description="List ride credits. filter by user username.", @@ -49,3 +113,4 @@ class RideCreditViewSet(viewsets.ModelViewSet): ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) + diff --git a/backend/apps/api/v1/accounts/views_magic_link.py b/backend/apps/api/v1/accounts/views_magic_link.py new file mode 100644 index 00000000..806bf67e --- /dev/null +++ b/backend/apps/api/v1/accounts/views_magic_link.py @@ -0,0 +1,180 @@ +""" +Magic Link (Login by Code) API views. + +Provides API endpoints for passwordless login via email code. +Uses django-allauth's built-in login-by-code functionality. +""" +from django.conf import settings +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +try: + from allauth.account.internal.flows.login_by_code import perform_login_by_code, request_login_code + from allauth.account.models import EmailAddress + from allauth.account.utils import user_email # noqa: F401 - imported to verify availability + HAS_LOGIN_BY_CODE = True +except ImportError: + HAS_LOGIN_BY_CODE = False + + +@extend_schema( + summary="Request magic link login code", + description="Send a one-time login code to the user's email address.", + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'email': {'type': 'string', 'format': 'email'} + }, + 'required': ['email'] + } + }, + responses={ + 200: {'description': 'Login code sent successfully'}, + 400: {'description': 'Invalid email or feature disabled'}, + }, + examples=[ + OpenApiExample( + 'Request login code', + value={'email': 'user@example.com'}, + request_only=True + ) + ] +) +@api_view(['POST']) +@permission_classes([AllowAny]) +def request_magic_link(request): + """ + Request a login code to be sent to the user's email. + + This is the first step of the magic link flow: + 1. User enters their email + 2. If the email exists, a code is sent + 3. User enters the code to complete login + """ + if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False): + return Response( + {'error': 'Magic link login is not enabled'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not HAS_LOGIN_BY_CODE: + return Response( + {'error': 'Login by code is not available in this version of allauth'}, + status=status.HTTP_400_BAD_REQUEST + ) + + email = request.data.get('email', '').lower().strip() + + if not email: + return Response( + {'error': 'Email is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if email exists (don't reveal if it doesn't for security) + try: + email_address = EmailAddress.objects.get(email__iexact=email, verified=True) + user = email_address.user + + # Request the login code + request_login_code(request._request, user) + + return Response({ + 'success': True, + 'message': 'If an account exists with this email, a login code has been sent.', + 'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300) + }) + + except EmailAddress.DoesNotExist: + # Don't reveal that the email doesn't exist + return Response({ + 'success': True, + 'message': 'If an account exists with this email, a login code has been sent.', + 'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300) + }) + + +@extend_schema( + summary="Verify magic link code", + description="Verify the login code and complete the login process.", + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'email': {'type': 'string', 'format': 'email'}, + 'code': {'type': 'string'} + }, + 'required': ['email', 'code'] + } + }, + responses={ + 200: {'description': 'Login successful'}, + 400: {'description': 'Invalid or expired code'}, + } +) +@api_view(['POST']) +@permission_classes([AllowAny]) +def verify_magic_link(request): + """ + Verify the login code and complete the login. + + This is the second step of the magic link flow. + """ + if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False): + return Response( + {'error': 'Magic link login is not enabled'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not HAS_LOGIN_BY_CODE: + return Response( + {'error': 'Login by code is not available'}, + status=status.HTTP_400_BAD_REQUEST + ) + + email = request.data.get('email', '').lower().strip() + code = request.data.get('code', '').strip() + + if not email or not code: + return Response( + {'error': 'Email and code are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + email_address = EmailAddress.objects.get(email__iexact=email, verified=True) + user = email_address.user + + # Attempt to verify the code and log in + success = perform_login_by_code(request._request, user, code) + + if success: + return Response({ + 'success': True, + 'message': 'Login successful', + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email + } + }) + else: + return Response( + {'error': 'Invalid or expired code. Please request a new one.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + except EmailAddress.DoesNotExist: + return Response( + {'error': 'Invalid email or code'}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception: + return Response( + {'error': 'Invalid or expired code. Please request a new one.'}, + status=status.HTTP_400_BAD_REQUEST + ) diff --git a/backend/apps/api/v1/auth/mfa.py b/backend/apps/api/v1/auth/mfa.py new file mode 100644 index 00000000..9e046630 --- /dev/null +++ b/backend/apps/api/v1/auth/mfa.py @@ -0,0 +1,385 @@ +""" +MFA (Multi-Factor Authentication) API Views + +Provides REST API endpoints for MFA operations using django-allauth's mfa module. +Supports TOTP (Time-based One-Time Password) authentication. +""" + +import base64 +from io import BytesIO + +from django.conf import settings +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +try: + import qrcode + HAS_QRCODE = True +except ImportError: + HAS_QRCODE = False + + +@extend_schema( + operation_id="get_mfa_status", + summary="Get MFA status for current user", + description="Returns whether MFA is enabled and what methods are configured.", + responses={ + 200: { + "description": "MFA status", + "example": { + "mfa_enabled": True, + "totp_enabled": True, + "recovery_codes_count": 10, + }, + }, + }, + tags=["MFA"], +) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_mfa_status(request): + """Get MFA status for current user.""" + from allauth.mfa.models import Authenticator + + user = request.user + authenticators = Authenticator.objects.filter(user=user) + + totp_enabled = authenticators.filter(type=Authenticator.Type.TOTP).exists() + recovery_enabled = authenticators.filter(type=Authenticator.Type.RECOVERY_CODES).exists() + + # Count recovery codes if any + recovery_count = 0 + if recovery_enabled: + try: + recovery_auth = authenticators.get(type=Authenticator.Type.RECOVERY_CODES) + recovery_count = len(recovery_auth.data.get("codes", [])) + except Authenticator.DoesNotExist: + pass + + return Response({ + "mfa_enabled": totp_enabled, + "totp_enabled": totp_enabled, + "recovery_codes_enabled": recovery_enabled, + "recovery_codes_count": recovery_count, + }) + + +@extend_schema( + operation_id="setup_totp", + summary="Initialize TOTP setup", + description="Generates a new TOTP secret and returns the QR code for scanning.", + responses={ + 200: { + "description": "TOTP setup data", + "example": { + "secret": "ABCDEFGHIJKLMNOP", + "provisioning_uri": "otpauth://totp/ThrillWiki:user@example.com?secret=...", + "qr_code_base64": "data:image/png;base64,...", + }, + }, + }, + tags=["MFA"], +) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def setup_totp(request): + """Generate TOTP secret and QR code for setup.""" + from allauth.mfa.totp.internal import auth as totp_auth + + user = request.user + + # Generate TOTP secret + secret = totp_auth.get_totp_secret(None) # Generate new secret + + # Build provisioning URI + issuer = getattr(settings, "MFA_TOTP_ISSUER", "ThrillWiki") + account_name = user.email or user.username + uri = f"otpauth://totp/{issuer}:{account_name}?secret={secret}&issuer={issuer}" + + # Generate QR code if qrcode library is available + qr_code_base64 = None + if HAS_QRCODE: + qr = qrcode.make(uri) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_code_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}" + + # Store secret in session for later verification + request.session["pending_totp_secret"] = secret + + return Response({ + "secret": secret, + "provisioning_uri": uri, + "qr_code_base64": qr_code_base64, + }) + + +@extend_schema( + operation_id="activate_totp", + summary="Activate TOTP with verification code", + description="Verifies the TOTP code and activates 2FA for the user.", + request={ + "application/json": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "6-digit TOTP code from authenticator app", + "example": "123456", + } + }, + "required": ["code"], + } + }, + responses={ + 200: { + "description": "TOTP activated successfully", + "example": { + "success": True, + "message": "Two-factor authentication enabled", + "recovery_codes": ["ABCD1234", "EFGH5678"], + }, + }, + 400: {"description": "Invalid code or missing setup data"}, + }, + tags=["MFA"], +) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def activate_totp(request): + """Verify TOTP code and activate MFA.""" + from allauth.mfa.models import Authenticator + from allauth.mfa.recovery_codes.internal import auth as recovery_auth + from allauth.mfa.totp.internal import auth as totp_auth + + user = request.user + code = request.data.get("code", "").strip() + + if not code: + return Response( + {"success": False, "error": "Verification code is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get pending secret from session + secret = request.session.get("pending_totp_secret") + if not secret: + return Response( + {"success": False, "error": "No pending TOTP setup. Please start setup again."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Verify the code + if not totp_auth.validate_totp_code(secret, code): + return Response( + {"success": False, "error": "Invalid verification code"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if already has TOTP + if Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists(): + return Response( + {"success": False, "error": "TOTP is already enabled"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create TOTP authenticator + Authenticator.objects.create( + user=user, + type=Authenticator.Type.TOTP, + data={"secret": secret}, + ) + + # Generate recovery codes + codes = recovery_auth.generate_recovery_codes() + Authenticator.objects.create( + user=user, + type=Authenticator.Type.RECOVERY_CODES, + data={"codes": codes}, + ) + + # Clear session + del request.session["pending_totp_secret"] + + return Response({ + "success": True, + "message": "Two-factor authentication enabled", + "recovery_codes": codes, + }) + + +@extend_schema( + operation_id="deactivate_totp", + summary="Disable TOTP authentication", + description="Removes TOTP from the user's account after password verification.", + request={ + "application/json": { + "type": "object", + "properties": { + "password": { + "type": "string", + "description": "Current password for confirmation", + } + }, + "required": ["password"], + } + }, + responses={ + 200: { + "description": "TOTP disabled", + "example": {"success": True, "message": "Two-factor authentication disabled"}, + }, + 400: {"description": "Invalid password or MFA not enabled"}, + }, + tags=["MFA"], +) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def deactivate_totp(request): + """Disable TOTP authentication.""" + from allauth.mfa.models import Authenticator + + user = request.user + password = request.data.get("password", "") + + # Verify password + if not user.check_password(password): + return Response( + {"success": False, "error": "Invalid password"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Remove TOTP and recovery codes + deleted_count, _ = Authenticator.objects.filter( + user=user, + type__in=[Authenticator.Type.TOTP, Authenticator.Type.RECOVERY_CODES] + ).delete() + + if deleted_count == 0: + return Response( + {"success": False, "error": "Two-factor authentication is not enabled"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response({ + "success": True, + "message": "Two-factor authentication disabled", + }) + + +@extend_schema( + operation_id="verify_totp", + summary="Verify TOTP code during login", + description="Verifies the TOTP code as part of the login process.", + request={ + "application/json": { + "type": "object", + "properties": { + "code": {"type": "string", "description": "6-digit TOTP code"} + }, + "required": ["code"], + } + }, + responses={ + 200: {"description": "Code verified", "example": {"success": True}}, + 400: {"description": "Invalid code"}, + }, + tags=["MFA"], +) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def verify_totp(request): + """Verify TOTP code.""" + from allauth.mfa.models import Authenticator + from allauth.mfa.totp.internal import auth as totp_auth + + user = request.user + code = request.data.get("code", "").strip() + + if not code: + return Response( + {"success": False, "error": "Verification code is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + authenticator = Authenticator.objects.get(user=user, type=Authenticator.Type.TOTP) + secret = authenticator.data.get("secret") + + if totp_auth.validate_totp_code(secret, code): + return Response({"success": True}) + else: + return Response( + {"success": False, "error": "Invalid verification code"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Authenticator.DoesNotExist: + return Response( + {"success": False, "error": "TOTP is not enabled"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +@extend_schema( + operation_id="regenerate_recovery_codes", + summary="Regenerate recovery codes", + description="Generates new recovery codes (invalidates old ones).", + request={ + "application/json": { + "type": "object", + "properties": { + "password": {"type": "string", "description": "Current password"} + }, + "required": ["password"], + } + }, + responses={ + 200: { + "description": "New recovery codes", + "example": {"success": True, "recovery_codes": ["ABCD1234", "EFGH5678"]}, + }, + 400: {"description": "Invalid password or MFA not enabled"}, + }, + tags=["MFA"], +) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def regenerate_recovery_codes(request): + """Regenerate recovery codes.""" + from allauth.mfa.models import Authenticator + from allauth.mfa.recovery_codes.internal import auth as recovery_auth + + user = request.user + password = request.data.get("password", "") + + # Verify password + if not user.check_password(password): + return Response( + {"success": False, "error": "Invalid password"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if TOTP is enabled + if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists(): + return Response( + {"success": False, "error": "Two-factor authentication is not enabled"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Generate new codes + codes = recovery_auth.generate_recovery_codes() + + # Update or create recovery codes authenticator + authenticator, created = Authenticator.objects.update_or_create( + user=user, + type=Authenticator.Type.RECOVERY_CODES, + defaults={"data": {"codes": codes}}, + ) + + return Response({ + "success": True, + "recovery_codes": codes, + }) diff --git a/backend/apps/api/v1/auth/serializers.py b/backend/apps/api/v1/auth/serializers.py index dd87e5e8..ffd913bd 100644 --- a/backend/apps/api/v1/auth/serializers.py +++ b/backend/apps/api/v1/auth/serializers.py @@ -5,21 +5,21 @@ This module contains all serializers related to authentication, user accounts, profiles, top lists, and user statistics. """ -from typing import Any, Dict - -from rest_framework import serializers -from drf_spectacular.utils import ( - extend_schema_serializer, - extend_schema_field, - OpenApiExample, -) -from django.contrib.auth.password_validation import validate_password -from django.utils.crypto import get_random_string -from django.contrib.auth import get_user_model -from django.utils import timezone from datetime import timedelta -from apps.accounts.models import PasswordReset +from typing import Any +from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import validate_password +from django.utils import timezone +from django.utils.crypto import get_random_string +from drf_spectacular.utils import ( + OpenApiExample, + extend_schema_field, + extend_schema_serializer, +) +from rest_framework import serializers + +from apps.accounts.models import PasswordReset UserModel = get_user_model() @@ -192,11 +192,13 @@ class SignupInputSerializer(serializers.ModelSerializer): def _send_verification_email(self, user): """Send email verification to the user.""" - from apps.accounts.models import EmailVerification + import logging + + from django.contrib.sites.shortcuts import get_current_site from django.utils.crypto import get_random_string from django_forwardemail.services import EmailService - from django.contrib.sites.shortcuts import get_current_site - import logging + + from apps.accounts.models import EmailVerification logger = logging.getLogger(__name__) @@ -436,7 +438,7 @@ class UserProfileOutputSerializer(serializers.Serializer): return obj.get_avatar_url() @extend_schema_field(serializers.DictField()) - def get_user(self, obj) -> Dict[str, Any]: + def get_user(self, obj) -> dict[str, Any]: return { "username": obj.user.username, "date_joined": obj.user.date_joined, diff --git a/backend/apps/api/v1/auth/serializers_package/__init__.py b/backend/apps/api/v1/auth/serializers_package/__init__.py index 68d62ed2..caf8e0d9 100644 --- a/backend/apps/api/v1/auth/serializers_package/__init__.py +++ b/backend/apps/api/v1/auth/serializers_package/__init__.py @@ -6,15 +6,15 @@ Main authentication serializers are imported directly from the parent serializer """ from .social import ( - ConnectedProviderSerializer, AvailableProviderSerializer, - SocialAuthStatusSerializer, + ConnectedProviderSerializer, + ConnectedProvidersListOutputSerializer, ConnectProviderInputSerializer, ConnectProviderOutputSerializer, DisconnectProviderOutputSerializer, - SocialProviderListOutputSerializer, - ConnectedProvidersListOutputSerializer, + SocialAuthStatusSerializer, SocialProviderErrorSerializer, + SocialProviderListOutputSerializer, ) __all__ = [ diff --git a/backend/apps/api/v1/auth/serializers_package/social.py b/backend/apps/api/v1/auth/serializers_package/social.py index 9988b15e..dfe855b9 100644 --- a/backend/apps/api/v1/auth/serializers_package/social.py +++ b/backend/apps/api/v1/auth/serializers_package/social.py @@ -5,8 +5,8 @@ Serializers for handling social provider connection/disconnection requests and responses in the ThrillWiki API. """ -from rest_framework import serializers from django.contrib.auth import get_user_model +from rest_framework import serializers User = get_user_model() diff --git a/backend/apps/api/v1/auth/urls.py b/backend/apps/api/v1/auth/urls.py index 6c8ec49f..4fc5faf0 100644 --- a/backend/apps/api/v1/auth/urls.py +++ b/backend/apps/api/v1/auth/urls.py @@ -5,29 +5,30 @@ This module contains URL patterns for core authentication functionality only. User profiles and top lists are handled by the dedicated accounts app. """ -from django.urls import path, include +from django.urls import include, path +from rest_framework_simplejwt.views import TokenRefreshView + +from . import mfa as mfa_views from .views import ( - # Main auth views - LoginAPIView, - SignupAPIView, - LogoutAPIView, - CurrentUserAPIView, - PasswordResetAPIView, - PasswordChangeAPIView, - SocialProvidersAPIView, AuthStatusAPIView, - # Email verification views - EmailVerificationAPIView, - ResendVerificationAPIView, # Social provider management views AvailableProvidersAPIView, ConnectedProvidersAPIView, ConnectProviderAPIView, + CurrentUserAPIView, DisconnectProviderAPIView, + # Email verification views + EmailVerificationAPIView, + # Main auth views + LoginAPIView, + LogoutAPIView, + PasswordChangeAPIView, + PasswordResetAPIView, + ResendVerificationAPIView, + SignupAPIView, SocialAuthStatusAPIView, + SocialProvidersAPIView, ) -from rest_framework_simplejwt.views import TokenRefreshView - urlpatterns = [ # Core authentication endpoints @@ -98,6 +99,14 @@ urlpatterns = [ ResendVerificationAPIView.as_view(), name="auth-resend-verification", ), + + # MFA (Multi-Factor Authentication) endpoints + path("mfa/status/", mfa_views.get_mfa_status, name="auth-mfa-status"), + path("mfa/totp/setup/", mfa_views.setup_totp, name="auth-mfa-totp-setup"), + path("mfa/totp/activate/", mfa_views.activate_totp, name="auth-mfa-totp-activate"), + path("mfa/totp/deactivate/", mfa_views.deactivate_totp, name="auth-mfa-totp-deactivate"), + path("mfa/totp/verify/", mfa_views.verify_totp, name="auth-mfa-totp-verify"), + path("mfa/recovery-codes/regenerate/", mfa_views.regenerate_recovery_codes, name="auth-mfa-recovery-regenerate"), ] # Note: User profiles and top lists functionality is now handled by the accounts app diff --git a/backend/apps/api/v1/auth/views.py b/backend/apps/api/v1/auth/views.py index bef03aef..028cf70c 100644 --- a/backend/apps/api/v1/auth/views.py +++ b/backend/apps/api/v1/auth/views.py @@ -6,44 +6,46 @@ login, signup, logout, password management, social authentication, user profiles, and top lists. """ -from .serializers_package.social import ( - ConnectedProviderSerializer, - AvailableProviderSerializer, - SocialAuthStatusSerializer, - ConnectProviderInputSerializer, - ConnectProviderOutputSerializer, - DisconnectProviderOutputSerializer, - SocialProviderErrorSerializer, -) -from apps.accounts.services.social_provider_service import SocialProviderService -from django.contrib.auth import authenticate, login, logout, get_user_model +from typing import cast # added 'cast' + +from django.contrib.auth import authenticate, get_user_model, login, logout from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError from django.db.models import Q -from typing import Optional, cast # added 'cast' from django.http import HttpRequest # new import +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status -from rest_framework.views import APIView +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.permissions import AllowAny, IsAuthenticated -from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework.views import APIView + +from apps.accounts.services.social_provider_service import SocialProviderService # Import directly from the auth serializers.py file (not the serializers package) from .serializers import ( + AuthStatusOutputSerializer, # Authentication serializers LoginInputSerializer, LoginOutputSerializer, - SignupInputSerializer, - SignupOutputSerializer, LogoutOutputSerializer, - UserOutputSerializer, - PasswordResetInputSerializer, - PasswordResetOutputSerializer, PasswordChangeInputSerializer, PasswordChangeOutputSerializer, + PasswordResetInputSerializer, + PasswordResetOutputSerializer, + SignupInputSerializer, + SignupOutputSerializer, SocialProviderOutputSerializer, - AuthStatusOutputSerializer, + UserOutputSerializer, +) +from .serializers_package.social import ( + AvailableProviderSerializer, + ConnectedProviderSerializer, + ConnectProviderInputSerializer, + ConnectProviderOutputSerializer, + DisconnectProviderOutputSerializer, + SocialAuthStatusSerializer, + SocialProviderErrorSerializer, ) # Handle optional dependencies with fallback classes @@ -62,10 +64,7 @@ try: # Ensure the imported object is a class/type that can be used as a base class. # If it's not a type for any reason, fall back to the safe mixin. - if isinstance(_ImportedTurnstileMixin, type): - TurnstileMixin = _ImportedTurnstileMixin - else: - TurnstileMixin = FallbackTurnstileMixin + TurnstileMixin = _ImportedTurnstileMixin if isinstance(_ImportedTurnstileMixin, type) else FallbackTurnstileMixin except Exception: # Catch any import errors or unexpected exceptions and use the fallback mixin. TurnstileMixin = FallbackTurnstileMixin @@ -88,7 +87,7 @@ def _get_underlying_request(request: Request) -> HttpRequest: # Helper: encapsulate user lookup + authenticate to reduce complexity in view def _authenticate_user_by_lookup( email_or_username: str, password: str, request: Request -) -> Optional[UserModel]: +) -> UserModel | None: """ Try a single optimized query to find a user by email OR username then authenticate. Returns authenticated user or None. @@ -199,7 +198,7 @@ class LoginAPIView(APIView): else: return Response( { - "error": "Email verification required", + "error": "Email verification required", "message": "Please verify your email address before logging in. Check your email for a verification link.", "email_verification_required": True }, @@ -246,7 +245,7 @@ class SignupAPIView(APIView): serializer = SignupInputSerializer(data=request.data, context={"request": request}) if serializer.is_valid(): user = serializer.save() - + # Don't log in the user immediately - they need to verify their email first response_serializer = SignupOutputSerializer( { @@ -754,23 +753,23 @@ class EmailVerificationAPIView(APIView): def get(self, request: Request, token: str) -> Response: from apps.accounts.models import EmailVerification - + try: verification = EmailVerification.objects.select_related('user').get(token=token) user = verification.user - + # Activate the user user.is_active = True user.save() - + # Delete the verification record verification.delete() - + return Response({ "message": "Email verified successfully. You can now log in.", "success": True }) - + except EmailVerification.DoesNotExist: return Response( {"error": "Invalid or expired verification token"}, @@ -798,45 +797,46 @@ class ResendVerificationAPIView(APIView): authentication_classes = [] def post(self, request: Request) -> Response: - from apps.accounts.models import EmailVerification + from django.contrib.sites.shortcuts import get_current_site from django.utils.crypto import get_random_string from django_forwardemail.services import EmailService - from django.contrib.sites.shortcuts import get_current_site - + + from apps.accounts.models import EmailVerification + email = request.data.get('email') if not email: return Response( {"error": "Email address is required"}, status=status.HTTP_400_BAD_REQUEST ) - + try: user = UserModel.objects.get(email__iexact=email.strip().lower()) - + # Don't resend if user is already active if user.is_active: return Response( {"error": "Email is already verified"}, status=status.HTTP_400_BAD_REQUEST ) - + # Create or update verification record verification, created = EmailVerification.objects.get_or_create( user=user, defaults={'token': get_random_string(64)} ) - + if not created: # Update existing token and timestamp verification.token = get_random_string(64) verification.save() - + # Send verification email site = get_current_site(_get_underlying_request(request)) verification_url = request.build_absolute_uri( f"/api/v1/auth/verify-email/{verification.token}/" ) - + try: EmailService.send_email( to=user.email, @@ -854,22 +854,22 @@ The ThrillWiki Team """.strip(), site=site, ) - + return Response({ "message": "Verification email sent successfully", "success": True }) - + except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Failed to send verification email to {user.email}: {e}") - + return Response( {"error": "Failed to send verification email"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - + except UserModel.DoesNotExist: # Don't reveal whether email exists return Response({ diff --git a/backend/apps/api/v1/core/urls.py b/backend/apps/api/v1/core/urls.py index 6b81e590..8f2a1213 100644 --- a/backend/apps/api/v1/core/urls.py +++ b/backend/apps/api/v1/core/urls.py @@ -4,6 +4,7 @@ Centralized from apps.core.urls """ from django.urls import path + from . import views # Entity search endpoints - migrated from apps.core.urls diff --git a/backend/apps/api/v1/core/views.py b/backend/apps/api/v1/core/views.py index a699fbdc..e0871bf9 100644 --- a/backend/apps/api/v1/core/views.py +++ b/backend/apps/api/v1/core/views.py @@ -8,18 +8,20 @@ Caching Strategy: - EntityNotFoundView: No caching - POST requests with context-specific data """ -from rest_framework.views import APIView -from rest_framework.response import Response + +import contextlib + +from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.permissions import AllowAny -from typing import Optional, List -from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView -from apps.core.services.entity_fuzzy_matching import ( - entity_fuzzy_matcher, - EntityType, -) from apps.core.decorators.cache_decorators import cache_api_response +from apps.core.services.entity_fuzzy_matching import ( + EntityType, + entity_fuzzy_matcher, +) class EntityFuzzySearchView(APIView): @@ -199,10 +201,8 @@ class EntityNotFoundView(APIView): # Determine entity types to search based on context entity_types = [] if entity_type_hint: - try: + with contextlib.suppress(ValueError): entity_types = [EntityType(entity_type_hint)] - except ValueError: - pass # If we have park context, prioritize ride searches if context.get("park_slug") and not entity_types: @@ -344,7 +344,7 @@ class QuickEntitySuggestionView(APIView): # Utility function for other views to use def get_entity_suggestions( - query: str, entity_types: Optional[List[str]] = None, user=None + query: str, entity_types: list[str] | None = None, user=None ): """ Utility function for other Django views to get entity suggestions. diff --git a/backend/apps/api/v1/email/urls.py b/backend/apps/api/v1/email/urls.py index ca4eef37..1b0b2187 100644 --- a/backend/apps/api/v1/email/urls.py +++ b/backend/apps/api/v1/email/urls.py @@ -4,6 +4,7 @@ Centralized from apps.email_service.urls """ from django.urls import path + from . import views urlpatterns = [ diff --git a/backend/apps/api/v1/email/views.py b/backend/apps/api/v1/email/views.py index 9b5a235a..4aa1ab6a 100644 --- a/backend/apps/api/v1/email/views.py +++ b/backend/apps/api/v1/email/views.py @@ -3,13 +3,13 @@ Centralized email service API views. Migrated from apps.email_service.views """ -from rest_framework.views import APIView -from rest_framework.response import Response +from django.contrib.sites.shortcuts import get_current_site +from django_forwardemail.services import EmailService +from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.permissions import AllowAny -from django.contrib.sites.shortcuts import get_current_site -from drf_spectacular.utils import extend_schema -from django_forwardemail.services import EmailService +from rest_framework.response import Response +from rest_framework.views import APIView @extend_schema( diff --git a/backend/apps/api/v1/history/urls.py b/backend/apps/api/v1/history/urls.py index 4cc99aaf..d992f767 100644 --- a/backend/apps/api/v1/history/urls.py +++ b/backend/apps/api/v1/history/urls.py @@ -4,7 +4,7 @@ History API URLs URL patterns for history-related API endpoints. """ -from django.urls import path, include +from django.urls import include, path from rest_framework.routers import DefaultRouter from .views import ( diff --git a/backend/apps/api/v1/history/views.py b/backend/apps/api/v1/history/views.py index ea99dd65..2ec260fa 100644 --- a/backend/apps/api/v1/history/views.py +++ b/backend/apps/api/v1/history/views.py @@ -5,18 +5,21 @@ This module provides ViewSets for accessing historical data and change tracking across all models in the ThrillWiki system using django-pghistory. """ -from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter +from collections.abc import Sequence +from datetime import datetime +from typing import cast + +import pghistory.models +from django.db.models import Count, QuerySet +from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view +from rest_framework import serializers as drf_serializers from rest_framework.filters import OrderingFilter from rest_framework.permissions import AllowAny +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet -from rest_framework.request import Request -from typing import Optional, cast, Sequence -from django.shortcuts import get_object_or_404 -from django.db.models import Count, QuerySet -import pghistory.models -from datetime import datetime # Import models from apps.parks.models import Park @@ -24,7 +27,6 @@ from apps.rides.models import Ride # Import serializers from .. import serializers as history_serializers -from rest_framework import serializers as drf_serializers # Minimal fallback serializer used when a specific serializer symbol is missing. @@ -79,7 +81,7 @@ ALL_TRACKED_MODELS: Sequence[str] = [ # --- Helper utilities to reduce duplicated logic / cognitive complexity --- -def _parse_date(date_str: Optional[str]) -> Optional[datetime]: +def _parse_date(date_str: str | None) -> datetime | None: if not date_str: return None try: diff --git a/backend/apps/api/v1/images/urls.py b/backend/apps/api/v1/images/urls.py index 5096b485..e152a143 100644 --- a/backend/apps/api/v1/images/urls.py +++ b/backend/apps/api/v1/images/urls.py @@ -1,4 +1,5 @@ from django.urls import path + from .views import GenerateUploadURLView urlpatterns = [ diff --git a/backend/apps/api/v1/images/views.py b/backend/apps/api/v1/images/views.py index 8951ad32..3e343a4e 100644 --- a/backend/apps/api/v1/images/views.py +++ b/backend/apps/api/v1/images/views.py @@ -1,12 +1,14 @@ -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated -from rest_framework import status -from apps.core.utils.cloudflare import get_direct_upload_url -from django.core.exceptions import ImproperlyConfigured -import requests import logging +import requests +from django.core.exceptions import ImproperlyConfigured +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.core.utils.cloudflare import get_direct_upload_url + logger = logging.getLogger(__name__) class GenerateUploadURLView(APIView): @@ -29,7 +31,7 @@ class GenerateUploadURLView(APIView): {"detail": "Failed to generate upload URL."}, status=status.HTTP_502_BAD_GATEWAY ) - except Exception as e: + except Exception: logger.exception("Unexpected error generating upload URL") return Response( {"detail": "An unexpected error occurred."}, diff --git a/backend/apps/api/v1/maps/urls.py b/backend/apps/api/v1/maps/urls.py index 3deb1882..fc581f15 100644 --- a/backend/apps/api/v1/maps/urls.py +++ b/backend/apps/api/v1/maps/urls.py @@ -4,6 +4,7 @@ Migrated from apps.core.urls.map_urls to centralized API structure. """ from django.urls import path + from . import views # Map API endpoints - migrated from apps.core.urls.map_urls diff --git a/backend/apps/api/v1/maps/views.py b/backend/apps/api/v1/maps/views.py index 271b7eae..0ec2a04e 100644 --- a/backend/apps/api/v1/maps/views.py +++ b/backend/apps/api/v1/maps/views.py @@ -12,30 +12,31 @@ Caching Strategy: import logging -from django.core.cache import cache -from django.http import HttpRequest -from django.db.models import Q 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, IsAdminUser +from django.core.cache import cache +from django.db.models import Q +from django.http import HttpRequest +from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( + OpenApiExample, + OpenApiParameter, extend_schema, extend_schema_view, - OpenApiParameter, - OpenApiExample, ) -from drf_spectacular.types import OpenApiTypes +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView +from apps.core.decorators.cache_decorators import cache_api_response +from apps.core.services.enhanced_cache_service import EnhancedCacheService from apps.parks.models import Park from apps.rides.models import Ride -from apps.core.services.enhanced_cache_service import EnhancedCacheService -from apps.core.decorators.cache_decorators import cache_api_response + from ..serializers.maps import ( + MapLocationDetailSerializer, MapLocationsResponseSerializer, MapSearchResponseSerializer, - MapLocationDetailSerializer, ) logger = logging.getLogger(__name__) diff --git a/backend/apps/api/v1/middleware.py b/backend/apps/api/v1/middleware.py index ce09100a..b45dcb2b 100644 --- a/backend/apps/api/v1/middleware.py +++ b/backend/apps/api/v1/middleware.py @@ -7,7 +7,8 @@ TypeScript interfaces, providing immediate feedback during development. import json import logging -from typing import Dict, Any +from typing import Any + from django.conf import settings from django.http import JsonResponse from django.utils.deprecation import MiddlewareMixin @@ -19,52 +20,49 @@ logger = logging.getLogger(__name__) class ContractValidationMiddleware(MiddlewareMixin): """ Development-only middleware that validates API responses against expected contracts. - + This middleware: 1. Checks all API responses for contract compliance 2. Logs warnings when responses don't match expected TypeScript interfaces 3. Specifically validates filter metadata structure 4. Alerts when categorical filters are strings instead of objects - + Only active when DEBUG=True to avoid performance impact in production. """ - + def __init__(self, get_response): super().__init__(get_response) self.get_response = get_response self.enabled = getattr(settings, 'DEBUG', False) - + if self.enabled: logger.info("Contract validation middleware enabled (DEBUG mode)") - + def process_response(self, request, response): """Process API responses to check for contract violations.""" - + if not self.enabled: return response - + # Only validate API endpoints if not request.path.startswith('/api/'): return response - + # Only validate JSON responses if not isinstance(response, (JsonResponse, Response)): return response - + # Only validate successful responses (2xx status codes) if not (200 <= response.status_code < 300): return response - + try: # Get response data - if isinstance(response, Response): - data = response.data - else: - data = json.loads(response.content.decode('utf-8')) - + data = response.data if isinstance(response, Response) else json.loads(response.content.decode('utf-8')) + # Validate the response self._validate_response_contract(request.path, data) - + except Exception as e: # Log validation errors but don't break the response logger.warning( @@ -76,55 +74,55 @@ class ContractValidationMiddleware(MiddlewareMixin): 'validation_error': str(e) } ) - + return response - + def _validate_response_contract(self, path: str, data: Any) -> None: """Validate response data against expected contracts.""" - + # Check for filter metadata endpoints if 'filter-options' in path or 'filter_options' in path: self._validate_filter_metadata(path, data) - + # Check for hybrid filtering endpoints if 'hybrid' in path: self._validate_hybrid_response(path, data) - + # Check for pagination responses if isinstance(data, dict) and 'results' in data: self._validate_pagination_response(path, data) - + # Check for common contract violations self._validate_common_patterns(path, data) - + def _validate_filter_metadata(self, path: str, data: Any) -> None: """Validate filter metadata structure.""" - + if not isinstance(data, dict): self._log_contract_violation( - path, + path, "FILTER_METADATA_NOT_DICT", f"Filter metadata should be a dictionary, got {type(data).__name__}" ) return - + # Check for categorical filters if 'categorical' in data: categorical = data['categorical'] if isinstance(categorical, dict): for filter_name, filter_options in categorical.items(): self._validate_categorical_filter(path, filter_name, filter_options) - + # Check for ranges if 'ranges' in data: ranges = data['ranges'] if isinstance(ranges, dict): for range_name, range_data in ranges.items(): self._validate_range_filter(path, range_name, range_data) - + def _validate_categorical_filter(self, path: str, filter_name: str, filter_options: Any) -> None: """Validate categorical filter options format.""" - + if not isinstance(filter_options, list): self._log_contract_violation( path, @@ -132,7 +130,7 @@ class ContractValidationMiddleware(MiddlewareMixin): f"Categorical filter '{filter_name}' should be an array, got {type(filter_options).__name__}" ) return - + for i, option in enumerate(filter_options): if isinstance(option, str): # CRITICAL: This is the main contract violation we're trying to catch @@ -163,10 +161,10 @@ class ContractValidationMiddleware(MiddlewareMixin): "INVALID_COUNT_TYPE", f"Categorical filter '{filter_name}' option {i} 'count' should be a number, got {type(option['count']).__name__}" ) - + def _validate_range_filter(self, path: str, range_name: str, range_data: Any) -> None: """Validate range filter format.""" - + if not isinstance(range_data, dict): self._log_contract_violation( path, @@ -174,7 +172,7 @@ class ContractValidationMiddleware(MiddlewareMixin): f"Range filter '{range_name}' should be an object, got {type(range_data).__name__}" ) return - + # Check required properties required_props = ['min', 'max'] for prop in required_props: @@ -184,7 +182,7 @@ class ContractValidationMiddleware(MiddlewareMixin): "MISSING_RANGE_PROPERTY", f"Range filter '{range_name}' missing required property '{prop}'" ) - + # Check step property if 'step' in range_data and not isinstance(range_data['step'], (int, float)): self._log_contract_violation( @@ -192,13 +190,13 @@ class ContractValidationMiddleware(MiddlewareMixin): "INVALID_STEP_TYPE", f"Range filter '{range_name}' 'step' should be a number, got {type(range_data['step']).__name__}" ) - + def _validate_hybrid_response(self, path: str, data: Any) -> None: """Validate hybrid filtering response structure.""" - + if not isinstance(data, dict): return - + # Check for strategy field if 'strategy' in data: strategy = data['strategy'] @@ -208,14 +206,14 @@ class ContractValidationMiddleware(MiddlewareMixin): "INVALID_STRATEGY_VALUE", f"Hybrid response strategy should be 'client_side' or 'server_side', got '{strategy}'" ) - + # Check filter_metadata structure if 'filter_metadata' in data: self._validate_filter_metadata(path, data['filter_metadata']) - - def _validate_pagination_response(self, path: str, data: Dict[str, Any]) -> None: + + def _validate_pagination_response(self, path: str, data: dict[str, Any]) -> None: """Validate pagination response structure.""" - + # Check for required pagination fields required_fields = ['count', 'results'] for field in required_fields: @@ -225,7 +223,7 @@ class ContractValidationMiddleware(MiddlewareMixin): "MISSING_PAGINATION_FIELD", f"Pagination response missing required field '{field}'" ) - + # Check results is array if 'results' in data and not isinstance(data['results'], list): self._log_contract_violation( @@ -233,17 +231,17 @@ class ContractValidationMiddleware(MiddlewareMixin): "RESULTS_NOT_ARRAY", f"Pagination 'results' should be an array, got {type(data['results']).__name__}" ) - + def _validate_common_patterns(self, path: str, data: Any) -> None: """Validate common API response patterns.""" - + if isinstance(data, dict): # Check for null vs undefined issues for key, value in data.items(): if value is None and key.endswith('_id'): # ID fields should probably be null, not undefined continue - + # Check for numeric fields that might be strings if key.endswith('_count') and isinstance(value, str): try: @@ -255,16 +253,16 @@ class ContractValidationMiddleware(MiddlewareMixin): ) except ValueError: pass - + def _log_contract_violation( - self, - path: str, - violation_type: str, - message: str, + self, + path: str, + violation_type: str, + message: str, severity: str = "WARNING" ) -> None: """Log a contract violation with structured data.""" - + log_data = { 'contract_violation': True, 'violation_type': violation_type, @@ -273,15 +271,15 @@ class ContractValidationMiddleware(MiddlewareMixin): 'message': message, 'suggestion': self._get_violation_suggestion(violation_type) } - + if severity == "ERROR": logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) else: logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) - + def _get_violation_suggestion(self, violation_type: str) -> str: """Get suggestion for fixing a contract violation.""" - + suggestions = { "CATEGORICAL_OPTION_IS_STRING": ( "Convert string arrays to object arrays with {value, label, count} structure. " @@ -308,31 +306,31 @@ class ContractValidationMiddleware(MiddlewareMixin): "Check serializer implementation." ) } - + return suggestions.get(violation_type, "Check the API response format against frontend TypeScript interfaces.") class ContractValidationSettings: """Settings for contract validation middleware.""" - + # Enable/disable specific validation checks VALIDATE_FILTER_METADATA = True VALIDATE_PAGINATION = True VALIDATE_HYBRID_RESPONSES = True VALIDATE_COMMON_PATTERNS = True - + # Severity levels for different violations CATEGORICAL_STRING_SEVERITY = "ERROR" # This is the critical issue MISSING_PROPERTY_SEVERITY = "WARNING" TYPE_MISMATCH_SEVERITY = "WARNING" - + # Paths to exclude from validation EXCLUDED_PATHS = [ '/api/docs/', '/api/schema/', '/api/v1/auth/', # Auth endpoints might have different structures ] - + @classmethod def should_validate_path(cls, path: str) -> bool: """Check if a path should be validated.""" diff --git a/backend/apps/api/v1/parks/history_views.py b/backend/apps/api/v1/parks/history_views.py index f3e29e50..186c9ad7 100644 --- a/backend/apps/api/v1/parks/history_views.py +++ b/backend/apps/api/v1/parks/history_views.py @@ -2,14 +2,16 @@ Park history API views. """ -from rest_framework import viewsets, mixins -from rest_framework.response import Response -from rest_framework.permissions import AllowAny from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema +from rest_framework import viewsets +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer from apps.parks.models import Park from apps.rides.models import Ride -from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer + class ParkHistoryViewSet(viewsets.GenericViewSet): """ @@ -18,7 +20,7 @@ class ParkHistoryViewSet(viewsets.GenericViewSet): permission_classes = [AllowAny] lookup_field = "slug" lookup_url_kwarg = "park_slug" - + @extend_schema( summary="Get park history", description="Retrieve history events for a park.", @@ -27,24 +29,24 @@ class ParkHistoryViewSet(viewsets.GenericViewSet): ) def list(self, request, park_slug=None): park = get_object_or_404(Park, slug=park_slug) - + events = [] if hasattr(park, "events"): events = park.events.all().order_by("-pgh_created_at") - + summary = { "total_events": len(events), "first_recorded": events.last().pgh_created_at if len(events) else None, "last_modified": events.first().pgh_created_at if len(events) else None, } - + data = { "park": park, "current_state": park, "summary": summary, "events": events } - + serializer = ParkHistoryOutputSerializer(data) return Response(serializer.data) diff --git a/backend/apps/api/v1/parks/park_reviews_views.py b/backend/apps/api/v1/parks/park_reviews_views.py index 217ed188..bc4f99bf 100644 --- a/backend/apps/api/v1/parks/park_reviews_views.py +++ b/backend/apps/api/v1/parks/park_reviews_views.py @@ -6,27 +6,26 @@ Provides CRUD operations for park reviews nested under parks/{slug}/reviews/ """ import logging + from django.core.exceptions import PermissionDenied from django.db.models import Avg from django.utils import timezone -from drf_spectacular.utils import extend_schema_view, extend_schema -from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError, NotFound -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.parks.models import Park, ParkReview from apps.api.v1.serializers.park_reviews import ( - ParkReviewOutputSerializer, ParkReviewCreateInputSerializer, - ParkReviewUpdateInputSerializer, ParkReviewListOutputSerializer, + ParkReviewOutputSerializer, ParkReviewStatsOutputSerializer, - ParkReviewModerationInputSerializer, + ParkReviewUpdateInputSerializer, ) +from apps.parks.models import Park, ParkReview logger = logging.getLogger(__name__) @@ -66,10 +65,7 @@ class ParkReviewViewSet(ModelViewSet): def get_permissions(self): """Set permissions based on action.""" - if self.action in ['list', 'retrieve', 'stats']: - permission_classes = [AllowAny] - else: - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated] return [permission() for permission in permission_classes] def get_queryset(self): @@ -143,7 +139,7 @@ class ParkReviewViewSet(ModelViewSet): reviews = ParkReview.objects.filter(park=park, is_published=True) total_reviews = reviews.count() avg_rating = reviews.aggregate(avg=Avg('rating'))['avg'] - + rating_distribution = {} for i in range(1, 11): rating_distribution[str(i)] = reviews.filter(rating=i).count() diff --git a/backend/apps/api/v1/parks/park_rides_views.py b/backend/apps/api/v1/parks/park_rides_views.py index ea7476b6..fecd9f27 100644 --- a/backend/apps/api/v1/parks/park_rides_views.py +++ b/backend/apps/api/v1/parks/park_rides_views.py @@ -6,19 +6,16 @@ This module implements endpoints for accessing rides within specific parks: - GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context """ -from typing import Any -from django.db import models -from django.db.models import Q, Count, Avg +from django.db.models import Q from django.db.models.query import QuerySet - -from rest_framework import status, permissions -from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +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, OpenApiParameter -from drf_spectacular.types import OpenApiTypes +from rest_framework.views import APIView # Import models try: @@ -32,8 +29,8 @@ except Exception: # Import serializers try: - from apps.api.v1.serializers.rides import RideListOutputSerializer, RideDetailOutputSerializer from apps.api.v1.serializers.parks import ParkDetailOutputSerializer + from apps.api.v1.serializers.rides import RideDetailOutputSerializer, RideListOutputSerializer SERIALIZERS_AVAILABLE = True except Exception: SERIALIZERS_AVAILABLE = False @@ -47,7 +44,7 @@ class StandardResultsSetPagination(PageNumberPagination): class ParkRidesListAPIView(APIView): """List rides at a specific park with pagination and filtering.""" - + permission_classes = [permissions.AllowAny] @extend_schema( @@ -59,7 +56,7 @@ class ParkRidesListAPIView(APIView): type=OpenApiTypes.INT, description="Page number"), OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Number of results per page (max 100)"), - + # Filtering OpenApiParameter(name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter by ride category"), @@ -67,7 +64,7 @@ class ParkRidesListAPIView(APIView): type=OpenApiTypes.STR, description="Filter by operational status"), OpenApiParameter(name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Search rides by name"), - + # Ordering OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Order results by field"), @@ -158,7 +155,7 @@ class ParkRidesListAPIView(APIView): class ParkRideDetailAPIView(APIView): """Get specific ride details within park context.""" - + permission_classes = [permissions.AllowAny] @extend_schema( @@ -222,7 +219,7 @@ class ParkRideDetailAPIView(APIView): class ParkComprehensiveDetailAPIView(APIView): """Get comprehensive park details including summary of rides.""" - + permission_classes = [permissions.AllowAny] @extend_schema( @@ -271,7 +268,7 @@ class ParkComprehensiveDetailAPIView(APIView): rides_serializer = RideListOutputSerializer( rides_sample, many=True, context={"request": request, "park": park} ) - + # Enhance response with rides data park_data["rides_summary"] = { "total_count": park.ride_count or 0, diff --git a/backend/apps/api/v1/parks/park_views.py b/backend/apps/api/v1/parks/park_views.py index 48fdc451..baf11a04 100644 --- a/backend/apps/api/v1/parks/park_views.py +++ b/backend/apps/api/v1/parks/park_views.py @@ -11,23 +11,24 @@ This module implements comprehensive park endpoints with full filtering support: Supports all 24 filtering parameters from frontend API documentation. """ +import contextlib from typing import Any -from django.db import models -from django.db.models import Q, Count, Avg -from django.db.models.query import QuerySet -from rest_framework import status, permissions -from rest_framework.views import APIView +from django.db import models +from django.db.models import Avg, Count, Q +from django.db.models.query import QuerySet +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +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, OpenApiParameter -from drf_spectacular.types import OpenApiTypes +from rest_framework.views import APIView # Import models try: - from apps.parks.models import Park, Company + from apps.parks.models import Company, Park MODELS_AVAILABLE = True except Exception: Park = None # type: ignore @@ -45,11 +46,11 @@ except Exception: # Import serializers try: from apps.api.v1.serializers.parks import ( - ParkListOutputSerializer, - ParkDetailOutputSerializer, ParkCreateInputSerializer, - ParkUpdateInputSerializer, + ParkDetailOutputSerializer, ParkImageSettingsInputSerializer, + ParkListOutputSerializer, + ParkUpdateInputSerializer, ) SERIALIZERS_AVAILABLE = True except Exception: @@ -247,12 +248,12 @@ class ParkListCreateAPIView(APIView): 'city': 'location__city__iexact', 'continent': 'location__continent__iexact' } - + for param_name, filter_field in location_filters.items(): value = params.get(param_name) if value: qs = qs.filter(**{filter_field: value}) - + return qs def _apply_park_attribute_filters(self, qs: QuerySet, params: dict) -> QuerySet: @@ -264,7 +265,7 @@ class ParkListCreateAPIView(APIView): status_filter = params.get("status") if status_filter: qs = qs.filter(status=status_filter) - + return qs def _apply_company_filters(self, qs: QuerySet, params: dict) -> QuerySet: @@ -275,73 +276,59 @@ class ParkListCreateAPIView(APIView): 'property_owner_id': 'property_owner_id', 'property_owner_slug': 'property_owner__slug' } - + for param_name, filter_field in company_filters.items(): value = params.get(param_name) if value: qs = qs.filter(**{filter_field: value}) - + return qs def _apply_rating_filters(self, qs: QuerySet, params: dict) -> QuerySet: """Apply rating-based filtering to the queryset.""" min_rating = params.get("min_rating") if min_rating: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(average_rating__gte=float(min_rating)) - except (ValueError, TypeError): - pass max_rating = params.get("max_rating") if max_rating: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(average_rating__lte=float(max_rating)) - except (ValueError, TypeError): - pass - + return qs def _apply_ride_count_filters(self, qs: QuerySet, params: dict) -> QuerySet: """Apply ride count filtering to the queryset.""" min_ride_count = params.get("min_ride_count") if min_ride_count: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(ride_count__gte=int(min_ride_count)) - except (ValueError, TypeError): - pass max_ride_count = params.get("max_ride_count") if max_ride_count: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(ride_count__lte=int(max_ride_count)) - except (ValueError, TypeError): - pass - + return qs def _apply_opening_year_filters(self, qs: QuerySet, params: dict) -> QuerySet: """Apply opening year filtering to the queryset.""" opening_year = params.get("opening_year") if opening_year: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(opening_date__year=int(opening_year)) - except (ValueError, TypeError): - pass min_opening_year = params.get("min_opening_year") if min_opening_year: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(opening_date__year__gte=int(min_opening_year)) - except (ValueError, TypeError): - pass max_opening_year = params.get("max_opening_year") if max_opening_year: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(opening_date__year__lte=int(max_opening_year)) - except (ValueError, TypeError): - pass - + return qs def _apply_roller_coaster_filters(self, qs: QuerySet, params: dict) -> QuerySet: @@ -355,18 +342,14 @@ class ParkListCreateAPIView(APIView): min_roller_coaster_count = params.get("min_roller_coaster_count") if min_roller_coaster_count: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(coaster_count__gte=int(min_roller_coaster_count)) - except (ValueError, TypeError): - pass max_roller_coaster_count = params.get("max_roller_coaster_count") if max_roller_coaster_count: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count)) - except (ValueError, TypeError): - pass - + return qs @extend_schema( @@ -440,13 +423,13 @@ class ParkDetailAPIView(APIView): def _get_park_or_404(self, identifier: str) -> Any: if not MODELS_AVAILABLE: raise NotFound( - ( + "Park detail is not available because domain models " "are not imported. Implement apps.parks.models.Park " "to enable detail endpoints." - ) + ) - + # Try to parse as integer ID first try: pk = int(identifier) @@ -475,36 +458,36 @@ class ParkDetailAPIView(APIView): summary="Get park full details", description=""" Retrieve comprehensive park details including: - + **Core Information:** - Basic park details (name, slug, description, status) - Opening/closing dates and operating season - Size in acres and website URL - Statistics (average rating, ride count, coaster count) - + **Location Data:** - Full address with coordinates - City, state, country information - Formatted address string - + **Company Information:** - Operating company details - Property owner information (if different) - + **Media:** - All approved photos with Cloudflare variants - Primary photo designation - Banner and card image settings - + **Related Content:** - Park areas/themed sections - Associated rides (summary) - + **Lookup Methods:** - By ID: `/api/v1/parks/123/` - By current slug: `/api/v1/parks/cedar-point/` - By historical slug: `/api/v1/parks/old-cedar-point-name/` - + **No Query Parameters Required** - This endpoint returns full details by default. """, responses={ @@ -598,11 +581,11 @@ class FilterOptionsAPIView(APIView): """Return comprehensive filter options with Rich Choice Objects metadata.""" # Import Rich Choice registry from apps.core.choices.registry import get_choices - + # Always get static choice definitions from Rich Choice Objects (primary source) park_types = get_choices('types', 'parks') statuses = get_choices('statuses', 'parks') - + # Convert Rich Choice Objects to frontend format with metadata park_types_data = [ { @@ -616,7 +599,7 @@ class FilterOptionsAPIView(APIView): } for choice in park_types ] - + statuses_data = [ { "value": choice.value, @@ -629,12 +612,12 @@ class FilterOptionsAPIView(APIView): } for choice in statuses ] - + # Get dynamic data from database if models are available if MODELS_AVAILABLE: # Add any dynamic data queries here pass - + return Response({ "park_types": park_types_data, "statuses": statuses_data, @@ -707,7 +690,7 @@ class FilterOptionsAPIView(APIView): # Get rich choice objects from registry park_types = get_choices('types', 'parks') statuses = get_choices('statuses', 'parks') - + # Convert Rich Choice Objects to frontend format with metadata park_types_data = [ { @@ -721,7 +704,7 @@ class FilterOptionsAPIView(APIView): } for choice in park_types ] - + statuses_data = [ { "value": choice.value, @@ -1118,7 +1101,7 @@ class OperatorListAPIView(APIView): } for op in operators ] - + return Response({ "results": data, "count": len(data) diff --git a/backend/apps/api/v1/parks/ride_photos_views.py b/backend/apps/api/v1/parks/ride_photos_views.py index 9b919030..8d5d8ad6 100644 --- a/backend/apps/api/v1/parks/ride_photos_views.py +++ b/backend/apps/api/v1/parks/ride_photos_views.py @@ -13,27 +13,27 @@ if TYPE_CHECKING: from django.core.exceptions import PermissionDenied from django.utils import timezone -from drf_spectacular.utils import extend_schema_view, extend_schema from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError, NotFound -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.rides.models.media import RidePhoto -from apps.rides.models import Ride -from apps.parks.models import Park -from apps.rides.services.media_service import RideMediaService from apps.api.v1.rides.serializers import ( - RidePhotoOutputSerializer, - RidePhotoCreateInputSerializer, - RidePhotoUpdateInputSerializer, - RidePhotoListOutputSerializer, RidePhotoApprovalInputSerializer, + RidePhotoCreateInputSerializer, + RidePhotoListOutputSerializer, + RidePhotoOutputSerializer, RidePhotoStatsOutputSerializer, + RidePhotoUpdateInputSerializer, ) +from apps.parks.models import Park +from apps.rides.models import Ride +from apps.rides.models.media import RidePhoto +from apps.rides.services.media_service import RideMediaService logger = logging.getLogger(__name__) @@ -116,10 +116,7 @@ class RidePhotoViewSet(ModelViewSet): def get_permissions(self): """Set permissions based on action.""" - if self.action in ['list', 'retrieve', 'stats']: - permission_classes = [AllowAny] - else: - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated] return [permission() for permission in permission_classes] def get_queryset(self): @@ -131,7 +128,7 @@ class RidePhotoViewSet(ModelViewSet): # Filter by park and ride from URL kwargs park_slug = self.kwargs.get("park_slug") ride_slug = self.kwargs.get("ride_slug") - + if park_slug and ride_slug: try: park, _ = Park.get_by_slug(park_slug) @@ -158,7 +155,7 @@ class RidePhotoViewSet(ModelViewSet): """Create a new ride photo using RideMediaService.""" park_slug = self.kwargs.get("park_slug") ride_slug = self.kwargs.get("ride_slug") - + if not park_slug or not ride_slug: raise ValidationError("Park and ride slugs are required") @@ -185,7 +182,7 @@ class RidePhotoViewSet(ModelViewSet): # Set the instance for the serializer response serializer.instance = photo - + logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}") except Exception as e: @@ -249,7 +246,7 @@ class RidePhotoViewSet(ModelViewSet): RideMediaService.delete_photo( instance, deleted_by=self.request.user ) - + logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}") except Exception as e: logger.error(f"Error deleting ride photo: {e}") @@ -331,7 +328,7 @@ class RidePhotoViewSet(ModelViewSet): validated_data = getattr(serializer, "validated_data", {}) photo_ids = validated_data.get("photo_ids") approve = validated_data.get("approve") - + park_slug = self.kwargs.get("park_slug") ride_slug = self.kwargs.get("ride_slug") @@ -381,7 +378,7 @@ class RidePhotoViewSet(ModelViewSet): """Get photo statistics for the ride.""" park_slug = self.kwargs.get("park_slug") ride_slug = self.kwargs.get("ride_slug") - + if not park_slug or not ride_slug: return Response( {"error": "Park and ride slugs are required"}, @@ -431,7 +428,7 @@ class RidePhotoViewSet(ModelViewSet): """Save a Cloudflare image as a ride photo after direct upload to Cloudflare.""" park_slug = self.kwargs.get("park_slug") ride_slug = self.kwargs.get("ride_slug") - + if not park_slug or not ride_slug: return Response( {"error": "Park and ride slugs are required"}, diff --git a/backend/apps/api/v1/parks/ride_reviews_views.py b/backend/apps/api/v1/parks/ride_reviews_views.py index cb73af70..80018071 100644 --- a/backend/apps/api/v1/parks/ride_reviews_views.py +++ b/backend/apps/api/v1/parks/ride_reviews_views.py @@ -12,28 +12,28 @@ if TYPE_CHECKING: pass from django.core.exceptions import PermissionDenied -from django.db.models import Avg, Count, Q +from django.db.models import Avg from django.utils import timezone -from drf_spectacular.utils import extend_schema_view, extend_schema from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError, NotFound -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.rides.models.reviews import RideReview -from apps.rides.models import Ride -from apps.parks.models import Park from apps.api.v1.serializers.ride_reviews import ( - RideReviewOutputSerializer, RideReviewCreateInputSerializer, - RideReviewUpdateInputSerializer, RideReviewListOutputSerializer, - RideReviewStatsOutputSerializer, RideReviewModerationInputSerializer, + RideReviewOutputSerializer, + RideReviewStatsOutputSerializer, + RideReviewUpdateInputSerializer, ) +from apps.parks.models import Park +from apps.rides.models import Ride +from apps.rides.models.reviews import RideReview logger = logging.getLogger(__name__) @@ -115,10 +115,7 @@ class RideReviewViewSet(ModelViewSet): def get_permissions(self): """Set permissions based on action.""" - if self.action in ['list', 'retrieve', 'stats']: - permission_classes = [AllowAny] - else: - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated] return [permission() for permission in permission_classes] def get_queryset(self): @@ -130,7 +127,7 @@ class RideReviewViewSet(ModelViewSet): # Filter by park and ride from URL kwargs park_slug = self.kwargs.get("park_slug") ride_slug = self.kwargs.get("ride_slug") - + if park_slug and ride_slug: try: park, _ = Park.get_by_slug(park_slug) @@ -141,7 +138,7 @@ class RideReviewViewSet(ModelViewSet): return queryset.none() # Filter published reviews for non-staff users - if not (hasattr(self.request, 'user') and + if not (hasattr(self.request, 'user') and getattr(self.request.user, 'is_staff', False)): queryset = queryset.filter(is_published=True) @@ -162,7 +159,7 @@ class RideReviewViewSet(ModelViewSet): """Create a new ride review.""" park_slug = self.kwargs.get("park_slug") ride_slug = self.kwargs.get("ride_slug") - + if not park_slug or not ride_slug: raise ValidationError("Park and ride slugs are required") @@ -185,7 +182,7 @@ class RideReviewViewSet(ModelViewSet): user=self.request.user, is_published=True # Auto-publish for now, can add moderation later ) - + logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}") except Exception as e: @@ -241,7 +238,7 @@ class RideReviewViewSet(ModelViewSet): """Get review statistics for the ride.""" park_slug = self.kwargs.get("park_slug") ride_slug = self.kwargs.get("ride_slug") - + if not park_slug or not ride_slug: return Response( {"error": "Park and ride slugs are required"}, @@ -265,19 +262,19 @@ class RideReviewViewSet(ModelViewSet): try: # Get review statistics reviews = RideReview.objects.filter(ride=ride, is_published=True) - + total_reviews = reviews.count() published_reviews = total_reviews # Since we're filtering published pending_reviews = RideReview.objects.filter(ride=ride, is_published=False).count() - + # Calculate average rating avg_rating = reviews.aggregate(avg_rating=Avg('rating'))['avg_rating'] - + # Get rating distribution rating_distribution = {} for i in range(1, 11): rating_distribution[str(i)] = reviews.filter(rating=i).count() - + # Get recent reviews count (last 30 days) from datetime import timedelta thirty_days_ago = timezone.now() - timedelta(days=30) diff --git a/backend/apps/api/v1/parks/serializers.py b/backend/apps/api/v1/parks/serializers.py index 77844f58..e223578e 100644 --- a/backend/apps/api/v1/parks/serializers.py +++ b/backend/apps/api/v1/parks/serializers.py @@ -5,12 +5,13 @@ This module contains serializers for park-specific media functionality. Enhanced from rogue implementation to maintain full feature parity. """ -from rest_framework import serializers from drf_spectacular.utils import ( + OpenApiExample, extend_schema_field, extend_schema_serializer, - OpenApiExample, ) +from rest_framework import serializers + from apps.parks.models import Park, ParkPhoto @@ -235,7 +236,7 @@ class HybridParkSerializer(serializers.ModelSerializer): Enhanced serializer for hybrid filtering strategy. Includes all filterable fields for client-side filtering. """ - + # Location fields from related ParkLocation city = serializers.SerializerMethodField() state = serializers.SerializerMethodField() @@ -243,19 +244,19 @@ class HybridParkSerializer(serializers.ModelSerializer): continent = serializers.SerializerMethodField() latitude = serializers.SerializerMethodField() longitude = serializers.SerializerMethodField() - + # Company fields operator_name = serializers.CharField(source="operator.name", read_only=True) property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True) - + # Image URLs for display banner_image_url = serializers.SerializerMethodField() card_image_url = serializers.SerializerMethodField() - + # Computed fields for filtering opening_year = serializers.IntegerField(read_only=True) search_text = serializers.CharField(read_only=True) - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_city(self, obj): """Get city from related location.""" @@ -263,7 +264,7 @@ class HybridParkSerializer(serializers.ModelSerializer): return obj.location.city if hasattr(obj, 'location') and obj.location else None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_state(self, obj): """Get state from related location.""" @@ -271,7 +272,7 @@ class HybridParkSerializer(serializers.ModelSerializer): return obj.location.state if hasattr(obj, 'location') and obj.location else None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_country(self, obj): """Get country from related location.""" @@ -279,7 +280,7 @@ class HybridParkSerializer(serializers.ModelSerializer): return obj.location.country if hasattr(obj, 'location') and obj.location else None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_continent(self, obj): """Get continent from related location.""" @@ -287,7 +288,7 @@ class HybridParkSerializer(serializers.ModelSerializer): return obj.location.continent if hasattr(obj, 'location') and obj.location else None except AttributeError: return None - + @extend_schema_field(serializers.FloatField(allow_null=True)) def get_latitude(self, obj): """Get latitude from related location.""" @@ -297,7 +298,7 @@ class HybridParkSerializer(serializers.ModelSerializer): return None except (AttributeError, IndexError, TypeError): return None - + @extend_schema_field(serializers.FloatField(allow_null=True)) def get_longitude(self, obj): """Get longitude from related location.""" @@ -307,14 +308,14 @@ class HybridParkSerializer(serializers.ModelSerializer): return None except (AttributeError, IndexError, TypeError): return None - + @extend_schema_field(serializers.URLField(allow_null=True)) def get_banner_image_url(self, obj): """Get banner image URL.""" if obj.banner_image and obj.banner_image.image: return obj.banner_image.image.url return None - + @extend_schema_field(serializers.URLField(allow_null=True)) def get_card_image_url(self, obj): """Get card image URL.""" @@ -332,42 +333,42 @@ class HybridParkSerializer(serializers.ModelSerializer): "description", "status", "park_type", - + # Dates and computed fields "opening_date", "closing_date", "opening_year", "operating_season", - + # Location fields "city", - "state", + "state", "country", "continent", "latitude", "longitude", - + # Company relationships "operator_name", "property_owner_name", - + # Statistics "size_acres", "average_rating", "ride_count", "coaster_count", - + # Images "banner_image_url", "card_image_url", - + # URLs "website", "url", - + # Computed fields for filtering "search_text", - + # Metadata "created_at", "updated_at", diff --git a/backend/apps/api/v1/parks/urls.py b/backend/apps/api/v1/parks/urls.py index c08e8191..69e916c0 100644 --- a/backend/apps/api/v1/parks/urls.py +++ b/backend/apps/api/v1/parks/urls.py @@ -6,28 +6,10 @@ intentionally expansive to match the rides API functionality and provide complete feature parity for parks management. """ -from django.urls import path, include +from django.urls import include, path from rest_framework.routers import DefaultRouter -from .park_views import ( - ParkListCreateAPIView, - ParkDetailAPIView, - FilterOptionsAPIView, - CompanySearchAPIView, - ParkSearchSuggestionsAPIView, - ParkImageSettingsAPIView, - OperatorListAPIView, -) -from .park_rides_views import ( - ParkRidesListAPIView, - ParkRideDetailAPIView, - ParkComprehensiveDetailAPIView, -) from apps.parks.views import location_search, reverse_geocode -from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView -from .ride_photos_views import RidePhotoViewSet -from .ride_photos_views import RidePhotoViewSet -from .ride_reviews_views import RideReviewViewSet from apps.parks.views_roadtrip import ( CreateTripView, FindParksAlongRouteView, @@ -35,6 +17,24 @@ from apps.parks.views_roadtrip import ( ParkDistanceCalculatorView, ) +from .park_rides_views import ( + ParkComprehensiveDetailAPIView, + ParkRideDetailAPIView, + ParkRidesListAPIView, +) +from .park_views import ( + CompanySearchAPIView, + FilterOptionsAPIView, + OperatorListAPIView, + ParkDetailAPIView, + ParkImageSettingsAPIView, + ParkListCreateAPIView, + ParkSearchSuggestionsAPIView, +) +from .ride_photos_views import RidePhotoViewSet +from .ride_reviews_views import RideReviewViewSet +from .views import HybridParkAPIView, ParkFilterMetadataAPIView, ParkPhotoViewSet + # Create router for nested photo endpoints router = DefaultRouter() router.register(r"", ParkPhotoViewSet, basename="park-photo") @@ -42,13 +42,12 @@ router.register(r"", ParkPhotoViewSet, basename="park-photo") # Create routers for nested ride endpoints ride_photos_router = DefaultRouter() ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo") -from .ride_reviews_views import RideReviewViewSet ride_reviews_router = DefaultRouter() ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review") -from .park_reviews_views import ParkReviewViewSet from .history_views import ParkHistoryViewSet, RideHistoryViewSet +from .park_reviews_views import ParkReviewViewSet # Create routers for nested park endpoints reviews_router = DefaultRouter() @@ -60,11 +59,11 @@ app_name = "api_v1_parks" urlpatterns = [ # Core list/create endpoints path("", ParkListCreateAPIView.as_view(), name="park-list-create"), - + # Hybrid filtering endpoints path("hybrid/", HybridParkAPIView.as_view(), name="park-hybrid-list"), path("hybrid/filter-metadata/", ParkFilterMetadataAPIView.as_view(), name="park-hybrid-filter-metadata"), - + # Filter options path("filter-options/", FilterOptionsAPIView.as_view(), name="park-filter-options"), # Autocomplete / suggestion endpoints @@ -80,14 +79,14 @@ urlpatterns = [ ), # Detail and action endpoints - supports both ID and slug path("/", ParkDetailAPIView.as_view(), name="park-detail"), - + # Park rides endpoints path("/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"), path("/rides//", ParkRideDetailAPIView.as_view(), name="park-ride-detail"), - + # Comprehensive park detail endpoint with rides summary path("/detail/", ParkComprehensiveDetailAPIView.as_view(), name="park-comprehensive-detail"), - + # Park image settings endpoint path( "/image-settings/", @@ -96,21 +95,21 @@ urlpatterns = [ ), # Park photo endpoints - domain-specific photo management path("/photos/", include(router.urls)), - + # Nested ride photo endpoints - photos for specific rides within parks path("/rides//photos/", include(ride_photos_router.urls)), - + # Nested ride review endpoints - reviews for specific rides within parks path("/rides//reviews/", include(ride_reviews_router.urls)), # Nested ride review endpoints - reviews for specific rides within parks path("/rides//reviews/", include(ride_reviews_router.urls)), - + # Ride History path("/rides//history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"), # Park Reviews path("/reviews/", include(reviews_router.urls)), - + # Park History path("/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"), diff --git a/backend/apps/api/v1/parks/views.py b/backend/apps/api/v1/parks/views.py index d58568a1..0d3a32dc 100644 --- a/backend/apps/api/v1/parks/views.py +++ b/backend/apps/api/v1/parks/views.py @@ -26,14 +26,13 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet +from apps.core.decorators.cache_decorators import cache_api_response from apps.core.exceptions import ( NotFoundError, - PermissionDeniedError, ServiceError, ValidationException, ) from apps.core.utils.error_handling import ErrorHandler -from apps.core.decorators.cache_decorators import cache_api_response from apps.parks.models import Park, ParkPhoto from apps.parks.services import ParkMediaService from apps.parks.services.hybrid_loader import smart_park_loader @@ -130,10 +129,7 @@ class ParkPhotoViewSet(ModelViewSet): def get_permissions(self): """Set permissions based on action.""" - if self.action in ["list", "retrieve", "stats"]: - permission_classes = [AllowAny] - else: - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] if self.action in ["list", "retrieve", "stats"] else [IsAuthenticated] return [permission() for permission in permission_classes] def get_queryset(self): # type: ignore[override] @@ -171,11 +167,8 @@ class ParkPhotoViewSet(ModelViewSet): raise ValidationError("Park ID/Slug is required") try: - if str(park_id).isdigit(): - park = Park.objects.get(pk=park_id) - else: - park = Park.objects.get(slug=park_id) - + park = Park.objects.get(pk=park_id) if str(park_id).isdigit() else Park.objects.get(slug=park_id) + # Use real park ID park_id = park.id except Park.DoesNotExist: @@ -398,10 +391,7 @@ class ParkPhotoViewSet(ModelViewSet): park = None if park_pk: try: - if str(park_pk).isdigit(): - park = Park.objects.get(pk=park_pk) - else: - park = Park.objects.get(slug=park_pk) + park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk) except Park.DoesNotExist: return ErrorHandler.handle_api_error( NotFoundError(f"Park with id/slug {park_pk} not found"), @@ -490,10 +480,7 @@ class ParkPhotoViewSet(ModelViewSet): ) try: - if str(park_pk).isdigit(): - park = Park.objects.get(pk=park_pk) - else: - park = Park.objects.get(slug=park_pk) + park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk) except Park.DoesNotExist: return Response( {"error": "Park not found"}, @@ -509,9 +496,9 @@ class ParkPhotoViewSet(ModelViewSet): try: # Import CloudflareImage model and service + from django.utils import timezone from django_cloudflareimages_toolkit.models import CloudflareImage from django_cloudflareimages_toolkit.services import CloudflareImagesService - from django.utils import timezone # Always fetch the latest image data from Cloudflare API # Get image details from Cloudflare API diff --git a/backend/apps/api/v1/rides/company_urls.py b/backend/apps/api/v1/rides/company_urls.py new file mode 100644 index 00000000..6aec1320 --- /dev/null +++ b/backend/apps/api/v1/rides/company_urls.py @@ -0,0 +1,12 @@ +"""URL routes for Company CRUD API.""" + +from django.urls import path + +from .company_views import CompanyDetailAPIView, CompanyListCreateAPIView + +app_name = "api_v1_companies" + +urlpatterns = [ + path("", CompanyListCreateAPIView.as_view(), name="company-list-create"), + path("/", CompanyDetailAPIView.as_view(), name="company-detail"), +] diff --git a/backend/apps/api/v1/rides/company_views.py b/backend/apps/api/v1/rides/company_views.py new file mode 100644 index 00000000..d0c3b010 --- /dev/null +++ b/backend/apps/api/v1/rides/company_views.py @@ -0,0 +1,167 @@ +""" +Company API views for ThrillWiki API v1. + +This module implements CRUD endpoints for company management: +- List / Create: GET /companies/ POST /companies/ +- Retrieve / Update / Delete: GET /companies/{id}/ PATCH/PUT/DELETE +""" + +from django.db.models import Q +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +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.views import APIView + +from apps.api.v1.serializers.companies import ( + CompanyCreateInputSerializer, + CompanyDetailOutputSerializer, + CompanyUpdateInputSerializer, +) + +try: + from apps.rides.models.company import Company + MODELS_AVAILABLE = True +except ImportError: + Company = None + MODELS_AVAILABLE = False + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + +class CompanyListCreateAPIView(APIView): + """List and create companies.""" + + permission_classes = [permissions.AllowAny] + + @extend_schema( + summary="List all companies", + description="List companies with optional search and role filtering.", + parameters=[ + OpenApiParameter(name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter(name="role", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter(name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT), + OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT), + ], + responses={200: CompanyDetailOutputSerializer(many=True)}, + tags=["Companies"], + ) + def get(self, request: Request) -> Response: + if not MODELS_AVAILABLE: + return Response( + {"detail": "Company models not available"}, + status=status.HTTP_501_NOT_IMPLEMENTED, + ) + + qs = Company.objects.all().order_by("name") + + # Search filter + search = request.query_params.get("search", "") + if search: + qs = qs.filter( + Q(name__icontains=search) | Q(description__icontains=search) + ) + + # Role filter + role = request.query_params.get("role", "") + if role: + qs = qs.filter(roles__contains=[role]) + + paginator = StandardResultsSetPagination() + page = paginator.paginate_queryset(qs, request) + serializer = CompanyDetailOutputSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + @extend_schema( + summary="Create a new company", + description="Create a new company with the given details.", + request=CompanyCreateInputSerializer, + responses={201: CompanyDetailOutputSerializer()}, + tags=["Companies"], + ) + def post(self, request: Request) -> Response: + if not MODELS_AVAILABLE: + return Response( + {"detail": "Company models not available"}, + status=status.HTTP_501_NOT_IMPLEMENTED, + ) + + serializer_in = CompanyCreateInputSerializer(data=request.data) + serializer_in.is_valid(raise_exception=True) + validated = serializer_in.validated_data + + company = Company.objects.create( + name=validated["name"], + roles=validated["roles"], + description=validated.get("description", ""), + website=validated.get("website", ""), + founded_date=validated.get("founded_date"), + ) + + serializer = CompanyDetailOutputSerializer(company) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class CompanyDetailAPIView(APIView): + """Retrieve, update, and delete a company.""" + + permission_classes = [permissions.AllowAny] + + def _get_company_or_404(self, pk: int) -> "Company": + if not MODELS_AVAILABLE: + raise NotFound("Company models not available") + try: + return Company.objects.get(pk=pk) + except Company.DoesNotExist: + raise NotFound("Company not found") + + @extend_schema( + summary="Retrieve a company", + description="Get detailed information about a specific company.", + responses={200: CompanyDetailOutputSerializer()}, + tags=["Companies"], + ) + def get(self, request: Request, pk: int) -> Response: + company = self._get_company_or_404(pk) + serializer = CompanyDetailOutputSerializer(company) + return Response(serializer.data) + + @extend_schema( + summary="Update a company", + description="Update a company (partial update supported).", + request=CompanyUpdateInputSerializer, + responses={200: CompanyDetailOutputSerializer()}, + tags=["Companies"], + ) + def patch(self, request: Request, pk: int) -> Response: + company = self._get_company_or_404(pk) + serializer_in = CompanyUpdateInputSerializer(data=request.data, partial=True) + serializer_in.is_valid(raise_exception=True) + + for field, value in serializer_in.validated_data.items(): + setattr(company, field, value) + company.save() + + serializer = CompanyDetailOutputSerializer(company) + return Response(serializer.data) + + def put(self, request: Request, pk: int) -> Response: + return self.patch(request, pk) + + @extend_schema( + summary="Delete a company", + description="Delete a company.", + responses={204: None}, + tags=["Companies"], + ) + def delete(self, request: Request, pk: int) -> Response: + company = self._get_company_or_404(pk) + company.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/api/v1/rides/manufacturers/urls.py b/backend/apps/api/v1/rides/manufacturers/urls.py index 0144d531..a880c7e3 100644 --- a/backend/apps/api/v1/rides/manufacturers/urls.py +++ b/backend/apps/api/v1/rides/manufacturers/urls.py @@ -11,17 +11,17 @@ This file exposes comprehensive endpoints for ride model management: from django.urls import path from .views import ( - RideModelListCreateAPIView, RideModelDetailAPIView, - RideModelSearchAPIView, RideModelFilterOptionsAPIView, - RideModelStatsAPIView, - RideModelVariantListCreateAPIView, - RideModelVariantDetailAPIView, - RideModelTechnicalSpecListCreateAPIView, - RideModelTechnicalSpecDetailAPIView, - RideModelPhotoListCreateAPIView, + RideModelListCreateAPIView, RideModelPhotoDetailAPIView, + RideModelPhotoListCreateAPIView, + RideModelSearchAPIView, + RideModelStatsAPIView, + RideModelTechnicalSpecDetailAPIView, + RideModelTechnicalSpecListCreateAPIView, + RideModelVariantDetailAPIView, + RideModelVariantListCreateAPIView, ) app_name = "api_v1_ride_models" diff --git a/backend/apps/api/v1/rides/manufacturers/views.py b/backend/apps/api/v1/rides/manufacturers/views.py index 21ace829..e06317f3 100644 --- a/backend/apps/api/v1/rides/manufacturers/views.py +++ b/backend/apps/api/v1/rides/manufacturers/views.py @@ -12,40 +12,40 @@ This module implements comprehensive endpoints for ride model management: - Photos: CRUD operations for ride model photos """ -from typing import Any from datetime import timedelta +from typing import Any -from rest_framework import status, permissions -from rest_framework.views import APIView +from django.db.models import Count, Q +from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import permissions, status +from rest_framework.exceptions import NotFound, ValidationError +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, ValidationError -from drf_spectacular.utils import extend_schema, OpenApiParameter -from drf_spectacular.types import OpenApiTypes -from django.db.models import Q, Count -from django.utils import timezone +from rest_framework.views import APIView # Import serializers from apps.api.v1.serializers.ride_models import ( - RideModelListOutputSerializer, - RideModelDetailOutputSerializer, RideModelCreateInputSerializer, - RideModelUpdateInputSerializer, + RideModelDetailOutputSerializer, RideModelFilterInputSerializer, - RideModelVariantOutputSerializer, - RideModelVariantCreateInputSerializer, - RideModelVariantUpdateInputSerializer, + RideModelListOutputSerializer, RideModelStatsOutputSerializer, + RideModelUpdateInputSerializer, + RideModelVariantCreateInputSerializer, + RideModelVariantOutputSerializer, + RideModelVariantUpdateInputSerializer, ) # Attempt to import models; fall back gracefully if not present try: from apps.rides.models import ( RideModel, - RideModelVariant, RideModelPhoto, RideModelTechnicalSpec, + RideModelVariant, ) from apps.rides.models.company import Company @@ -54,12 +54,12 @@ except ImportError: try: # Try alternative import path from apps.rides.models.rides import ( + Company, RideModel, - RideModelVariant, RideModelPhoto, RideModelTechnicalSpec, + RideModelVariant, ) - from apps.rides.models.rides import Company MODELS_AVAILABLE = True except ImportError: @@ -486,14 +486,14 @@ class RideModelFilterOptionsAPIView(APIView): """Return filter options for ride models 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') target_markets = get_choices('target_markets', 'rides') - + # Convert Rich Choice Objects to frontend format with metadata categories_data = [ { @@ -507,7 +507,7 @@ class RideModelFilterOptionsAPIView(APIView): } for choice in categories ] - + target_markets_data = [ { "value": choice.value, @@ -520,7 +520,7 @@ class RideModelFilterOptionsAPIView(APIView): } for choice in target_markets ] - + except Exception: # Ultimate fallback with basic structure categories_data = [ @@ -538,7 +538,7 @@ class RideModelFilterOptionsAPIView(APIView): {"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}, ] - + return Response({ "categories": categories_data, "target_markets": target_markets_data, @@ -557,11 +557,11 @@ class RideModelFilterOptionsAPIView(APIView): # 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') target_markets = get_choices('target_markets', 'rides') - + # Convert Rich Choice Objects to frontend format with metadata categories_data = [ { @@ -575,7 +575,7 @@ class RideModelFilterOptionsAPIView(APIView): } for choice in categories ] - + target_markets_data = [ { "value": choice.value, diff --git a/backend/apps/api/v1/rides/photo_views.py b/backend/apps/api/v1/rides/photo_views.py index 40759123..eb400826 100644 --- a/backend/apps/api/v1/rides/photo_views.py +++ b/backend/apps/api/v1/rides/photo_views.py @@ -5,23 +5,25 @@ This module contains ride photo ViewSet following the parks pattern for domain c Enhanced from centralized media API to provide domain-specific ride photo management. """ -from .serializers import ( - RidePhotoOutputSerializer, - RidePhotoCreateInputSerializer, - RidePhotoUpdateInputSerializer, - RidePhotoListOutputSerializer, - RidePhotoApprovalInputSerializer, - RidePhotoStatsOutputSerializer, -) from typing import TYPE_CHECKING +from .serializers import ( + RidePhotoApprovalInputSerializer, + RidePhotoCreateInputSerializer, + RidePhotoListOutputSerializer, + RidePhotoOutputSerializer, + RidePhotoStatsOutputSerializer, + RidePhotoUpdateInputSerializer, +) + if TYPE_CHECKING: pass import logging +from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied -from drf_spectacular.utils import extend_schema_view, extend_schema from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -29,9 +31,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.rides.models import RidePhoto, Ride +from apps.rides.models import Ride, RidePhoto from apps.rides.services.media_service import RideMediaService -from django.contrib.auth import get_user_model UserModel = get_user_model() @@ -460,9 +461,9 @@ class RidePhotoViewSet(ModelViewSet): try: # Import CloudflareImage model and service + from django.utils import timezone from django_cloudflareimages_toolkit.models import CloudflareImage from django_cloudflareimages_toolkit.services import CloudflareImagesService - from django.utils import timezone # Always fetch the latest image data from Cloudflare API try: diff --git a/backend/apps/api/v1/rides/serializers.py b/backend/apps/api/v1/rides/serializers.py index 4a2cbaab..f2700152 100644 --- a/backend/apps/api/v1/rides/serializers.py +++ b/backend/apps/api/v1/rides/serializers.py @@ -4,12 +4,13 @@ Ride media serializers for ThrillWiki API v1. This module contains serializers for ride-specific media functionality. """ -from rest_framework import serializers from drf_spectacular.utils import ( + OpenApiExample, extend_schema_field, extend_schema_serializer, - OpenApiExample, ) +from rest_framework import serializers + from apps.rides.models import Ride, RidePhoto @@ -267,33 +268,33 @@ class HybridRideSerializer(serializers.ModelSerializer): Enhanced serializer for hybrid filtering strategy. Includes all filterable fields for client-side filtering. """ - + # Park fields park_name = serializers.CharField(source="park.name", read_only=True) park_slug = serializers.CharField(source="park.slug", read_only=True) - + # Park location fields park_city = serializers.SerializerMethodField() park_state = serializers.SerializerMethodField() park_country = serializers.SerializerMethodField() - + # Park area fields park_area_name = serializers.CharField(source="park_area.name", read_only=True, allow_null=True) park_area_slug = serializers.CharField(source="park_area.slug", read_only=True, allow_null=True) - + # Company fields manufacturer_name = serializers.CharField(source="manufacturer.name", read_only=True, allow_null=True) manufacturer_slug = serializers.CharField(source="manufacturer.slug", read_only=True, allow_null=True) designer_name = serializers.CharField(source="designer.name", read_only=True, allow_null=True) designer_slug = serializers.CharField(source="designer.slug", read_only=True, allow_null=True) - + # Ride model fields ride_model_name = serializers.CharField(source="ride_model.name", read_only=True, allow_null=True) ride_model_slug = serializers.CharField(source="ride_model.slug", read_only=True, allow_null=True) ride_model_category = serializers.CharField(source="ride_model.category", read_only=True, allow_null=True) ride_model_manufacturer_name = serializers.CharField(source="ride_model.manufacturer.name", read_only=True, allow_null=True) ride_model_manufacturer_slug = serializers.CharField(source="ride_model.manufacturer.slug", read_only=True, allow_null=True) - + # Roller coaster stats fields coaster_height_ft = serializers.SerializerMethodField() coaster_length_ft = serializers.SerializerMethodField() @@ -309,15 +310,15 @@ class HybridRideSerializer(serializers.ModelSerializer): coaster_trains_count = serializers.SerializerMethodField() coaster_cars_per_train = serializers.SerializerMethodField() coaster_seats_per_car = serializers.SerializerMethodField() - + # Image URLs for display banner_image_url = serializers.SerializerMethodField() card_image_url = serializers.SerializerMethodField() - + # Computed fields for filtering opening_year = serializers.IntegerField(read_only=True) search_text = serializers.CharField(read_only=True) - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_park_city(self, obj): """Get city from park location.""" @@ -327,7 +328,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_park_state(self, obj): """Get state from park location.""" @@ -337,7 +338,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_park_country(self, obj): """Get country from park location.""" @@ -347,7 +348,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.FloatField(allow_null=True)) def get_coaster_height_ft(self, obj): """Get roller coaster height.""" @@ -357,7 +358,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except (AttributeError, TypeError): return None - + @extend_schema_field(serializers.FloatField(allow_null=True)) def get_coaster_length_ft(self, obj): """Get roller coaster length.""" @@ -367,7 +368,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except (AttributeError, TypeError): return None - + @extend_schema_field(serializers.FloatField(allow_null=True)) def get_coaster_speed_mph(self, obj): """Get roller coaster speed.""" @@ -377,7 +378,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except (AttributeError, TypeError): return None - + @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_coaster_inversions(self, obj): """Get roller coaster inversions.""" @@ -387,7 +388,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_coaster_ride_time_seconds(self, obj): """Get roller coaster ride time.""" @@ -397,7 +398,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_coaster_track_type(self, obj): """Get roller coaster track type.""" @@ -407,7 +408,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_coaster_track_material(self, obj): """Get roller coaster track material.""" @@ -417,7 +418,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_coaster_roller_coaster_type(self, obj): """Get roller coaster type.""" @@ -427,7 +428,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.FloatField(allow_null=True)) def get_coaster_max_drop_height_ft(self, obj): """Get roller coaster max drop height.""" @@ -437,7 +438,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except (AttributeError, TypeError): return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_coaster_propulsion_system(self, obj): """Get roller coaster propulsion system.""" @@ -447,7 +448,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.CharField(allow_null=True)) def get_coaster_train_style(self, obj): """Get roller coaster train style.""" @@ -457,7 +458,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_coaster_trains_count(self, obj): """Get roller coaster trains count.""" @@ -467,7 +468,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_coaster_cars_per_train(self, obj): """Get roller coaster cars per train.""" @@ -477,7 +478,7 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_coaster_seats_per_car(self, obj): """Get roller coaster seats per car.""" @@ -487,14 +488,14 @@ class HybridRideSerializer(serializers.ModelSerializer): return None except AttributeError: return None - + @extend_schema_field(serializers.URLField(allow_null=True)) def get_banner_image_url(self, obj): """Get banner image URL.""" if obj.banner_image and obj.banner_image.image: return obj.banner_image.image.url return None - + @extend_schema_field(serializers.URLField(allow_null=True)) def get_card_image_url(self, obj): """Get card image URL.""" @@ -513,44 +514,44 @@ class HybridRideSerializer(serializers.ModelSerializer): "category", "status", "post_closing_status", - + # Dates and computed fields "opening_date", "closing_date", "status_since", "opening_year", - + # Park fields "park_name", "park_slug", "park_city", "park_state", "park_country", - + # Park area fields "park_area_name", "park_area_slug", - + # Company fields "manufacturer_name", "manufacturer_slug", "designer_name", "designer_slug", - + # Ride model fields "ride_model_name", "ride_model_slug", "ride_model_category", "ride_model_manufacturer_name", "ride_model_manufacturer_slug", - + # Ride specifications "min_height_in", "max_height_in", "capacity_per_hour", "ride_duration_seconds", "average_rating", - + # Roller coaster stats "coaster_height_ft", "coaster_length_ft", @@ -566,18 +567,18 @@ class HybridRideSerializer(serializers.ModelSerializer): "coaster_trains_count", "coaster_cars_per_train", "coaster_seats_per_car", - + # Images "banner_image_url", "card_image_url", - + # URLs "url", "park_url", - + # Computed fields for filtering "search_text", - + # Metadata "created_at", "updated_at", diff --git a/backend/apps/api/v1/rides/urls.py b/backend/apps/api/v1/rides/urls.py index 0e9c6dd0..5ccdd456 100644 --- a/backend/apps/api/v1/rides/urls.py +++ b/backend/apps/api/v1/rides/urls.py @@ -8,23 +8,23 @@ actions (bulk, publish, export, import, recommendations) should be added to the views module when business logic is available. """ -from django.urls import path, include +from django.urls import include, path from rest_framework.routers import DefaultRouter +from .photo_views import RidePhotoViewSet from .views import ( - RideListCreateAPIView, - RideDetailAPIView, - FilterOptionsAPIView, CompanySearchAPIView, + DesignerListAPIView, + FilterOptionsAPIView, + HybridRideAPIView, + ManufacturerListAPIView, + RideDetailAPIView, + RideFilterMetadataAPIView, + RideImageSettingsAPIView, + RideListCreateAPIView, RideModelSearchAPIView, RideSearchSuggestionsAPIView, - RideImageSettingsAPIView, - HybridRideAPIView, - RideFilterMetadataAPIView, - ManufacturerListAPIView, - DesignerListAPIView, ) -from .photo_views import RidePhotoViewSet # Create router for nested photo endpoints router = DefaultRouter() @@ -35,11 +35,11 @@ app_name = "api_v1_rides" urlpatterns = [ # Core list/create endpoints path("", RideListCreateAPIView.as_view(), name="ride-list-create"), - + # Hybrid filtering endpoints path("hybrid/", HybridRideAPIView.as_view(), name="ride-hybrid-filtering"), path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"), - + # Filter options path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"), # Autocomplete / suggestion endpoints diff --git a/backend/apps/api/v1/rides/views.py b/backend/apps/api/v1/rides/views.py index 5875eec6..183a532c 100644 --- a/backend/apps/api/v1/rides/views.py +++ b/backend/apps/api/v1/rides/views.py @@ -23,12 +23,13 @@ Caching Strategy: - RideSearchSuggestionsAPIView.get: 5 minutes (300s) - suggestions should be fresh """ +import contextlib import logging from typing import Any from django.db import models from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import permissions, status from rest_framework.exceptions import NotFound from rest_framework.pagination import PageNumberPagination @@ -53,9 +54,9 @@ smart_ride_loader = SmartRideLoader() # Attempt to import model-level helpers; fall back gracefully if not present. try: + from apps.parks.models import Company, Park from apps.rides.models import Ride, RideModel from apps.rides.models.rides import RollerCoasterStats - from apps.parks.models import Park, Company MODELS_AVAILABLE = True except Exception: @@ -370,10 +371,8 @@ class RideListCreateAPIView(APIView): park_id = params.get("park_id") if park_id: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(park_id=int(park_id)) - except (ValueError, TypeError): - pass return qs @@ -393,10 +392,8 @@ class RideListCreateAPIView(APIView): """Apply manufacturer and designer filtering.""" manufacturer_id = params.get("manufacturer_id") if manufacturer_id: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(manufacturer_id=int(manufacturer_id)) - except (ValueError, TypeError): - pass manufacturer_slug = params.get("manufacturer_slug") if manufacturer_slug: @@ -404,10 +401,8 @@ class RideListCreateAPIView(APIView): designer_id = params.get("designer_id") if designer_id: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(designer_id=int(designer_id)) - except (ValueError, TypeError): - pass designer_slug = params.get("designer_slug") if designer_slug: @@ -419,10 +414,8 @@ class RideListCreateAPIView(APIView): """Apply ride model filtering.""" ride_model_id = params.get("ride_model_id") if ride_model_id: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(ride_model_id=int(ride_model_id)) - except (ValueError, TypeError): - pass ride_model_slug = params.get("ride_model_slug") manufacturer_slug_for_model = params.get("manufacturer_slug") @@ -438,17 +431,13 @@ class RideListCreateAPIView(APIView): """Apply rating-based filtering.""" min_rating = params.get("min_rating") if min_rating: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(average_rating__gte=float(min_rating)) - except (ValueError, TypeError): - pass max_rating = params.get("max_rating") if max_rating: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(average_rating__lte=float(max_rating)) - except (ValueError, TypeError): - pass return qs @@ -456,17 +445,13 @@ class RideListCreateAPIView(APIView): """Apply height requirement filtering.""" min_height_req = params.get("min_height_requirement") if min_height_req: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(min_height_in__gte=int(min_height_req)) - except (ValueError, TypeError): - pass max_height_req = params.get("max_height_requirement") if max_height_req: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(max_height_in__lte=int(max_height_req)) - except (ValueError, TypeError): - pass return qs @@ -474,17 +459,13 @@ class RideListCreateAPIView(APIView): """Apply capacity filtering.""" min_capacity = params.get("min_capacity") if min_capacity: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(capacity_per_hour__gte=int(min_capacity)) - except (ValueError, TypeError): - pass max_capacity = params.get("max_capacity") if max_capacity: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(capacity_per_hour__lte=int(max_capacity)) - except (ValueError, TypeError): - pass return qs @@ -492,24 +473,18 @@ class RideListCreateAPIView(APIView): """Apply opening year filtering.""" opening_year = params.get("opening_year") if opening_year: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(opening_date__year=int(opening_year)) - except (ValueError, TypeError): - pass min_opening_year = params.get("min_opening_year") if min_opening_year: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(opening_date__year__gte=int(min_opening_year)) - except (ValueError, TypeError): - pass max_opening_year = params.get("max_opening_year") if max_opening_year: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(opening_date__year__lte=int(max_opening_year)) - except (ValueError, TypeError): - pass return qs @@ -530,47 +505,35 @@ class RideListCreateAPIView(APIView): # Height filters min_height_ft = params.get("min_height_ft") if min_height_ft: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(coaster_stats__height_ft__gte=float(min_height_ft)) - except (ValueError, TypeError): - pass max_height_ft = params.get("max_height_ft") if max_height_ft: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(coaster_stats__height_ft__lte=float(max_height_ft)) - except (ValueError, TypeError): - pass # Speed filters min_speed_mph = params.get("min_speed_mph") if min_speed_mph: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(coaster_stats__speed_mph__gte=float(min_speed_mph)) - except (ValueError, TypeError): - pass max_speed_mph = params.get("max_speed_mph") if max_speed_mph: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(coaster_stats__speed_mph__lte=float(max_speed_mph)) - except (ValueError, TypeError): - pass # Inversion filters min_inversions = params.get("min_inversions") if min_inversions: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(coaster_stats__inversions__gte=int(min_inversions)) - except (ValueError, TypeError): - pass max_inversions = params.get("max_inversions") if max_inversions: - try: + with contextlib.suppress(ValueError, TypeError): qs = qs.filter(coaster_stats__inversions__lte=int(max_inversions)) - except (ValueError, TypeError): - pass has_inversions = params.get("has_inversions") if has_inversions is not None: @@ -2176,10 +2139,8 @@ class HybridRideAPIView(APIView): value = query_params.get(param) if value: if param == "park_id": - try: + with contextlib.suppress(ValueError): filters[param] = int(value) - except ValueError: - pass else: filters[param] = value @@ -2461,14 +2422,14 @@ class RideFilterMetadataAPIView(APIView): class BaseCompanyListAPIView(APIView): permission_classes = [permissions.AllowAny] role = None - + def get(self, request: Request) -> Response: if not MODELS_AVAILABLE: return Response( {"detail": "Models not available"}, status=status.HTTP_501_NOT_IMPLEMENTED ) - + companies = ( Company.objects.filter(roles__contains=[self.role]) .annotate(ride_count=Count("manufactured_rides" if self.role == "MANUFACTURER" else "designed_rides")) @@ -2486,7 +2447,7 @@ class BaseCompanyListAPIView(APIView): } for c in companies ] - + return Response({ "results": data, "count": len(data) diff --git a/backend/apps/api/v1/serializers/__init__.py b/backend/apps/api/v1/serializers/__init__.py index 83eab70f..9b763ae5 100644 --- a/backend/apps/api/v1/serializers/__init__.py +++ b/backend/apps/api/v1/serializers/__init__.py @@ -5,88 +5,88 @@ This module provides a unified interface to all serializers across different dom while maintaining the modular structure for better organization and maintainability. """ +import importlib +from typing import Any + +# --- Companies and ride models domain --- +from .companies import ( + CompanyCreateInputSerializer, + CompanyDetailOutputSerializer, + CompanyUpdateInputSerializer, + RideModelCreateInputSerializer, + RideModelDetailOutputSerializer, + RideModelUpdateInputSerializer, +) # noqa: F401 + +# --- Parks domain --- +from .parks import ( + ParkAreaCreateInputSerializer, + ParkAreaDetailOutputSerializer, + ParkAreaUpdateInputSerializer, + ParkCreateInputSerializer, + ParkDetailOutputSerializer, + ParkFilterInputSerializer, + ParkListOutputSerializer, + ParkLocationCreateInputSerializer, + ParkLocationOutputSerializer, + ParkLocationUpdateInputSerializer, + ParkSuggestionOutputSerializer, + ParkSuggestionSerializer, + ParkUpdateInputSerializer, +) # noqa: F401 + +# --- Rides domain --- +from .rides import ( + RideCreateInputSerializer, + RideDetailOutputSerializer, + RideFilterInputSerializer, + RideListOutputSerializer, + RideLocationCreateInputSerializer, + RideLocationOutputSerializer, + RideLocationUpdateInputSerializer, + RideModelOutputSerializer, + RideParkOutputSerializer, + RideReviewCreateInputSerializer, + RideReviewOutputSerializer, + RideReviewUpdateInputSerializer, + RideUpdateInputSerializer, + RollerCoasterStatsCreateInputSerializer, + RollerCoasterStatsOutputSerializer, + RollerCoasterStatsUpdateInputSerializer, +) # noqa: F401 from .services import ( - HealthCheckOutputSerializer, - PerformanceMetricsOutputSerializer, - SimpleHealthOutputSerializer, - EmailSendInputSerializer, - EmailTemplateOutputSerializer, - MapDataOutputSerializer, CoordinateInputSerializer, - HistoryEventSerializer, - HistoryEntryOutputSerializer, - HistoryCreateInputSerializer, - ModerationSubmissionSerializer, - ModerationSubmissionOutputSerializer, - RoadtripParkSerializer, - RoadtripCreateInputSerializer, - RoadtripOutputSerializer, - GeocodeInputSerializer, - GeocodeOutputSerializer, DistanceCalculationInputSerializer, DistanceCalculationOutputSerializer, + EmailSendInputSerializer, + EmailTemplateOutputSerializer, + GeocodeInputSerializer, + GeocodeOutputSerializer, + HealthCheckOutputSerializer, + HistoryCreateInputSerializer, + HistoryEntryOutputSerializer, + HistoryEventSerializer, + MapDataOutputSerializer, + ModerationSubmissionOutputSerializer, + ModerationSubmissionSerializer, + PerformanceMetricsOutputSerializer, + RoadtripCreateInputSerializer, + RoadtripOutputSerializer, + RoadtripParkSerializer, + SimpleHealthOutputSerializer, ) # noqa: F401 -from typing import Any, Dict, List -import importlib # --- Shared utilities and base classes --- from .shared import ( FilterOptionSerializer, FilterRangeSerializer, StandardizedFilterMetadataSerializer, - validate_filter_metadata_contract, ensure_filter_option_format, -) # noqa: F401 - -# --- Parks domain --- -from .parks import ( - ParkListOutputSerializer, - ParkDetailOutputSerializer, - ParkCreateInputSerializer, - ParkUpdateInputSerializer, - ParkFilterInputSerializer, - ParkAreaDetailOutputSerializer, - ParkAreaCreateInputSerializer, - ParkAreaUpdateInputSerializer, - ParkLocationOutputSerializer, - ParkLocationCreateInputSerializer, - ParkLocationUpdateInputSerializer, - ParkSuggestionSerializer, - ParkSuggestionOutputSerializer, -) # noqa: F401 - -# --- Companies and ride models domain --- -from .companies import ( - CompanyDetailOutputSerializer, - CompanyCreateInputSerializer, - CompanyUpdateInputSerializer, - RideModelDetailOutputSerializer, - RideModelCreateInputSerializer, - RideModelUpdateInputSerializer, -) # noqa: F401 - -# --- Rides domain --- -from .rides import ( - RideParkOutputSerializer, - RideModelOutputSerializer, - RideListOutputSerializer, - RideDetailOutputSerializer, - RideCreateInputSerializer, - RideUpdateInputSerializer, - RideFilterInputSerializer, - RollerCoasterStatsOutputSerializer, - RollerCoasterStatsCreateInputSerializer, - RollerCoasterStatsUpdateInputSerializer, - RideLocationOutputSerializer, - RideLocationCreateInputSerializer, - RideLocationUpdateInputSerializer, - RideReviewOutputSerializer, - RideReviewCreateInputSerializer, - RideReviewUpdateInputSerializer, + validate_filter_metadata_contract, ) # noqa: F401 # --- Accounts domain: try multiple likely locations, fall back to placeholders --- -_ACCOUNTS_SYMBOLS: List[str] = [ +_ACCOUNTS_SYMBOLS: list[str] = [ "UserProfileOutputSerializer", "UserProfileCreateInputSerializer", "UserProfileUpdateInputSerializer", @@ -106,7 +106,7 @@ _ACCOUNTS_SYMBOLS: List[str] = [ ] -def _import_accounts_symbols() -> Dict[str, Any]: +def _import_accounts_symbols() -> dict[str, Any]: """ Try a list of candidate module paths and return a dict mapping expected symbol names to the objects found. If no candidate provides a symbol, the symbol maps to None. @@ -119,7 +119,7 @@ def _import_accounts_symbols() -> Dict[str, Any]: ] # Prepare default placeholders - result: Dict[str, Any] = {name: None for name in _ACCOUNTS_SYMBOLS} + result: dict[str, Any] = dict.fromkeys(_ACCOUNTS_SYMBOLS) for modname in candidates: try: diff --git a/backend/apps/api/v1/serializers/accounts.py b/backend/apps/api/v1/serializers/accounts.py index 5237d070..1af81654 100644 --- a/backend/apps/api/v1/serializers/accounts.py +++ b/backend/apps/api/v1/serializers/accounts.py @@ -5,21 +5,22 @@ This module contains all serializers related to user account management, profile settings, preferences, privacy, notifications, and security. """ -from rest_framework import serializers from django.contrib.auth import get_user_model from drf_spectacular.utils import ( - extend_schema_serializer, OpenApiExample, + extend_schema_serializer, ) +from rest_framework import serializers + from apps.accounts.models import ( - User, - UserProfile, - UserNotification, NotificationPreference, + User, + UserNotification, + UserProfile, ) +from apps.core.choices.serializers import RichChoiceFieldSerializer from apps.lists.models import UserList from apps.rides.models.credits import RideCredit -from apps.core.choices.serializers import RichChoiceFieldSerializer UserModel = get_user_model() @@ -187,7 +188,7 @@ class PublicUserSerializer(serializers.ModelSerializer): Only exposes public information. """ profile = UserProfileSerializer(read_only=True) - + class Meta: model = User fields = [ @@ -906,9 +907,10 @@ class AvatarUploadSerializer(serializers.Serializer): # Try to validate with PIL try: - from PIL import Image import io + from PIL import Image + value.seek(0) image_data = value.read() value.seek(0) # Reset for later use diff --git a/backend/apps/api/v1/serializers/auth.py b/backend/apps/api/v1/serializers/auth.py index 6cb58c07..4bf1a650 100644 --- a/backend/apps/api/v1/serializers/auth.py +++ b/backend/apps/api/v1/serializers/auth.py @@ -5,14 +5,14 @@ This module contains all serializers related to user authentication, registration, password management, and social authentication. """ -from rest_framework import serializers -from django.contrib.auth import get_user_model, authenticate +from django.contrib.auth import authenticate, get_user_model from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError as DjangoValidationError from drf_spectacular.utils import ( - extend_schema_serializer, OpenApiExample, + extend_schema_serializer, ) +from rest_framework import serializers UserModel = get_user_model() diff --git a/backend/apps/api/v1/serializers/companies.py b/backend/apps/api/v1/serializers/companies.py index df4a8ab2..5e8257a2 100644 --- a/backend/apps/api/v1/serializers/companies.py +++ b/backend/apps/api/v1/serializers/companies.py @@ -5,16 +5,16 @@ This module contains all serializers related to companies that operate parks or manufacture rides, as well as ride model serializers. """ -from rest_framework import serializers from drf_spectacular.utils import ( - extend_schema_serializer, - extend_schema_field, OpenApiExample, + extend_schema_field, + extend_schema_serializer, ) +from rest_framework import serializers -from .shared import ModelChoices from apps.core.choices.serializers import RichChoiceFieldSerializer +from .shared import ModelChoices # === COMPANY SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/history.py b/backend/apps/api/v1/serializers/history.py index 4aeb4e1c..9f35dcb1 100644 --- a/backend/apps/api/v1/serializers/history.py +++ b/backend/apps/api/v1/serializers/history.py @@ -5,8 +5,8 @@ This module contains serializers for history tracking and timeline functionality using django-pghistory. """ -from rest_framework import serializers from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers class ParkHistoryEventSerializer(serializers.Serializer): diff --git a/backend/apps/api/v1/serializers/maps.py b/backend/apps/api/v1/serializers/maps.py index a05a1259..4938ffde 100644 --- a/backend/apps/api/v1/serializers/maps.py +++ b/backend/apps/api/v1/serializers/maps.py @@ -5,13 +5,12 @@ This module contains all serializers related to map functionality, including location data, search results, and clustering. """ -from rest_framework import serializers from drf_spectacular.utils import ( - extend_schema_serializer, - extend_schema_field, OpenApiExample, + extend_schema_field, + extend_schema_serializer, ) - +from rest_framework import serializers # === MAP LOCATION SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/media.py b/backend/apps/api/v1/serializers/media.py index 6462f9e3..7a8a654e 100644 --- a/backend/apps/api/v1/serializers/media.py +++ b/backend/apps/api/v1/serializers/media.py @@ -5,13 +5,12 @@ This module contains serializers for photo uploads, media management, and related media functionality. """ -from rest_framework import serializers from drf_spectacular.utils import ( - extend_schema_serializer, - extend_schema_field, OpenApiExample, + extend_schema_field, + extend_schema_serializer, ) - +from rest_framework import serializers # === MEDIA SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/other.py b/backend/apps/api/v1/serializers/other.py index 94e09bbb..f1ac4ec6 100644 --- a/backend/apps/api/v1/serializers/other.py +++ b/backend/apps/api/v1/serializers/other.py @@ -5,13 +5,12 @@ This module contains serializers for statistics, health checks, and other miscellaneous functionality. """ -from rest_framework import serializers from drf_spectacular.utils import ( extend_schema_field, ) -from .shared import ModelChoices -from apps.core.choices.serializers import RichChoiceFieldSerializer +from rest_framework import serializers +from apps.core.choices.serializers import RichChoiceFieldSerializer # === STATISTICS SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/park_reviews.py b/backend/apps/api/v1/serializers/park_reviews.py index e2910434..f1265213 100644 --- a/backend/apps/api/v1/serializers/park_reviews.py +++ b/backend/apps/api/v1/serializers/park_reviews.py @@ -4,10 +4,12 @@ Serializers for park review API endpoints. This module contains serializers for park review CRUD operations. """ +from drf_spectacular.utils import OpenApiExample, extend_schema_field, extend_schema_serializer from rest_framework import serializers -from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample -from apps.parks.models.reviews import ParkReview + from apps.api.v1.serializers.reviews import ReviewUserSerializer +from apps.parks.models.reviews import ParkReview + @extend_schema_serializer( examples=[ diff --git a/backend/apps/api/v1/serializers/parks.py b/backend/apps/api/v1/serializers/parks.py index e8dc44fd..d72552ca 100644 --- a/backend/apps/api/v1/serializers/parks.py +++ b/backend/apps/api/v1/serializers/parks.py @@ -5,18 +5,18 @@ This module contains all serializers related to parks, park areas, park location and park search functionality. """ -from rest_framework import serializers from drf_spectacular.utils import ( - extend_schema_serializer, - extend_schema_field, OpenApiExample, + extend_schema_field, + extend_schema_serializer, ) +from rest_framework import serializers + +from apps.core.choices.serializers import RichChoiceFieldSerializer +from apps.core.services.media_url_service import MediaURLService from config.django import base as settings -from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices -from apps.core.services.media_url_service import MediaURLService -from apps.core.choices.serializers import RichChoiceFieldSerializer - +from .shared import CompanyOutputSerializer, LocationOutputSerializer, ModelChoices # === PARK SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/parks_media.py b/backend/apps/api/v1/serializers/parks_media.py index 71fc0d64..2b86f988 100644 --- a/backend/apps/api/v1/serializers/parks_media.py +++ b/backend/apps/api/v1/serializers/parks_media.py @@ -5,6 +5,7 @@ This module contains serializers for park-specific media functionality. """ from rest_framework import serializers + from apps.parks.models import ParkPhoto diff --git a/backend/apps/api/v1/serializers/reviews.py b/backend/apps/api/v1/serializers/reviews.py index b7243e0f..a894cbe2 100644 --- a/backend/apps/api/v1/serializers/reviews.py +++ b/backend/apps/api/v1/serializers/reviews.py @@ -3,9 +3,10 @@ Serializers for review-related API endpoints. """ from rest_framework import serializers + +from apps.accounts.models import User from apps.parks.models.reviews import ParkReview from apps.rides.models.reviews import RideReview -from apps.accounts.models import User class ReviewUserSerializer(serializers.ModelSerializer): diff --git a/backend/apps/api/v1/serializers/ride_credits.py b/backend/apps/api/v1/serializers/ride_credits.py index 38af8630..b1d282ef 100644 --- a/backend/apps/api/v1/serializers/ride_credits.py +++ b/backend/apps/api/v1/serializers/ride_credits.py @@ -1,17 +1,18 @@ from rest_framework import serializers -from drf_spectacular.utils import extend_schema_field -from apps.rides.models.credits import RideCredit -from apps.rides.models import Ride + from apps.api.v1.serializers.rides import RideListOutputSerializer +from apps.rides.models import Ride +from apps.rides.models.credits import RideCredit + class RideCreditSerializer(serializers.ModelSerializer): """Serializer for user ride credits.""" - + ride_id = serializers.PrimaryKeyRelatedField( queryset=Ride.objects.all(), source='ride', write_only=True ) ride = RideListOutputSerializer(read_only=True) - + class Meta: model = RideCredit fields = [ @@ -23,6 +24,7 @@ class RideCreditSerializer(serializers.ModelSerializer): 'first_ridden_at', 'last_ridden_at', 'notes', + 'display_order', 'created_at', 'updated_at', ] @@ -37,7 +39,7 @@ class RideCreditSerializer(serializers.ModelSerializer): last = attrs.get('last_ridden_at') if first and last and last < first: raise serializers.ValidationError("Last ridden date cannot be before first ridden date.") - + return attrs def create(self, validated_data): diff --git a/backend/apps/api/v1/serializers/ride_models.py b/backend/apps/api/v1/serializers/ride_models.py index e433378b..feeede88 100644 --- a/backend/apps/api/v1/serializers/ride_models.py +++ b/backend/apps/api/v1/serializers/ride_models.py @@ -5,16 +5,17 @@ This module contains all serializers related to ride models, variants, technical specifications, and related functionality. """ -from rest_framework import serializers from drf_spectacular.utils import ( - extend_schema_serializer, - extend_schema_field, OpenApiExample, + extend_schema_field, + extend_schema_serializer, ) +from rest_framework import serializers + +from apps.core.choices.serializers import RichChoiceFieldSerializer from config.django import base as settings from .shared import ModelChoices -from apps.core.choices.serializers import RichChoiceFieldSerializer # Use dynamic imports to avoid circular import issues @@ -23,9 +24,9 @@ def get_ride_model_classes(): """Get ride model classes dynamically to avoid import issues.""" from apps.rides.models import ( RideModel, - RideModelVariant, RideModelPhoto, RideModelTechnicalSpec, + RideModelVariant, ) return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec diff --git a/backend/apps/api/v1/serializers/ride_reviews.py b/backend/apps/api/v1/serializers/ride_reviews.py index 60be865e..9ba6012b 100644 --- a/backend/apps/api/v1/serializers/ride_reviews.py +++ b/backend/apps/api/v1/serializers/ride_reviews.py @@ -4,11 +4,11 @@ Serializers for ride review API endpoints. This module contains serializers for ride review CRUD operations with Rich Choice Objects compliance. """ +from drf_spectacular.utils import OpenApiExample, extend_schema_field, extend_schema_serializer from rest_framework import serializers -from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample -from apps.rides.models.reviews import RideReview + from apps.accounts.models import User -from apps.core.choices.serializers import RichChoiceSerializer +from apps.rides.models.reviews import RideReview class ReviewUserSerializer(serializers.ModelSerializer): @@ -74,7 +74,7 @@ class RideReviewOutputSerializer(serializers.ModelSerializer): """Output serializer for ride reviews.""" user = ReviewUserSerializer(read_only=True) - + # Ride information ride = serializers.SerializerMethodField() park = serializers.SerializerMethodField() diff --git a/backend/apps/api/v1/serializers/rides.py b/backend/apps/api/v1/serializers/rides.py index fbd18801..417cf444 100644 --- a/backend/apps/api/v1/serializers/rides.py +++ b/backend/apps/api/v1/serializers/rides.py @@ -5,16 +5,17 @@ This module contains all serializers related to rides, roller coaster statistics ride locations, and ride reviews. """ -from rest_framework import serializers from drf_spectacular.utils import ( - extend_schema_serializer, - extend_schema_field, OpenApiExample, + extend_schema_field, + extend_schema_serializer, ) -from config.django import base as settings -from .shared import ModelChoices -from apps.core.choices.serializers import RichChoiceFieldSerializer +from rest_framework import serializers +from apps.core.choices.serializers import RichChoiceFieldSerializer +from config.django import base as settings + +from .shared import ModelChoices # === RIDE SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/rides_media.py b/backend/apps/api/v1/serializers/rides_media.py index 1cc01306..59dbf49c 100644 --- a/backend/apps/api/v1/serializers/rides_media.py +++ b/backend/apps/api/v1/serializers/rides_media.py @@ -5,6 +5,7 @@ This module contains serializers for ride-specific media functionality. """ from rest_framework import serializers + from apps.rides.models import RidePhoto diff --git a/backend/apps/api/v1/serializers/search.py b/backend/apps/api/v1/serializers/search.py index ae880c0c..d08e527f 100644 --- a/backend/apps/api/v1/serializers/search.py +++ b/backend/apps/api/v1/serializers/search.py @@ -6,9 +6,10 @@ and other search functionality. """ from rest_framework import serializers -from ..shared import ModelChoices + from apps.core.choices.serializers import RichChoiceFieldSerializer +from ..shared import ModelChoices # === CORE ENTITY SEARCH SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/services.py b/backend/apps/api/v1/serializers/services.py index 5b82559a..25dc6052 100644 --- a/backend/apps/api/v1/serializers/services.py +++ b/backend/apps/api/v1/serializers/services.py @@ -5,11 +5,10 @@ This module contains serializers for various services like email, maps, history tracking, moderation, and roadtrip planning. """ -from rest_framework import serializers from drf_spectacular.utils import ( extend_schema_field, ) - +from rest_framework import serializers # === HEALTH CHECK SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/shared.py b/backend/apps/api/v1/serializers/shared.py index 1e7e0286..2c8cfe84 100644 --- a/backend/apps/api/v1/serializers/shared.py +++ b/backend/apps/api/v1/serializers/shared.py @@ -8,14 +8,15 @@ These serializers prevent contract violations by providing a single source of tr for common data structures used throughout the API. """ +from typing import Any + from rest_framework import serializers -from typing import Dict, Any, List class FilterOptionSerializer(serializers.Serializer): """ Standard filter option format - matches frontend TypeScript exactly. - + Frontend TypeScript interface: interface FilterOption { value: string; @@ -31,7 +32,7 @@ class FilterOptionSerializer(serializers.Serializer): help_text="Human-readable display label" ) count = serializers.IntegerField( - required=False, + required=False, allow_null=True, help_text="Number of items matching this filter option" ) @@ -44,7 +45,7 @@ class FilterOptionSerializer(serializers.Serializer): class FilterRangeSerializer(serializers.Serializer): """ Standard range filter format. - + Frontend TypeScript interface: interface FilterRange { min: number; @@ -66,7 +67,7 @@ class FilterRangeSerializer(serializers.Serializer): help_text="Step size for range inputs" ) unit = serializers.CharField( - required=False, + required=False, allow_null=True, help_text="Unit of measurement (e.g., 'feet', 'mph', 'stars')" ) @@ -75,7 +76,7 @@ class FilterRangeSerializer(serializers.Serializer): class BooleanFilterSerializer(serializers.Serializer): """ Standard boolean filter format. - + Frontend TypeScript interface: interface BooleanFilter { key: string; @@ -97,7 +98,7 @@ class BooleanFilterSerializer(serializers.Serializer): class OrderingOptionSerializer(serializers.Serializer): """ Standard ordering option format. - + Frontend TypeScript interface: interface OrderingOption { value: string; @@ -115,7 +116,7 @@ class OrderingOptionSerializer(serializers.Serializer): class StandardizedFilterMetadataSerializer(serializers.Serializer): """ Matches frontend TypeScript interface exactly. - + This serializer ensures all filter metadata responses follow the same structure that the frontend expects, preventing runtime type errors. """ @@ -131,7 +132,7 @@ class StandardizedFilterMetadataSerializer(serializers.Serializer): help_text="Total number of items in the filtered dataset" ) ordering_options = FilterOptionSerializer( - many=True, + many=True, required=False, help_text="Available ordering options" ) @@ -145,7 +146,7 @@ class StandardizedFilterMetadataSerializer(serializers.Serializer): class PaginationMetadataSerializer(serializers.Serializer): """ Standard pagination metadata format. - + Frontend TypeScript interface: interface PaginationMetadata { count: number; @@ -183,7 +184,7 @@ class PaginationMetadataSerializer(serializers.Serializer): class ApiResponseSerializer(serializers.Serializer): """ Standard API response wrapper. - + Frontend TypeScript interface: interface ApiResponse { success: boolean; @@ -214,7 +215,7 @@ class ApiResponseSerializer(serializers.Serializer): class ErrorResponseSerializer(serializers.Serializer): """ Standard error response format. - + Frontend TypeScript interface: interface ApiError { status: "error"; @@ -245,7 +246,7 @@ class ErrorResponseSerializer(serializers.Serializer): class LocationSerializer(serializers.Serializer): """ Standard location format. - + Frontend TypeScript interface: interface Location { city: string; @@ -291,7 +292,7 @@ LocationOutputSerializer = LocationSerializer class CompanyOutputSerializer(serializers.Serializer): """ Standard company output format. - + Frontend TypeScript interface: interface Company { id: number; @@ -322,24 +323,24 @@ class ModelChoices: """ Utility class to provide model choices for serializers using Rich Choice Objects. This prevents circular imports while providing access to model choices from the registry. - + NO FALLBACKS - All choices must be properly defined in Rich Choice Objects. """ - + @staticmethod def get_park_status_choices(): """Get park status choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("statuses", "parks") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_ride_status_choices(): """Get ride status choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("statuses", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_company_role_choices(): """Get company role choices from Rich Choice registry.""" @@ -350,91 +351,91 @@ class ModelChoices: parks_choices = get_choices("company_roles", "parks") all_choices = list(rides_choices) + list(parks_choices) return [(choice.value, choice.label) for choice in all_choices] - + @staticmethod def get_ride_category_choices(): """Get ride category choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("categories", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_ride_post_closing_choices(): """Get ride post-closing status choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("post_closing_statuses", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_coaster_track_choices(): """Get coaster track material choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("track_materials", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_coaster_type_choices(): """Get coaster type choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("coaster_types", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_launch_choices(): """Get launch system choices from Rich Choice registry (legacy method).""" from apps.core.choices.registry import get_choices choices = get_choices("propulsion_systems", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_propulsion_system_choices(): """Get propulsion system choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("propulsion_systems", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_photo_type_choices(): """Get photo type choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("photo_types", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_spec_category_choices(): """Get technical specification category choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("spec_categories", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_technical_spec_category_choices(): """Get technical specification category choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("spec_categories", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_target_market_choices(): """Get target market choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("target_markets", "rides") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_entity_type_choices(): """Get entity type choices for search functionality.""" from apps.core.choices.registry import get_choices choices = get_choices("entity_types", "core") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_health_status_choices(): """Get health check status choices from Rich Choice registry.""" from apps.core.choices.registry import get_choices choices = get_choices("health_statuses", "core") return [(choice.value, choice.label) for choice in choices] - + @staticmethod def get_simple_health_status_choices(): """Get simple health check status choices from Rich Choice registry.""" @@ -446,7 +447,7 @@ class ModelChoices: class EntityReferenceSerializer(serializers.Serializer): """ Standard entity reference format. - + Frontend TypeScript interface: interface Entity { id: number; @@ -468,7 +469,7 @@ class EntityReferenceSerializer(serializers.Serializer): class ImageVariantsSerializer(serializers.Serializer): """ Standard image variants format. - + Frontend TypeScript interface: interface ImageVariants { thumbnail: string; @@ -495,7 +496,7 @@ class ImageVariantsSerializer(serializers.Serializer): class PhotoSerializer(serializers.Serializer): """ Standard photo format. - + Frontend TypeScript interface: interface Photo { id: number; @@ -546,7 +547,7 @@ class PhotoSerializer(serializers.Serializer): class UserInfoSerializer(serializers.Serializer): """ Standard user info format. - + Frontend TypeScript interface: interface UserInfo { id: number; @@ -571,19 +572,19 @@ class UserInfoSerializer(serializers.Serializer): ) -def validate_filter_metadata_contract(data: Dict[str, Any]) -> Dict[str, Any]: +def validate_filter_metadata_contract(data: dict[str, Any]) -> dict[str, Any]: """ Validate that filter metadata follows the expected contract. - + This function can be used in views to ensure filter metadata matches the frontend TypeScript interface before returning it. - + Args: data: Filter metadata dictionary - + Returns: Validated and potentially transformed data - + Raises: serializers.ValidationError: If data doesn't match contract """ @@ -593,21 +594,21 @@ def validate_filter_metadata_contract(data: Dict[str, Any]) -> Dict[str, Any]: return serializer.validated_data -def ensure_filter_option_format(options: List[Any]) -> List[Dict[str, Any]]: +def ensure_filter_option_format(options: list[Any]) -> list[dict[str, Any]]: """ Ensure a list of filter options follows the expected format. - + This utility function converts various input formats to the standard FilterOption format expected by the frontend. - + Args: options: List of options in various formats - + Returns: List of options in standard format """ standardized = [] - + for option in options: if isinstance(option, dict): # Already in correct format or close to it @@ -633,19 +634,19 @@ def ensure_filter_option_format(options: List[Any]) -> List[Dict[str, Any]]: 'count': None, 'selected': False } - + standardized.append(standardized_option) - + return standardized -def ensure_range_format(range_data: Dict[str, Any]) -> Dict[str, Any]: +def ensure_range_format(range_data: dict[str, Any]) -> dict[str, Any]: """ Ensure range data follows the expected format. - + Args: range_data: Range data dictionary - + Returns: Range data in standard format """ diff --git a/backend/apps/api/v1/serializers_rankings.py b/backend/apps/api/v1/serializers_rankings.py index b3c37ccf..ce90af96 100644 --- a/backend/apps/api/v1/serializers_rankings.py +++ b/backend/apps/api/v1/serializers_rankings.py @@ -2,13 +2,14 @@ API serializers for the ride ranking system. """ -from rest_framework import serializers from drf_spectacular.utils import ( - extend_schema_serializer, - extend_schema_field, OpenApiExample, + extend_schema_field, + extend_schema_serializer, ) -from apps.rides.models import RideRanking, RankingSnapshot +from rest_framework import serializers + +from apps.rides.models import RankingSnapshot, RideRanking @extend_schema_serializer( @@ -179,6 +180,7 @@ class RideRankingDetailSerializer(serializers.ModelSerializer): def get_head_to_head_comparisons(self, obj): """Get top head-to-head comparisons.""" from django.db.models import Q + from apps.rides.models import RidePairComparison comparisons = ( diff --git a/backend/apps/api/v1/signals.py b/backend/apps/api/v1/signals.py index 5059e8b9..50bc41c1 100644 --- a/backend/apps/api/v1/signals.py +++ b/backend/apps/api/v1/signals.py @@ -5,17 +5,20 @@ This module contains signal handlers that invalidate the stats cache whenever relevant entities are created, updated, or deleted. """ -from django.db.models.signals import post_save, post_delete -from django.dispatch import receiver from django.core.cache import cache +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver -from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany +from apps.parks.models import Company as ParkCompany +from apps.parks.models import Park, ParkPhoto, ParkReview +from apps.rides.models import ( + Company as RideCompany, +) from apps.rides.models import ( Ride, - RollerCoasterStats, - RideReview, RidePhoto, - Company as RideCompany, + RideReview, + RollerCoasterStats, ) diff --git a/backend/apps/api/v1/tests/test_contracts.py b/backend/apps/api/v1/tests/test_contracts.py index 77a510ca..3657a430 100644 --- a/backend/apps/api/v1/tests/test_contracts.py +++ b/backend/apps/api/v1/tests/test_contracts.py @@ -5,120 +5,120 @@ These tests verify that API responses match frontend TypeScript interfaces exact preventing runtime errors and ensuring type safety. """ -from django.test import TestCase, Client +from django.test import Client, TestCase from rest_framework.test import APITestCase +from apps.api.v1.serializers.shared import ( + ensure_filter_option_format, + ensure_range_format, + validate_filter_metadata_contract, +) from apps.parks.services.hybrid_loader import smart_park_loader from apps.rides.services.hybrid_loader import SmartRideLoader -from apps.api.v1.serializers.shared import ( - validate_filter_metadata_contract, - ensure_filter_option_format, - ensure_range_format -) class FilterMetadataContractTests(TestCase): """Test that filter metadata follows the expected contract.""" - + def setUp(self): self.client = Client() - + def test_parks_filter_metadata_structure(self): """Test that parks filter metadata has correct structure.""" # Get filter metadata from the service metadata = smart_park_loader.get_filter_metadata() - + # Should have required top-level keys self.assertIn('categorical', metadata) self.assertIn('ranges', metadata) self.assertIn('total_count', metadata) - + # Categorical filters should be objects with value/label/count categorical = metadata['categorical'] self.assertIsInstance(categorical, dict) - + for filter_name, filter_options in categorical.items(): with self.subTest(filter_name=filter_name): - self.assertIsInstance(filter_options, list, + self.assertIsInstance(filter_options, list, f"Filter '{filter_name}' should be a list") - + for i, option in enumerate(filter_options): with self.subTest(filter_name=filter_name, option_index=i): self.assertIsInstance(option, dict, f"Filter '{filter_name}' option {i} should be an object, not {type(option).__name__}") - + # Check required properties self.assertIn('value', option, f"Filter '{filter_name}' option {i} missing 'value' property") self.assertIn('label', option, f"Filter '{filter_name}' option {i} missing 'label' property") - + # Check types self.assertIsInstance(option['value'], str, f"Filter '{filter_name}' option {i} 'value' should be string") self.assertIsInstance(option['label'], str, f"Filter '{filter_name}' option {i} 'label' should be string") - + # Count is optional but should be int if present if 'count' in option and option['count'] is not None: self.assertIsInstance(option['count'], int, f"Filter '{filter_name}' option {i} 'count' should be int") - + def test_rides_filter_metadata_structure(self): """Test that rides filter metadata has correct structure.""" loader = SmartRideLoader() metadata = loader.get_filter_metadata() - + # Should have required top-level keys self.assertIn('categorical', metadata) self.assertIn('ranges', metadata) self.assertIn('total_count', metadata) - + # Categorical filters should be objects with value/label/count categorical = metadata['categorical'] self.assertIsInstance(categorical, dict) - + # Test specific categorical filters that were problematic critical_filters = ['categories', 'statuses', 'roller_coaster_types', 'track_materials'] - + for filter_name in critical_filters: if filter_name in categorical: with self.subTest(filter_name=filter_name): filter_options = categorical[filter_name] self.assertIsInstance(filter_options, list) - + for i, option in enumerate(filter_options): with self.subTest(filter_name=filter_name, option_index=i): self.assertIsInstance(option, dict, f"CRITICAL: Filter '{filter_name}' option {i} is {type(option).__name__} but should be dict") - + self.assertIn('value', option) self.assertIn('label', option) self.assertIn('count', option) - + def test_range_metadata_structure(self): """Test that range metadata has correct structure.""" # Test parks ranges parks_metadata = smart_park_loader.get_filter_metadata() ranges = parks_metadata['ranges'] - + for range_name, range_data in ranges.items(): with self.subTest(range_name=range_name): self.assertIsInstance(range_data, dict, f"Range '{range_name}' should be an object") - + # Check required properties self.assertIn('min', range_data) self.assertIn('max', range_data) self.assertIn('step', range_data) self.assertIn('unit', range_data) - + # Check types (min/max can be None) if range_data['min'] is not None: self.assertIsInstance(range_data['min'], (int, float)) if range_data['max'] is not None: self.assertIsInstance(range_data['max'], (int, float)) - + self.assertIsInstance(range_data['step'], (int, float)) # Unit can be None or string if range_data['unit'] is not None: @@ -127,7 +127,7 @@ class FilterMetadataContractTests(TestCase): class ContractValidationUtilityTests(TestCase): """Test contract validation utility functions.""" - + def test_validate_filter_metadata_contract_valid(self): """Test validation passes for valid filter metadata.""" valid_metadata = { @@ -147,16 +147,16 @@ class ContractValidationUtilityTests(TestCase): }, 'total_count': 100 } - + # Should not raise an exception validated = validate_filter_metadata_contract(valid_metadata) self.assertIsInstance(validated, dict) self.assertEqual(validated['total_count'], 100) - + def test_validate_filter_metadata_contract_invalid(self): """Test validation fails for invalid filter metadata.""" from rest_framework import serializers - + invalid_metadata = { 'categorical': { 'statuses': ['OPERATING', 'CLOSED_TEMP'] # Should be objects, not strings @@ -164,17 +164,17 @@ class ContractValidationUtilityTests(TestCase): 'ranges': {}, 'total_count': 100 } - + # Should raise ValidationError with self.assertRaises(serializers.ValidationError): validate_filter_metadata_contract(invalid_metadata) - + def test_ensure_filter_option_format_strings(self): """Test converting string arrays to proper format.""" string_options = ['OPERATING', 'CLOSED_TEMP', 'UNDER_CONSTRUCTION'] - + formatted = ensure_filter_option_format(string_options) - + self.assertEqual(len(formatted), 3) for i, option in enumerate(formatted): self.assertIsInstance(option, dict) @@ -182,44 +182,44 @@ class ContractValidationUtilityTests(TestCase): self.assertIn('label', option) self.assertIn('count', option) self.assertIn('selected', option) - + self.assertEqual(option['value'], string_options[i]) self.assertEqual(option['label'], string_options[i]) self.assertIsNone(option['count']) self.assertFalse(option['selected']) - + def test_ensure_filter_option_format_tuples(self): """Test converting tuple arrays to proper format.""" tuple_options = [ ('OPERATING', 'Operating', 5), ('CLOSED_TEMP', 'Temporarily Closed', 2) ] - + formatted = ensure_filter_option_format(tuple_options) - + self.assertEqual(len(formatted), 2) self.assertEqual(formatted[0]['value'], 'OPERATING') self.assertEqual(formatted[0]['label'], 'Operating') self.assertEqual(formatted[0]['count'], 5) - + self.assertEqual(formatted[1]['value'], 'CLOSED_TEMP') self.assertEqual(formatted[1]['label'], 'Temporarily Closed') self.assertEqual(formatted[1]['count'], 2) - + def test_ensure_filter_option_format_dicts(self): """Test that properly formatted dicts pass through correctly.""" dict_options = [ {'value': 'OPERATING', 'label': 'Operating', 'count': 5}, {'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2} ] - + formatted = ensure_filter_option_format(dict_options) - + self.assertEqual(len(formatted), 2) self.assertEqual(formatted[0]['value'], 'OPERATING') self.assertEqual(formatted[0]['label'], 'Operating') self.assertEqual(formatted[0]['count'], 5) - + def test_ensure_range_format(self): """Test range format utility.""" range_data = { @@ -228,36 +228,36 @@ class ContractValidationUtilityTests(TestCase): 'step': 0.5, 'unit': 'stars' } - + formatted = ensure_range_format(range_data) - + self.assertEqual(formatted['min'], 1.0) self.assertEqual(formatted['max'], 10.0) self.assertEqual(formatted['step'], 0.5) self.assertEqual(formatted['unit'], 'stars') - + def test_ensure_range_format_missing_step(self): """Test range format with missing step defaults to 1.0.""" range_data = { 'min': 1, 'max': 10 } - + formatted = ensure_range_format(range_data) - + self.assertEqual(formatted['step'], 1.0) self.assertIsNone(formatted['unit']) class APIEndpointContractTests(APITestCase): """Test actual API endpoints for contract compliance.""" - + def test_parks_hybrid_endpoint_contract(self): """Test parks hybrid endpoint returns proper contract.""" # This would require actual data in the database # For now, we'll test the structure pass - + def test_rides_hybrid_endpoint_contract(self): """Test rides hybrid endpoint returns proper contract.""" # This would require actual data in the database @@ -267,7 +267,7 @@ class APIEndpointContractTests(APITestCase): class TypeScriptInterfaceComplianceTests(TestCase): """Test that responses match TypeScript interfaces exactly.""" - + def test_filter_option_interface_compliance(self): """Test FilterOption interface compliance.""" # TypeScript interface: @@ -277,28 +277,28 @@ class TypeScriptInterfaceComplianceTests(TestCase): # count?: number; # selected?: boolean; # } - + option = { 'value': 'OPERATING', 'label': 'Operating', 'count': 5, 'selected': False } - + # All required fields present self.assertIn('value', option) self.assertIn('label', option) - + # Correct types self.assertIsInstance(option['value'], str) self.assertIsInstance(option['label'], str) - + # Optional fields have correct types if present if 'count' in option and option['count'] is not None: self.assertIsInstance(option['count'], int) if 'selected' in option: self.assertIsInstance(option['selected'], bool) - + def test_filter_range_interface_compliance(self): """Test FilterRange interface compliance.""" # TypeScript interface: @@ -308,27 +308,27 @@ class TypeScriptInterfaceComplianceTests(TestCase): # step: number; # unit?: string; # } - + range_data = { 'min': 1.0, 'max': 10.0, 'step': 0.1, 'unit': 'stars' } - + # All required fields present self.assertIn('min', range_data) self.assertIn('max', range_data) self.assertIn('step', range_data) - + # Correct types (min/max can be null) if range_data['min'] is not None: self.assertIsInstance(range_data['min'], (int, float)) if range_data['max'] is not None: self.assertIsInstance(range_data['max'], (int, float)) - + self.assertIsInstance(range_data['step'], (int, float)) - + # Optional unit field if 'unit' in range_data and range_data['unit'] is not None: self.assertIsInstance(range_data['unit'], str) @@ -336,72 +336,72 @@ class TypeScriptInterfaceComplianceTests(TestCase): class RegressionTests(TestCase): """Regression tests for specific contract violations that were fixed.""" - + def test_categorical_filters_not_strings(self): """Regression test: Ensure categorical filters are never returned as strings.""" # This was the main issue - categorical filters were returned as: - # ['OPERATING', 'CLOSED_TEMP'] instead of + # ['OPERATING', 'CLOSED_TEMP'] instead of # [{'value': 'OPERATING', 'label': 'Operating', 'count': 5}, ...] - + # Test parks parks_metadata = smart_park_loader.get_filter_metadata() categorical = parks_metadata.get('categorical', {}) - + for filter_name, filter_options in categorical.items(): with self.subTest(filter_name=filter_name): self.assertIsInstance(filter_options, list) - + for i, option in enumerate(filter_options): with self.subTest(filter_name=filter_name, option_index=i): self.assertIsInstance(option, dict, f"REGRESSION: Filter '{filter_name}' option {i} is a {type(option).__name__} " f"but should be a dict. This causes frontend crashes!") - + # Must not be a string self.assertNotIsInstance(option, str, f"CRITICAL REGRESSION: Filter '{filter_name}' option {i} is a string '{option}' " f"but frontend expects object with value/label/count properties!") - + # Test rides rides_loader = SmartRideLoader() rides_metadata = rides_loader.get_filter_metadata() categorical = rides_metadata.get('categorical', {}) - + for filter_name, filter_options in categorical.items(): with self.subTest(filter_name=f"rides_{filter_name}"): self.assertIsInstance(filter_options, list) - + for i, option in enumerate(filter_options): with self.subTest(filter_name=f"rides_{filter_name}", option_index=i): self.assertIsInstance(option, dict, f"REGRESSION: Rides filter '{filter_name}' option {i} is a {type(option).__name__} " f"but should be a dict. This causes frontend crashes!") - + def test_ranges_have_step_and_unit(self): """Regression test: Ensure ranges have step and unit properties.""" # Frontend expects: { min: number, max: number, step: number, unit?: string } # Backend was sometimes missing step and unit - + parks_metadata = smart_park_loader.get_filter_metadata() ranges = parks_metadata.get('ranges', {}) - + for range_name, range_data in ranges.items(): with self.subTest(range_name=range_name): self.assertIn('step', range_data, f"Range '{range_name}' missing 'step' property required by frontend") self.assertIn('unit', range_data, f"Range '{range_name}' missing 'unit' property required by frontend") - + # Step should be a number self.assertIsInstance(range_data['step'], (int, float), f"Range '{range_name}' step should be a number") - + def test_no_undefined_values(self): """Regression test: Ensure no undefined values (should be null).""" # JavaScript undefined !== null, and TypeScript interfaces expect null - + parks_metadata = smart_park_loader.get_filter_metadata() - + def check_no_undefined(obj, path=""): if isinstance(obj, dict): for key, value in obj.items(): @@ -413,6 +413,6 @@ class RegressionTests(TestCase): for i, item in enumerate(obj): current_path = f"{path}[{i}]" check_no_undefined(item, current_path) - + # This will recursively check the entire metadata structure check_no_undefined(parks_metadata) diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py index 0b088688..a870500b 100644 --- a/backend/apps/api/v1/urls.py +++ b/backend/apps/api/v1/urls.py @@ -5,23 +5,24 @@ This module provides unified API routing following RESTful conventions and DRF Router patterns for automatic URL generation. """ -from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView +from django.urls import include, path +from rest_framework.routers import DefaultRouter + # Import other views from the views directory from .views import ( HealthCheckAPIView, + NewContentAPIView, PerformanceMetricsAPIView, SimpleHealthAPIView, # Trending system views TrendingAPIView, - NewContentAPIView, TriggerTrendingCalculationAPIView, ) from .views.discovery import DiscoveryAPIView -from .views.stats import StatsAPIView, StatsRecalculateAPIView -from .views.reviews import LatestReviewsAPIView from .views.leaderboard import leaderboard -from django.urls import path, include -from rest_framework.routers import DefaultRouter +from .views.reviews import LatestReviewsAPIView +from .views.stats import StatsAPIView, StatsRecalculateAPIView +from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView # Create the main API router router = DefaultRouter() @@ -79,6 +80,7 @@ urlpatterns = [ path("core/", include("apps.api.v1.core.urls")), path("maps/", include("apps.api.v1.maps.urls")), path("lists/", include("apps.lists.urls")), + path("companies/", include("apps.api.v1.rides.company_urls")), path("moderation/", include("apps.moderation.urls")), path("reviews/", include("apps.reviews.urls")), path("media/", include("apps.media.urls")), diff --git a/backend/apps/api/v1/views/__init__.py b/backend/apps/api/v1/views/__init__.py index aa481e4f..c6aa28e9 100644 --- a/backend/apps/api/v1/views/__init__.py +++ b/backend/apps/api/v1/views/__init__.py @@ -9,25 +9,23 @@ This package contains all API view classes organized by functionality: # Import all view classes for easy access from .auth import ( - LoginAPIView, - SignupAPIView, - LogoutAPIView, - CurrentUserAPIView, - PasswordResetAPIView, - PasswordChangeAPIView, - SocialProvidersAPIView, AuthStatusAPIView, + CurrentUserAPIView, + LoginAPIView, + LogoutAPIView, + PasswordChangeAPIView, + PasswordResetAPIView, + SignupAPIView, + SocialProvidersAPIView, ) - from .health import ( HealthCheckAPIView, PerformanceMetricsAPIView, SimpleHealthAPIView, ) - from .trending import ( - TrendingAPIView, NewContentAPIView, + TrendingAPIView, TriggerTrendingCalculationAPIView, ) diff --git a/backend/apps/api/v1/views/auth.py b/backend/apps/api/v1/views/auth.py index 555cd4bf..4eef4f3d 100644 --- a/backend/apps/api/v1/views/auth.py +++ b/backend/apps/api/v1/views/auth.py @@ -7,34 +7,34 @@ login, signup, logout, password management, and social authentication. # type: ignore[misc,attr-defined,arg-type,call-arg,index,assignment] -from typing import TYPE_CHECKING, Type, Any -from django.contrib.auth import login, logout, get_user_model +from typing import TYPE_CHECKING, Any + +from django.contrib.auth import get_user_model, login, logout from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status -from rest_framework.views import APIView +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.permissions import AllowAny, IsAuthenticated -from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework.views import APIView # Import serializers from the auth serializers module from ..serializers.auth import ( + AuthStatusOutputSerializer, LoginInputSerializer, LoginOutputSerializer, - SignupInputSerializer, - SignupOutputSerializer, LogoutOutputSerializer, - UserOutputSerializer, - PasswordResetInputSerializer, - PasswordResetOutputSerializer, PasswordChangeInputSerializer, PasswordChangeOutputSerializer, + PasswordResetInputSerializer, + PasswordResetOutputSerializer, + SignupInputSerializer, + SignupOutputSerializer, SocialProviderOutputSerializer, - AuthStatusOutputSerializer, + UserOutputSerializer, ) - # Handle optional dependencies with fallback classes @@ -56,7 +56,7 @@ except ImportError: if TYPE_CHECKING: from typing import Union - TurnstileMixinType = Union[Type[FallbackTurnstileMixin], Any] + TurnstileMixinType = Union[type[FallbackTurnstileMixin], Any] else: TurnstileMixinType = TurnstileMixin diff --git a/backend/apps/api/v1/views/base.py b/backend/apps/api/v1/views/base.py index 58affa9e..9436b1ce 100644 --- a/backend/apps/api/v1/views/base.py +++ b/backend/apps/api/v1/views/base.py @@ -6,16 +6,15 @@ consistent formats that match frontend TypeScript interfaces exactly. """ import logging -from typing import Dict, Any, Optional, Type -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status -from rest_framework.serializers import Serializer -from django.conf import settings +from typing import Any -from apps.api.v1.serializers.shared import ( - validate_filter_metadata_contract -) +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response +from rest_framework.serializers import Serializer +from rest_framework.views import APIView + +from apps.api.v1.serializers.shared import validate_filter_metadata_contract logger = logging.getLogger(__name__) @@ -23,28 +22,28 @@ logger = logging.getLogger(__name__) class ContractCompliantAPIView(APIView): """ Base API view that ensures all responses are contract-compliant. - + This view provides: - Standardized success response format - Consistent error response format - Automatic contract validation in DEBUG mode - Proper error logging with context """ - + # Override in subclasses to specify response serializer - response_serializer_class: Optional[Type[Serializer]] = None - + response_serializer_class: type[Serializer] | None = None + def dispatch(self, request, *args, **kwargs): """Override dispatch to add contract validation.""" try: response = super().dispatch(request, *args, **kwargs) - + # Validate contract in DEBUG mode if settings.DEBUG and hasattr(response, 'data'): self._validate_response_contract(response.data) - + return response - + except Exception as e: # Log the error with context logger.error( @@ -58,66 +57,66 @@ class ContractCompliantAPIView(APIView): }, exc_info=True ) - + # Return standardized error response return self.error_response( message="An internal error occurred", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) - + def success_response( - self, - data: Any = None, - message: str = None, + self, + data: Any = None, + message: str = None, status_code: int = status.HTTP_200_OK, - headers: Dict[str, str] = None + headers: dict[str, str] = None ) -> Response: """ Create a standardized success response. - + Args: data: Response data message: Optional success message status_code: HTTP status code headers: Optional response headers - + Returns: Response with standardized format """ response_data = { 'success': True } - + if data is not None: response_data['data'] = data - + if message: response_data['message'] = message - + return Response( - response_data, + response_data, status=status_code, headers=headers ) - + def error_response( self, message: str, status_code: int = status.HTTP_400_BAD_REQUEST, error_code: str = None, details: Any = None, - headers: Dict[str, str] = None + headers: dict[str, str] = None ) -> Response: """ Create a standardized error response. - + Args: message: Error message status_code: HTTP status code error_code: Optional error code details: Optional error details headers: Optional response headers - + Returns: Response with standardized error format """ @@ -125,40 +124,40 @@ class ContractCompliantAPIView(APIView): 'code': error_code or 'API_ERROR', 'message': message } - + if details: error_data['details'] = details - + # Add user context if available if hasattr(self, 'request') and hasattr(self.request, 'user'): user = self.request.user if user and user.is_authenticated: error_data['request_user'] = user.username - + response_data = { 'status': 'error', 'error': error_data, 'data': None } - + return Response( response_data, status=status_code, headers=headers ) - + def validation_error_response( self, - errors: Dict[str, Any], + errors: dict[str, Any], message: str = "Validation failed" ) -> Response: """ Create a standardized validation error response. - + Args: errors: Validation errors dictionary message: Error message - + Returns: Response with validation errors """ @@ -170,11 +169,11 @@ class ContractCompliantAPIView(APIView): }, status=status.HTTP_400_BAD_REQUEST ) - + def _validate_response_contract(self, data: Any) -> None: """ Validate response data against expected contracts. - + This method is called automatically in DEBUG mode to catch contract violations during development. """ @@ -182,9 +181,9 @@ class ContractCompliantAPIView(APIView): # Check if this looks like filter metadata if isinstance(data, dict) and 'categorical' in data and 'ranges' in data: validate_filter_metadata_contract(data) - + # Add more contract validations as needed - + except Exception as e: logger.warning( f"Contract validation failed in {self.__class__.__name__}: {str(e)}", @@ -199,30 +198,30 @@ class ContractCompliantAPIView(APIView): class FilterMetadataAPIView(ContractCompliantAPIView): """ Base view for filter metadata endpoints. - + This view ensures filter metadata responses always follow the correct contract that matches frontend TypeScript interfaces. """ - - def get_filter_metadata(self) -> Dict[str, Any]: + + def get_filter_metadata(self) -> dict[str, Any]: """ Override this method in subclasses to provide filter metadata. - + Returns: Filter metadata dictionary """ raise NotImplementedError("Subclasses must implement get_filter_metadata()") - + def get(self, request, *args, **kwargs): """Handle GET requests for filter metadata.""" try: metadata = self.get_filter_metadata() - + # Validate the metadata contract validated_metadata = validate_filter_metadata_contract(metadata) - + return self.success_response(validated_metadata) - + except Exception as e: logger.error( f"Error getting filter metadata in {self.__class__.__name__}: {str(e)}", @@ -232,7 +231,7 @@ class FilterMetadataAPIView(ContractCompliantAPIView): }, exc_info=True ) - + return self.error_response( message="Failed to retrieve filter metadata", error_code="FILTER_METADATA_ERROR" @@ -242,37 +241,37 @@ class FilterMetadataAPIView(ContractCompliantAPIView): class HybridFilteringAPIView(ContractCompliantAPIView): """ Base view for hybrid filtering endpoints. - + This view provides common functionality for hybrid filtering responses and ensures they follow the correct contract. """ - - def get_hybrid_data(self, filters: Dict[str, Any] = None) -> Dict[str, Any]: + + def get_hybrid_data(self, filters: dict[str, Any] = None) -> dict[str, Any]: """ Override this method in subclasses to provide hybrid data. - + Args: filters: Filter parameters - + Returns: Hybrid response dictionary """ raise NotImplementedError("Subclasses must implement get_hybrid_data()") - + def get(self, request, *args, **kwargs): """Handle GET requests for hybrid filtering.""" try: # Extract filters from request parameters filters = self.extract_filters(request) - + # Get hybrid data hybrid_data = self.get_hybrid_data(filters) - + # Validate hybrid response structure self._validate_hybrid_response(hybrid_data) - + return self.success_response(hybrid_data) - + except Exception as e: logger.error( f"Error in hybrid filtering for {self.__class__.__name__}: {str(e)}", @@ -283,21 +282,21 @@ class HybridFilteringAPIView(ContractCompliantAPIView): }, exc_info=True ) - + return self.error_response( message="Failed to retrieve filtered data", error_code="HYBRID_FILTERING_ERROR" ) - - def extract_filters(self, request) -> Dict[str, Any]: + + def extract_filters(self, request) -> dict[str, Any]: """ Extract filter parameters from request. - + Override this method in subclasses to customize filter extraction. - + Args: request: HTTP request object - + Returns: Dictionary of filter parameters """ @@ -306,24 +305,24 @@ class HybridFilteringAPIView(ContractCompliantAPIView): for key, value in request.query_params.items(): if value: # Only include non-empty values filters[key] = value - + # Store for error logging self._extracted_filters = filters - + return filters - - def _validate_hybrid_response(self, data: Dict[str, Any]) -> None: + + def _validate_hybrid_response(self, data: dict[str, Any]) -> None: """Validate hybrid response structure.""" required_fields = ['strategy', 'total_count'] - + for field in required_fields: if field not in data: raise ValueError(f"Hybrid response missing required field: {field}") - + # Validate strategy value if data['strategy'] not in ['client_side', 'server_side']: raise ValueError(f"Invalid strategy value: {data['strategy']}") - + # Validate filter metadata if present if 'filter_metadata' in data: validate_filter_metadata_contract(data['filter_metadata']) @@ -332,77 +331,77 @@ class HybridFilteringAPIView(ContractCompliantAPIView): class PaginatedAPIView(ContractCompliantAPIView): """ Base view for paginated responses. - + This view ensures paginated responses follow the correct contract with consistent pagination metadata. """ - + default_page_size = 20 max_page_size = 100 - + def get_paginated_response( self, queryset, - serializer_class: Type[Serializer], + serializer_class: type[Serializer], request, page_size: int = None ) -> Response: """ Create a paginated response. - + Args: queryset: Django queryset to paginate serializer_class: Serializer class for items request: HTTP request object page_size: Optional page size override - + Returns: Paginated response """ - from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger - + from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator + # Determine page size if page_size is None: page_size = min( int(request.query_params.get('page_size', self.default_page_size)), self.max_page_size ) - + # Get page number page_number = request.query_params.get('page', 1) - + try: page_number = int(page_number) except (ValueError, TypeError): page_number = 1 - + # Create paginator paginator = Paginator(queryset, page_size) - + try: page = paginator.page(page_number) except PageNotAnInteger: page = paginator.page(1) except EmptyPage: page = paginator.page(paginator.num_pages) - + # Serialize data serializer = serializer_class(page.object_list, many=True) - + # Build pagination URLs request_url = request.build_absolute_uri().split('?')[0] query_params = request.query_params.copy() - + next_url = None if page.has_next(): query_params['page'] = page.next_page_number() next_url = f"{request_url}?{query_params.urlencode()}" - + previous_url = None if page.has_previous(): query_params['page'] = page.previous_page_number() previous_url = f"{request_url}?{query_params.urlencode()}" - + # Create response data response_data = { 'count': paginator.count, @@ -413,36 +412,36 @@ class PaginatedAPIView(ContractCompliantAPIView): 'current_page': page.number, 'total_pages': paginator.num_pages } - + return self.success_response(response_data) def contract_compliant_view(view_class): """ Decorator to make any view contract-compliant. - + This decorator can be applied to existing views to add contract validation without changing the base class. """ original_dispatch = view_class.dispatch - + def new_dispatch(self, request, *args, **kwargs): try: response = original_dispatch(self, request, *args, **kwargs) - + # Add contract validation in DEBUG mode if settings.DEBUG and hasattr(response, 'data'): # Basic validation - can be extended pass - + return response - + except Exception as e: logger.error( f"Error in decorated view {view_class.__name__}: {str(e)}", exc_info=True ) - + # Return basic error response return Response( { @@ -455,6 +454,6 @@ def contract_compliant_view(view_class): }, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - + view_class.dispatch = new_dispatch return view_class diff --git a/backend/apps/api/v1/views/discovery.py b/backend/apps/api/v1/views/discovery.py index db70cb16..7ecf5be3 100644 --- a/backend/apps/api/v1/views/discovery.py +++ b/backend/apps/api/v1/views/discovery.py @@ -1,14 +1,14 @@ -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.db.models import F + from django.utils import timezone from drf_spectacular.utils import extend_schema -from datetime import timedelta +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + from apps.parks.models import Park from apps.rides.models import Ride + class DiscoveryAPIView(APIView): """ API endpoint for discovery content (Top Lists, Opening/Closing Soon). @@ -28,7 +28,7 @@ class DiscoveryAPIView(APIView): # --- TOP LISTS --- # Top Parks by average rating top_parks = Park.objects.filter(average_rating__isnull=False).order_by("-average_rating")[:limit] - + # Top Rides by average rating (fallback to RideRanking in future) top_rides = Ride.objects.filter(average_rating__isnull=False).order_by("-average_rating")[:limit] @@ -70,7 +70,7 @@ class DiscoveryAPIView(APIView): "rides": self._serialize(recently_closed_rides, "ride"), } } - + return Response(data) def _serialize(self, queryset, type_): diff --git a/backend/apps/api/v1/views/health.py b/backend/apps/api/v1/views/health.py index abe8f633..c8078fc1 100644 --- a/backend/apps/api/v1/views/health.py +++ b/backend/apps/api/v1/views/health.py @@ -6,14 +6,15 @@ performance metrics, and database analysis. """ import time -from django.utils import timezone + from django.conf import settings -from rest_framework.views import APIView +from django.utils import timezone +from drf_spectacular.utils import extend_schema, extend_schema_view +from health_check.views import MainView +from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from health_check.views import MainView -from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework.views import APIView # Import serializers from ..serializers import ( @@ -150,9 +151,10 @@ class HealthCheckAPIView(APIView): def _get_database_metrics(self) -> dict: """Get database performance metrics.""" try: - from django.db import connection from typing import Any + from django.db import connection + # Get basic connection info metrics: dict[str, Any] = { "vendor": connection.vendor, diff --git a/backend/apps/api/v1/views/leaderboard.py b/backend/apps/api/v1/views/leaderboard.py index 81c2c081..129f5882 100644 --- a/backend/apps/api/v1/views/leaderboard.py +++ b/backend/apps/api/v1/views/leaderboard.py @@ -1,18 +1,18 @@ """ Leaderboard views for user rankings """ -from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import AllowAny -from rest_framework.response import Response +from datetime import timedelta + from django.db.models import Count, Sum from django.db.models.functions import Coalesce from django.utils import timezone -from datetime import timedelta +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response -from apps.accounts.models import User -from apps.rides.models import RideCredit -from apps.reviews.models import Review from apps.moderation.models import EditSubmission +from apps.reviews.models import Review +from apps.rides.models import RideCredit @api_view(['GET']) @@ -20,7 +20,7 @@ from apps.moderation.models import EditSubmission def leaderboard(request): """ Get user leaderboard data. - + Query params: - category: 'credits' | 'reviews' | 'contributions' (default: credits) - period: 'all' | 'monthly' | 'weekly' (default: all) @@ -29,14 +29,14 @@ def leaderboard(request): category = request.query_params.get('category', 'credits') period = request.query_params.get('period', 'all') limit = min(int(request.query_params.get('limit', 25)), 100) - + # Calculate date filter based on period date_filter = None if period == 'weekly': date_filter = timezone.now() - timedelta(days=7) elif period == 'monthly': date_filter = timezone.now() - timedelta(days=30) - + if category == 'credits': return _get_credits_leaderboard(date_filter, limit) elif category == 'reviews': @@ -50,16 +50,16 @@ def leaderboard(request): def _get_credits_leaderboard(date_filter, limit): """Top users by total ride credits.""" queryset = RideCredit.objects.all() - + if date_filter: queryset = queryset.filter(created_at__gte=date_filter) - + # Aggregate credits per user users_data = queryset.values('user_id', 'user__username', 'user__display_name').annotate( total_credits=Coalesce(Sum('count'), 0), unique_rides=Count('ride', distinct=True), ).order_by('-total_credits')[:limit] - + results = [] for rank, entry in enumerate(users_data, 1): results.append({ @@ -70,7 +70,7 @@ def _get_credits_leaderboard(date_filter, limit): 'total_credits': entry['total_credits'], 'unique_rides': entry['unique_rides'], }) - + return Response({ 'category': 'credits', 'results': results, @@ -80,15 +80,15 @@ def _get_credits_leaderboard(date_filter, limit): def _get_reviews_leaderboard(date_filter, limit): """Top users by review count.""" queryset = Review.objects.all() - + if date_filter: queryset = queryset.filter(created_at__gte=date_filter) - + # Count reviews per user users_data = queryset.values('user_id', 'user__username', 'user__display_name').annotate( review_count=Count('id'), ).order_by('-review_count')[:limit] - + results = [] for rank, entry in enumerate(users_data, 1): results.append({ @@ -98,7 +98,7 @@ def _get_reviews_leaderboard(date_filter, limit): 'display_name': entry['user__display_name'] or entry['user__username'], 'review_count': entry['review_count'], }) - + return Response({ 'category': 'reviews', 'results': results, @@ -108,15 +108,15 @@ def _get_reviews_leaderboard(date_filter, limit): def _get_contributions_leaderboard(date_filter, limit): """Top users by approved contributions.""" queryset = EditSubmission.objects.filter(status='approved') - + if date_filter: queryset = queryset.filter(created_at__gte=date_filter) - + # Count contributions per user users_data = queryset.values('submitted_by_id', 'submitted_by__username', 'submitted_by__display_name').annotate( contribution_count=Count('id'), ).order_by('-contribution_count')[:limit] - + results = [] for rank, entry in enumerate(users_data, 1): results.append({ @@ -126,7 +126,7 @@ def _get_contributions_leaderboard(date_filter, limit): 'display_name': entry['submitted_by__display_name'] or entry['submitted_by__username'], 'contribution_count': entry['contribution_count'], }) - + return Response({ 'category': 'contributions', 'results': results, diff --git a/backend/apps/api/v1/views/reviews.py b/backend/apps/api/v1/views/reviews.py index cc1643cc..41a95545 100644 --- a/backend/apps/api/v1/views/reviews.py +++ b/backend/apps/api/v1/views/reviews.py @@ -2,17 +2,19 @@ Views for review-related API endpoints. """ -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from rest_framework import status -from drf_spectacular.utils import extend_schema, OpenApiParameter -from drf_spectacular.types import OpenApiTypes from itertools import chain from operator import attrgetter +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + from apps.parks.models.reviews import ParkReview from apps.rides.models.reviews import RideReview + from ..serializers.reviews import LatestReviewSerializer diff --git a/backend/apps/api/v1/views/stats.py b/backend/apps/api/v1/views/stats.py index cd369f60..42c7e766 100644 --- a/backend/apps/api/v1/views/stats.py +++ b/backend/apps/api/v1/views/stats.py @@ -5,24 +5,29 @@ Provides aggregate statistics about the platform's content including counts of parks, rides, manufacturers, and other entities. """ -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status -from rest_framework.permissions import AllowAny, IsAdminUser -from django.db.models import Count -from django.core.cache import cache -from django.utils import timezone -from drf_spectacular.utils import extend_schema, OpenApiExample from datetime import datetime -from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany +from django.core.cache import cache +from django.db.models import Count +from django.utils import timezone +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.parks.models import Company as ParkCompany +from apps.parks.models import Park, ParkPhoto, ParkReview from apps.rides.models import ( - Ride, - RollerCoasterStats, - RideReview, - RidePhoto, Company as RideCompany, ) +from apps.rides.models import ( + Ride, + RidePhoto, + RideReview, + RollerCoasterStats, +) + from ..serializers.stats import StatsSerializer @@ -103,17 +108,17 @@ class StatsAPIView(APIView): summary="Get platform statistics", description=""" Returns comprehensive aggregate statistics about the ThrillWiki platform. - + This endpoint provides detailed counts and breakdowns of all major entities including: - Parks, rides, and roller coasters - Companies (manufacturers, operators, designers, property owners) - Photos and reviews - Ride categories (roller coasters, dark rides, flat rides, etc.) - Status breakdowns (operating, closed, under construction, etc.) - - Results are cached for 5 minutes for optimal performance and automatically + + Results are cached for 5 minutes for optimal performance and automatically invalidated when relevant data changes. - + **No authentication required** - this is a public endpoint. """.strip(), responses={ diff --git a/backend/apps/api/v1/views/trending.py b/backend/apps/api/v1/views/trending.py index 3652df15..afd375ae 100644 --- a/backend/apps/api/v1/views/trending.py +++ b/backend/apps/api/v1/views/trending.py @@ -5,14 +5,15 @@ This module contains endpoints for trending and new content discovery including trending parks, rides, and recently added content. """ -from datetime import datetime, date -from rest_framework.views import APIView +from datetime import date, datetime + +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.permissions import AllowAny, IsAdminUser from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.permissions import AllowAny, IsAdminUser -from rest_framework import status -from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter -from drf_spectacular.types import OpenApiTypes +from rest_framework.views import APIView @extend_schema_view( @@ -111,9 +112,10 @@ class TriggerTrendingCalculationAPIView(APIView): def post(self, request: Request) -> Response: """Trigger trending content calculation using management commands.""" try: - from django.core.management import call_command import io - from contextlib import redirect_stdout, redirect_stderr + from contextlib import redirect_stderr, redirect_stdout + + from django.core.management import call_command # Capture command output trending_output = io.StringIO() @@ -227,10 +229,7 @@ class NewContentAPIView(APIView): if date_added: try: # Parse the date string - if isinstance(date_added, str): - item_date = datetime.fromisoformat(date_added).date() - else: - item_date = date_added + item_date = datetime.fromisoformat(date_added).date() if isinstance(date_added, str) else date_added # Calculate days difference days_diff = (today - item_date).days diff --git a/backend/apps/api/v1/viewsets_rankings.py b/backend/apps/api/v1/viewsets_rankings.py index db5a6897..8509d39b 100644 --- a/backend/apps/api/v1/viewsets_rankings.py +++ b/backend/apps/api/v1/viewsets_rankings.py @@ -2,32 +2,34 @@ API viewsets for the ride ranking system. """ -from typing import TYPE_CHECKING, Any, Type, cast +from typing import TYPE_CHECKING, Any, cast from django.db.models import Q, QuerySet from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend -from drf_spectacular.utils import extend_schema, extend_schema_view, 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.filters import OrderingFilter -from rest_framework.permissions import IsAuthenticatedOrReadOnly, AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import BaseSerializer -from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.views import APIView +from rest_framework.viewsets import ReadOnlyModelViewSet if TYPE_CHECKING: pass # Import models inside methods to avoid Django initialization issues +import contextlib + from .serializers_rankings import ( - RideRankingSerializer, - RideRankingDetailSerializer, RankingSnapshotSerializer, RankingStatsSerializer, + RideRankingDetailSerializer, + RideRankingSerializer, ) @@ -127,10 +129,8 @@ class RideRankingViewSet(ReadOnlyModelViewSet): # Filter by minimum mutual riders min_riders = request.query_params.get("min_riders") if min_riders: - try: + with contextlib.suppress(ValueError): queryset = queryset.filter(mutual_riders_count__gte=int(min_riders)) - except ValueError: - pass # Filter by park park_slug = request.query_params.get("park") @@ -142,12 +142,12 @@ class RideRankingViewSet(ReadOnlyModelViewSet): def get_serializer_class(self) -> Any: # type: ignore[override] """Use different serializers for list vs detail.""" if self.action == "retrieve": - return cast(Type[BaseSerializer], RideRankingDetailSerializer) + return cast(type[BaseSerializer], RideRankingDetailSerializer) elif self.action == "history": - return cast(Type[BaseSerializer], RankingSnapshotSerializer) + return cast(type[BaseSerializer], RankingSnapshotSerializer) elif self.action == "statistics": - return cast(Type[BaseSerializer], RankingStatsSerializer) - return cast(Type[BaseSerializer], RideRankingSerializer) + return cast(type[BaseSerializer], RankingStatsSerializer) + return cast(type[BaseSerializer], RideRankingSerializer) @action(detail=True, methods=["get"]) def history(self, request, ride_slug=None): @@ -167,7 +167,7 @@ class RideRankingViewSet(ReadOnlyModelViewSet): @action(detail=False, methods=["get"]) def statistics(self, request): """Get overall ranking system statistics.""" - from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot + from apps.rides.models import RankingSnapshot, RidePairComparison, RideRanking total_rankings = RideRanking.objects.count() total_comparisons = RidePairComparison.objects.count() diff --git a/backend/apps/blog/apps.py b/backend/apps/blog/apps.py index 4ad51811..3fce1f6b 100644 --- a/backend/apps/blog/apps.py +++ b/backend/apps/blog/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class BlogConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.blog" diff --git a/backend/apps/blog/models.py b/backend/apps/blog/models.py index c490380c..98573ac3 100644 --- a/backend/apps/blog/models.py +++ b/backend/apps/blog/models.py @@ -1,12 +1,15 @@ -from django.db import models from django.conf import settings -from apps.core.models import SluggedModel +from django.db import models + # Using string reference for CloudflareImage from django_cloudflareimages_toolkit.models import CloudflareImage +from apps.core.models import SluggedModel + + class Tag(SluggedModel): name = models.CharField(max_length=50, unique=True) - + class Meta: ordering = ["name"] @@ -17,7 +20,7 @@ class Post(SluggedModel): title = models.CharField(max_length=255) content = models.TextField(help_text="Markdown content supported") excerpt = models.TextField(blank=True, help_text="Short summary for lists") - + image = models.ForeignKey( CloudflareImage, on_delete=models.SET_NULL, @@ -26,18 +29,18 @@ class Post(SluggedModel): related_name="blog_posts", help_text="Featured image" ) - + author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="blog_posts" ) - + published_at = models.DateTimeField(null=True, blank=True, db_index=True) is_published = models.BooleanField(default=False, db_index=True) - + tags = models.ManyToManyField(Tag, blank=True, related_name="posts") - + class Meta: ordering = ["-published_at", "-created_at"] diff --git a/backend/apps/blog/serializers.py b/backend/apps/blog/serializers.py index 7fe0d0d3..1e137ea9 100644 --- a/backend/apps/blog/serializers.py +++ b/backend/apps/blog/serializers.py @@ -1,8 +1,11 @@ +from django_cloudflareimages_toolkit.models import CloudflareImage from rest_framework import serializers -from .models import Post, Tag + from apps.accounts.serializers import UserSerializer from apps.media.serializers import CloudflareImageSerializer -from django_cloudflareimages_toolkit.models import CloudflareImage + +from .models import Post, Tag + class TagSerializer(serializers.ModelSerializer): class Meta: @@ -14,7 +17,7 @@ class PostListSerializer(serializers.ModelSerializer): author = UserSerializer(read_only=True) tags = TagSerializer(many=True, read_only=True) image = CloudflareImageSerializer(read_only=True) - + class Meta: model = Post fields = [ @@ -33,13 +36,13 @@ class PostDetailSerializer(serializers.ModelSerializer): tags = TagSerializer(many=True, read_only=True) image = CloudflareImageSerializer(read_only=True) image_id = serializers.PrimaryKeyRelatedField( - queryset=CloudflareImage.objects.all(), - source='image', + queryset=CloudflareImage.objects.all(), + source='image', write_only=True, required=False, allow_null=True ) - + class Meta: model = Post fields = [ diff --git a/backend/apps/blog/urls.py b/backend/apps/blog/urls.py index d0774a15..5c4a775e 100644 --- a/backend/apps/blog/urls.py +++ b/backend/apps/blog/urls.py @@ -1,5 +1,6 @@ -from django.urls import path, include +from django.urls import include, path from rest_framework.routers import DefaultRouter + from .views import PostViewSet, TagViewSet router = DefaultRouter() diff --git a/backend/apps/blog/views.py b/backend/apps/blog/views.py index 2b3f8c98..bdfa904b 100644 --- a/backend/apps/blog/views.py +++ b/backend/apps/blog/views.py @@ -1,10 +1,13 @@ -from rest_framework import viewsets, permissions, filters -from django_filters.rest_framework import DjangoFilterBackend from django.utils import timezone -from .models import Post, Tag -from .serializers import PostListSerializer, PostDetailSerializer, TagSerializer +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, permissions, viewsets + from apps.core.permissions import IsStaffOrReadOnly +from .models import Post, Tag +from .serializers import PostDetailSerializer, PostListSerializer, TagSerializer + + class TagViewSet(viewsets.ReadOnlyModelViewSet): queryset = Tag.objects.all() serializer_class = TagSerializer diff --git a/backend/apps/context_portal/alembic/env.py b/backend/apps/context_portal/alembic/env.py index ea3bb1ba..f3c81405 100644 --- a/backend/apps/context_portal/alembic/env.py +++ b/backend/apps/context_portal/alembic/env.py @@ -57,8 +57,10 @@ def run_migrations_online() -> None: # Import SQLAlchemy lazily so environments without it (e.g. static analyzers) # don't fail at module import time. try: - from sqlalchemy import engine_from_config # type: ignore - from sqlalchemy import pool # type: ignore + from sqlalchemy import ( + engine_from_config, # type: ignore + pool, # type: ignore + ) except ImportError as exc: raise RuntimeError( "SQLAlchemy is required to run online Alembic migrations. " diff --git a/backend/apps/context_portal/alembic/versions/2025_06_17_initial_schema.py b/backend/apps/context_portal/alembic/versions/2025_06_17_initial_schema.py index 48f97c96..55d3b2e3 100644 --- a/backend/apps/context_portal/alembic/versions/2025_06_17_initial_schema.py +++ b/backend/apps/context_portal/alembic/versions/2025_06_17_initial_schema.py @@ -6,8 +6,8 @@ Create Date: 2025-06-17 15:00:00.000000 """ -from alembic import op # type: ignore import sqlalchemy as sa # type: ignore +from alembic import op # type: ignore # revision identifiers, used by Alembic. revision = "20250617" diff --git a/backend/apps/core/admin/mixins.py b/backend/apps/core/admin/mixins.py index 2bfccd61..410f9e25 100644 --- a/backend/apps/core/admin/mixins.py +++ b/backend/apps/core/admin/mixins.py @@ -13,7 +13,6 @@ from io import StringIO from django.contrib import admin, messages from django.core.serializers.json import DjangoJSONEncoder from django.http import HttpResponse -from django.utils.html import format_html class QueryOptimizationMixin: diff --git a/backend/apps/core/analytics.py b/backend/apps/core/analytics.py index 51d94ce4..2ef8506b 100644 --- a/backend/apps/core/analytics.py +++ b/backend/apps/core/analytics.py @@ -1,10 +1,11 @@ -from django.db import models +from datetime import timedelta + +import pghistory from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.utils import timezone +from django.db import models from django.db.models import Count -from datetime import timedelta -import pghistory +from django.utils import timezone @pghistory.track() diff --git a/backend/apps/core/api/exceptions.py b/backend/apps/core/api/exceptions.py index f1829acb..ab9bbc48 100644 --- a/backend/apps/core/api/exceptions.py +++ b/backend/apps/core/api/exceptions.py @@ -3,21 +3,27 @@ Custom exception handling for ThrillWiki API. Provides standardized error responses following Django styleguide patterns. """ -from typing import Any, Dict, Optional +from typing import Any -from django.http import Http404 from django.core.exceptions import ( PermissionDenied, +) +from django.core.exceptions import ( ValidationError as DjangoValidationError, ) +from django.http import Http404 from rest_framework import status -from rest_framework.response import Response -from rest_framework.views import exception_handler from rest_framework.exceptions import ( - ValidationError as DRFValidationError, NotFound, +) +from rest_framework.exceptions import ( PermissionDenied as DRFPermissionDenied, ) +from rest_framework.exceptions import ( + ValidationError as DRFValidationError, +) +from rest_framework.response import Response +from rest_framework.views import exception_handler from ..exceptions import ThrillWikiException from ..logging import get_logger, log_exception @@ -26,8 +32,8 @@ logger = get_logger(__name__) def custom_exception_handler( - exc: Exception, context: Dict[str, Any] -) -> Optional[Response]: + exc: Exception, context: dict[str, Any] +) -> Response | None: """ Custom exception handler for DRF that provides standardized error responses. @@ -209,7 +215,7 @@ def _get_error_message(exc: Exception, response_data: Any) -> str: return str(exc) if str(exc) else "An error occurred" -def _get_error_details(exc: Exception, response_data: Any) -> Optional[Dict[str, Any]]: +def _get_error_details(exc: Exception, response_data: Any) -> dict[str, Any] | None: """Extract detailed error information for debugging.""" if isinstance(response_data, dict) and len(response_data) > 1: return response_data @@ -224,7 +230,7 @@ def _get_error_details(exc: Exception, response_data: Any) -> Optional[Dict[str, def _format_django_validation_errors( exc: DjangoValidationError, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Format Django ValidationError for API response.""" if hasattr(exc, "error_dict"): # Field-specific errors diff --git a/backend/apps/core/api/mixins.py b/backend/apps/core/api/mixins.py index f1cc3ecc..9e80526a 100644 --- a/backend/apps/core/api/mixins.py +++ b/backend/apps/core/api/mixins.py @@ -2,10 +2,11 @@ Common mixins for API views following Django styleguide patterns. """ -from typing import Dict, Any, Optional, Type +from typing import Any + +from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response -from rest_framework import status # Constants for error messages _MISSING_INPUT_SERIALIZER_MSG = "Subclasses must set input_serializer class attribute" @@ -20,17 +21,17 @@ class ApiMixin: # Expose expected attributes so static type checkers know they exist on subclasses. # Subclasses or other bases (e.g. DRF GenericAPIView) will actually provide these. - input_serializer: Optional[Type[Any]] = None - output_serializer: Optional[Type[Any]] = None + input_serializer: type[Any] | None = None + output_serializer: type[Any] | None = None def create_response( self, *, data: Any = None, - message: Optional[str] = None, + message: str | None = None, status_code: int = status.HTTP_200_OK, - pagination: Optional[Dict[str, Any]] = None, - metadata: Optional[Dict[str, Any]] = None, + pagination: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, ) -> Response: """ Create standardized API response. @@ -66,8 +67,8 @@ class ApiMixin: *, message: str, status_code: int = status.HTTP_400_BAD_REQUEST, - error_code: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, + error_code: str | None = None, + details: dict[str, Any] | None = None, ) -> Response: """ Create standardized error response. @@ -82,7 +83,7 @@ class ApiMixin: Standardized error Response object """ # explicitly allow any-shaped values in the error_data dict - error_data: Dict[str, Any] = { + error_data: dict[str, Any] = { "code": error_code or "GENERIC_ERROR", "message": message, } diff --git a/backend/apps/core/checks.py b/backend/apps/core/checks.py index 10b57ccc..c695f97a 100644 --- a/backend/apps/core/checks.py +++ b/backend/apps/core/checks.py @@ -20,9 +20,9 @@ Security checks included: import os import re -from django.conf import settings -from django.core.checks import Error, Warning, register, Tags +from django.conf import settings +from django.core.checks import Error, Tags, Warning, register # ============================================================================= # Secret Key Validation diff --git a/backend/apps/core/choices/__init__.py b/backend/apps/core/choices/__init__.py index 1fc8aa5b..ffa24bdb 100644 --- a/backend/apps/core/choices/__init__.py +++ b/backend/apps/core/choices/__init__.py @@ -12,11 +12,11 @@ Key Components: - RichChoiceSerializer: DRF serializer for API responses """ -from .base import RichChoice, ChoiceCategory, ChoiceGroup -from .registry import ChoiceRegistry, register_choices +from .base import ChoiceCategory, ChoiceGroup, RichChoice from .fields import RichChoiceField -from .serializers import RichChoiceSerializer, RichChoiceOptionSerializer -from .utils import validate_choice_value, get_choice_display +from .registry import ChoiceRegistry, register_choices +from .serializers import RichChoiceOptionSerializer, RichChoiceSerializer +from .utils import get_choice_display, validate_choice_value __all__ = [ 'RichChoice', diff --git a/backend/apps/core/choices/base.py b/backend/apps/core/choices/base.py index 0d7eff70..4ce5cf70 100644 --- a/backend/apps/core/choices/base.py +++ b/backend/apps/core/choices/base.py @@ -5,8 +5,8 @@ This module defines the core dataclass structures for rich choice objects. """ from dataclasses import dataclass, field -from typing import Dict, Any, Optional from enum import Enum +from typing import Any class ChoiceCategory(Enum): @@ -30,10 +30,10 @@ class ChoiceCategory(Enum): class RichChoice: """ Rich choice object with metadata support. - + This replaces simple tuple choices with a comprehensive object that can carry additional information like descriptions, colors, icons, and custom metadata. - + Attributes: value: The stored value (equivalent to first element of tuple choice) label: Human-readable display name (equivalent to second element of tuple choice) @@ -45,39 +45,39 @@ class RichChoice: value: str label: str description: str = "" - metadata: Dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) deprecated: bool = False category: ChoiceCategory = ChoiceCategory.OTHER - + def __post_init__(self): """Validate the choice object after initialization""" if not self.value: raise ValueError("Choice value cannot be empty") if not self.label: raise ValueError("Choice label cannot be empty") - + @property - def color(self) -> Optional[str]: + def color(self) -> str | None: """Get the color from metadata if available""" return self.metadata.get('color') - + @property - def icon(self) -> Optional[str]: + def icon(self) -> str | None: """Get the icon from metadata if available""" return self.metadata.get('icon') - + @property - def css_class(self) -> Optional[str]: + def css_class(self) -> str | None: """Get the CSS class from metadata if available""" return self.metadata.get('css_class') - + @property def sort_order(self) -> int: """Get the sort order from metadata, defaulting to 0""" return self.metadata.get('sort_order', 0) - - - def to_dict(self) -> Dict[str, Any]: + + + def to_dict(self) -> dict[str, Any]: """Convert to dictionary representation for API serialization""" return { 'value': self.value, @@ -91,11 +91,11 @@ class RichChoice: 'css_class': self.css_class, 'sort_order': self.sort_order, } - - + + def __str__(self) -> str: return self.label - + def __repr__(self) -> str: return f"RichChoice(value='{self.value}', label='{self.label}')" @@ -104,47 +104,47 @@ class RichChoice: class ChoiceGroup: """ A group of related choices with shared metadata. - + This allows for organizing choices into logical groups with common properties and behaviors. """ name: str choices: list[RichChoice] description: str = "" - metadata: Dict[str, Any] = field(default_factory=dict) - + metadata: dict[str, Any] = field(default_factory=dict) + def __post_init__(self): """Validate the choice group after initialization""" if not self.name: raise ValueError("Choice group name cannot be empty") if not self.choices: raise ValueError("Choice group must contain at least one choice") - + # Validate that all choice values are unique within the group values = [choice.value for choice in self.choices] if len(values) != len(set(values)): raise ValueError("All choice values within a group must be unique") - - def get_choice(self, value: str) -> Optional[RichChoice]: + + def get_choice(self, value: str) -> RichChoice | None: """Get a choice by its value""" for choice in self.choices: if choice.value == value: return choice return None - + def get_choices_by_category(self, category: ChoiceCategory) -> list[RichChoice]: """Get all choices in a specific category""" return [choice for choice in self.choices if choice.category == category] - + def get_active_choices(self) -> list[RichChoice]: """Get all non-deprecated choices""" return [choice for choice in self.choices if not choice.deprecated] - + def to_tuple_choices(self) -> list[tuple[str, str]]: """Convert to legacy tuple choices format""" return [(choice.value, choice.label) for choice in self.choices] - - def to_dict(self) -> Dict[str, Any]: + + def to_dict(self) -> dict[str, Any]: """Convert to dictionary representation for API serialization""" return { 'name': self.name, diff --git a/backend/apps/core/choices/core_choices.py b/backend/apps/core/choices/core_choices.py index 987dc63e..c5a7f34f 100644 --- a/backend/apps/core/choices/core_choices.py +++ b/backend/apps/core/choices/core_choices.py @@ -5,10 +5,9 @@ This module defines all choice objects for core system functionality, including health checks, API statuses, and other system-level choices. """ -from .base import RichChoice, ChoiceCategory +from .base import ChoiceCategory, RichChoice from .registry import register_choices - # Health Check Status Choices HEALTH_STATUSES = [ RichChoice( @@ -128,7 +127,7 @@ ENTITY_TYPES = [ def register_core_choices(): """Register all core system choices with the global registry""" - + register_choices( name="health_statuses", choices=HEALTH_STATUSES, @@ -136,7 +135,7 @@ def register_core_choices(): description="Health check status options", metadata={'domain': 'core', 'type': 'health_status'} ) - + register_choices( name="simple_health_statuses", choices=SIMPLE_HEALTH_STATUSES, @@ -144,7 +143,7 @@ def register_core_choices(): description="Simple health check status options", metadata={'domain': 'core', 'type': 'simple_health_status'} ) - + register_choices( name="entity_types", choices=ENTITY_TYPES, diff --git a/backend/apps/core/choices/fields.py b/backend/apps/core/choices/fields.py index fa4f5479..6ef21af7 100644 --- a/backend/apps/core/choices/fields.py +++ b/backend/apps/core/choices/fields.py @@ -4,10 +4,12 @@ Django Model Fields for Rich Choices This module provides Django model field implementations for rich choice objects. """ -from typing import Any, Optional -from django.db import models +from typing import Any + from django.core.exceptions import ValidationError +from django.db import models from django.forms import ChoiceField + from .base import RichChoice from .registry import registry @@ -15,11 +17,11 @@ from .registry import registry class RichChoiceField(models.CharField): """ Django model field for rich choice objects. - + This field stores the choice value as a CharField but provides rich choice functionality through the registry system. """ - + def __init__( self, choice_group: str, @@ -30,7 +32,7 @@ class RichChoiceField(models.CharField): ): """ Initialize the RichChoiceField. - + Args: choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group @@ -41,66 +43,66 @@ class RichChoiceField(models.CharField): self.choice_group = choice_group self.domain = domain self.allow_deprecated = allow_deprecated - + # Set choices from registry for Django admin and forms if self.allow_deprecated: choices_list = registry.get_choices(choice_group, domain) else: choices_list = registry.get_active_choices(choice_group, domain) - + choices = [(choice.value, choice.label) for choice in choices_list] - + kwargs['choices'] = choices kwargs['max_length'] = max_length - + super().__init__(**kwargs) - + def validate(self, value: Any, model_instance: Any) -> None: """Validate the choice value""" super().validate(value, model_instance) - + if value is None or value == '': return - + # Check if choice exists in registry choice = registry.get_choice(self.choice_group, value, self.domain) if choice is None: raise ValidationError( f"'{value}' is not a valid choice for {self.choice_group}" ) - + # Check if deprecated choices are allowed if choice.deprecated and not self.allow_deprecated: raise ValidationError( f"'{value}' is deprecated and cannot be used for new entries" ) - - def get_rich_choice(self, value: str) -> Optional[RichChoice]: + + def get_rich_choice(self, value: str) -> RichChoice | None: """Get the RichChoice object for a value""" return registry.get_choice(self.choice_group, value, self.domain) - + def get_choice_display(self, value: str) -> str: """Get the display label for a choice value""" return registry.get_choice_display(self.choice_group, value, self.domain) - + def contribute_to_class(self, cls: Any, name: str, private_only: bool = False, **kwargs: Any) -> None: """Add helper methods to the model class (signature compatible with Django Field)""" super().contribute_to_class(cls, name, private_only=private_only, **kwargs) - + # Add get_FOO_rich_choice method def get_rich_choice_method(instance): value = getattr(instance, name) return self.get_rich_choice(value) if value else None - + setattr(cls, f'get_{name}_rich_choice', get_rich_choice_method) - + # Add get_FOO_display method (Django provides this, but we enhance it) def get_display_method(instance): value = getattr(instance, name) return self.get_choice_display(value) if value else '' - + setattr(cls, f'get_{name}_display', get_display_method) - + def deconstruct(self): """Support for Django migrations""" name, path, args, kwargs = super().deconstruct() @@ -114,7 +116,7 @@ class RichChoiceFormField(ChoiceField): """ Form field for rich choices with enhanced functionality. """ - + def __init__( self, choice_group: str, @@ -125,7 +127,7 @@ class RichChoiceFormField(ChoiceField): ): """ Initialize the form field. - + Args: choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group @@ -137,13 +139,13 @@ class RichChoiceFormField(ChoiceField): self.domain = domain self.allow_deprecated = allow_deprecated self.show_descriptions = show_descriptions - + # Get choices from registry if allow_deprecated: choices_list = registry.get_choices(choice_group, domain) else: choices_list = registry.get_active_choices(choice_group, domain) - + # Format choices for display choices = [] for choice in choices_list: @@ -151,24 +153,24 @@ class RichChoiceFormField(ChoiceField): if show_descriptions and choice.description: label = f"{choice.label} - {choice.description}" choices.append((choice.value, label)) - + kwargs['choices'] = choices super().__init__(**kwargs) - + def validate(self, value: Any) -> None: """Validate the choice value""" super().validate(value) - + if value is None or value == '': return - + # Check if choice exists in registry choice = registry.get_choice(self.choice_group, value, self.domain) if choice is None: raise ValidationError( f"'{value}' is not a valid choice for {self.choice_group}" ) - + # Check if deprecated choices are allowed if choice.deprecated and not self.allow_deprecated: raise ValidationError( @@ -185,7 +187,7 @@ def create_rich_choice_field( ) -> RichChoiceField: """ Factory function to create a RichChoiceField. - + This is useful for creating fields with consistent settings across multiple models. """ diff --git a/backend/apps/core/choices/registry.py b/backend/apps/core/choices/registry.py index 9cefae4b..9a15f1e4 100644 --- a/backend/apps/core/choices/registry.py +++ b/backend/apps/core/choices/registry.py @@ -4,55 +4,57 @@ Choice Registry Centralized registry for managing all choice definitions across the application. """ -from typing import Dict, List, Optional, Any +from typing import Any + from django.core.exceptions import ImproperlyConfigured -from .base import RichChoice, ChoiceGroup + +from .base import ChoiceGroup, RichChoice class ChoiceRegistry: """ Centralized registry for managing all choice definitions. - + This provides a single source of truth for all choice objects throughout the application, with support for namespacing by domain. """ - + def __init__(self): - self._choices: Dict[str, ChoiceGroup] = {} - self._domains: Dict[str, List[str]] = {} - + self._choices: dict[str, ChoiceGroup] = {} + self._domains: dict[str, list[str]] = {} + def register( - self, - name: str, - choices: List[RichChoice], + self, + name: str, + choices: list[RichChoice], domain: str = "core", description: str = "", - metadata: Optional[Dict[str, Any]] = None + metadata: dict[str, Any] | None = None ) -> ChoiceGroup: """ Register a group of choices. - + Args: name: Unique name for the choice group choices: List of RichChoice objects domain: Domain namespace (e.g., 'rides', 'parks', 'accounts') description: Description of the choice group metadata: Additional metadata for the group - + Returns: The registered ChoiceGroup - + Raises: ImproperlyConfigured: If name is already registered with different choices """ full_name = f"{domain}.{name}" - + if full_name in self._choices: # Check if the existing registration is identical existing_group = self._choices[full_name] existing_values = [choice.value for choice in existing_group.choices] new_values = [choice.value for choice in choices] - + if existing_values == new_values: # Same choices, return existing group (allow duplicate registration) return existing_group @@ -62,69 +64,69 @@ class ChoiceRegistry: f"Choice group '{full_name}' is already registered with different choices. " f"Existing: {existing_values}, New: {new_values}" ) - + choice_group = ChoiceGroup( name=full_name, choices=choices, description=description, metadata=metadata or {} ) - + self._choices[full_name] = choice_group - + # Track domain if domain not in self._domains: self._domains[domain] = [] self._domains[domain].append(name) - + return choice_group - - def get(self, name: str, domain: str = "core") -> Optional[ChoiceGroup]: + + def get(self, name: str, domain: str = "core") -> ChoiceGroup | None: """Get a choice group by name and domain""" full_name = f"{domain}.{name}" return self._choices.get(full_name) - - def get_choice(self, group_name: str, value: str, domain: str = "core") -> Optional[RichChoice]: + + def get_choice(self, group_name: str, value: str, domain: str = "core") -> RichChoice | None: """Get a specific choice by group name, value, and domain""" choice_group = self.get(group_name, domain) if choice_group: return choice_group.get_choice(value) return None - - def get_choices(self, name: str, domain: str = "core") -> List[RichChoice]: + + def get_choices(self, name: str, domain: str = "core") -> list[RichChoice]: """Get all choices in a group""" choice_group = self.get(name, domain) return choice_group.choices if choice_group else [] - - def get_active_choices(self, name: str, domain: str = "core") -> List[RichChoice]: + + def get_active_choices(self, name: str, domain: str = "core") -> list[RichChoice]: """Get all non-deprecated choices in a group""" choice_group = self.get(name, domain) return choice_group.get_active_choices() if choice_group else [] - - - def get_domains(self) -> List[str]: + + + def get_domains(self) -> list[str]: """Get all registered domains""" return list(self._domains.keys()) - - def get_domain_choices(self, domain: str) -> Dict[str, ChoiceGroup]: + + def get_domain_choices(self, domain: str) -> dict[str, ChoiceGroup]: """Get all choice groups for a specific domain""" if domain not in self._domains: return {} - + return { name: self._choices[f"{domain}.{name}"] for name in self._domains[domain] } - - def list_all(self) -> Dict[str, ChoiceGroup]: + + def list_all(self) -> dict[str, ChoiceGroup]: """Get all registered choice groups""" return self._choices.copy() - + def validate_choice(self, group_name: str, value: str, domain: str = "core") -> bool: """Validate that a choice value exists in a group""" choice = self.get_choice(group_name, value, domain) return choice is not None and not choice.deprecated - + def get_choice_display(self, group_name: str, value: str, domain: str = "core") -> str: """Get the display label for a choice value""" choice = self.get_choice(group_name, value, domain) @@ -132,7 +134,7 @@ class ChoiceRegistry: return choice.label else: raise ValueError(f"Choice value '{value}' not found in group '{group_name}' for domain '{domain}'") - + def clear_domain(self, domain: str) -> None: """Clear all choices for a specific domain (useful for testing)""" if domain in self._domains: @@ -141,7 +143,7 @@ class ChoiceRegistry: if full_name in self._choices: del self._choices[full_name] del self._domains[domain] - + def clear_all(self) -> None: """Clear all registered choices (useful for testing)""" self._choices.clear() @@ -154,33 +156,33 @@ registry = ChoiceRegistry() def register_choices( name: str, - choices: List[RichChoice], + choices: list[RichChoice], domain: str = "core", description: str = "", - metadata: Optional[Dict[str, Any]] = None + metadata: dict[str, Any] | None = None ) -> ChoiceGroup: """ Convenience function to register choices with the global registry. - + Args: name: Unique name for the choice group choices: List of RichChoice objects domain: Domain namespace description: Description of the choice group metadata: Additional metadata for the group - + Returns: The registered ChoiceGroup """ return registry.register(name, choices, domain, description, metadata) -def get_choices(name: str, domain: str = "core") -> List[RichChoice]: +def get_choices(name: str, domain: str = "core") -> list[RichChoice]: """Get choices from the global registry""" return registry.get_choices(name, domain) -def get_choice(group_name: str, value: str, domain: str = "core") -> Optional[RichChoice]: +def get_choice(group_name: str, value: str, domain: str = "core") -> RichChoice | None: """Get a specific choice from the global registry""" return registry.get_choice(group_name, value, domain) diff --git a/backend/apps/core/choices/serializers.py b/backend/apps/core/choices/serializers.py index 8ce8ceff..ffce354e 100644 --- a/backend/apps/core/choices/serializers.py +++ b/backend/apps/core/choices/serializers.py @@ -5,16 +5,18 @@ This module provides Django REST Framework serializer implementations for rich choice objects. """ -from typing import Any, Dict, List +from typing import Any + from rest_framework import serializers -from .base import RichChoice, ChoiceGroup + +from .base import ChoiceGroup, RichChoice from .registry import registry class RichChoiceSerializer(serializers.Serializer): """ Serializer for individual RichChoice objects. - + This provides a consistent API representation for choice objects with all their metadata. """ @@ -28,8 +30,8 @@ class RichChoiceSerializer(serializers.Serializer): icon = serializers.CharField(allow_null=True) css_class = serializers.CharField(allow_null=True) sort_order = serializers.IntegerField() - - def to_representation(self, instance: RichChoice) -> Dict[str, Any]: + + def to_representation(self, instance: RichChoice) -> dict[str, Any]: """Convert RichChoice to dictionary representation""" return instance.to_dict() @@ -37,7 +39,7 @@ class RichChoiceSerializer(serializers.Serializer): class RichChoiceOptionSerializer(serializers.Serializer): """ Serializer for choice options in filter endpoints. - + This replaces the legacy FilterOptionSerializer with rich choice support. """ value = serializers.CharField() @@ -50,8 +52,8 @@ class RichChoiceOptionSerializer(serializers.Serializer): icon = serializers.CharField(allow_null=True, required=False) css_class = serializers.CharField(allow_null=True, required=False) metadata = serializers.DictField(required=False) - - def to_representation(self, instance) -> Dict[str, Any]: + + def to_representation(self, instance) -> dict[str, Any]: """Convert choice option to dictionary representation""" if isinstance(instance, RichChoice): # Convert RichChoice to option format @@ -88,7 +90,7 @@ class RichChoiceOptionSerializer(serializers.Serializer): class ChoiceGroupSerializer(serializers.Serializer): """ Serializer for ChoiceGroup objects. - + This provides API representation for entire choice groups with all their choices and metadata. """ @@ -96,8 +98,8 @@ class ChoiceGroupSerializer(serializers.Serializer): description = serializers.CharField() metadata = serializers.DictField() choices = RichChoiceSerializer(many=True) - - def to_representation(self, instance: ChoiceGroup) -> Dict[str, Any]: + + def to_representation(self, instance: ChoiceGroup) -> dict[str, Any]: """Convert ChoiceGroup to dictionary representation""" return instance.to_dict() @@ -105,11 +107,11 @@ class ChoiceGroupSerializer(serializers.Serializer): class RichChoiceFieldSerializer(serializers.CharField): """ Serializer field for rich choice values. - + This field serializes the choice value but can optionally include rich choice metadata in the response. """ - + def __init__( self, choice_group: str, @@ -119,7 +121,7 @@ class RichChoiceFieldSerializer(serializers.CharField): ): """ Initialize the serializer field. - + Args: choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group @@ -130,12 +132,12 @@ class RichChoiceFieldSerializer(serializers.CharField): self.domain = domain self.include_metadata = include_metadata super().__init__(**kwargs) - + def to_representation(self, value: str) -> Any: """Convert choice value to representation""" if not value: return value - + if self.include_metadata: # Return rich choice object choice = registry.get_choice(self.choice_group, value, self.domain) @@ -158,7 +160,7 @@ class RichChoiceFieldSerializer(serializers.CharField): else: # Return just the value return value - + def to_internal_value(self, data: Any) -> str: """Convert input data to choice value""" if isinstance(data, dict) and 'value' in data: @@ -175,26 +177,26 @@ def create_choice_options_serializer( include_counts: bool = False, queryset=None, count_field: str = 'id' -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """ Create choice options for filter endpoints. - + This function generates choice options with optional counts for use in filter metadata endpoints. - + Args: choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group include_counts: Whether to include counts for each option queryset: QuerySet to count against (required if include_counts=True) count_field: Field to filter on for counting (default: 'id') - + Returns: List of choice option dictionaries """ choices = registry.get_active_choices(choice_group, domain) options = [] - + for choice in choices: option_data = { 'value': choice.value, @@ -207,7 +209,7 @@ def create_choice_options_serializer( 'css_class': choice.css_class, 'metadata': choice.metadata, } - + if include_counts and queryset is not None: # Count items for this choice try: @@ -218,9 +220,9 @@ def create_choice_options_serializer( option_data['count'] = None else: option_data['count'] = None - + options.append(option_data) - + # Sort by sort_order, then by label options.sort(key=lambda x: ( (lambda c: c.sort_order if (c is not None and hasattr(c, 'sort_order')) else 0)( @@ -228,7 +230,7 @@ def create_choice_options_serializer( ), x['label'] )) - + return options @@ -240,19 +242,19 @@ def serialize_choice_value( ) -> Any: """ Serialize a single choice value. - + Args: value: The choice value to serialize choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group include_metadata: Whether to include rich choice metadata - + Returns: Serialized choice value (string or rich object) """ if not value: return value - + if include_metadata: choice = registry.get_choice(choice_group, value, domain) if choice: diff --git a/backend/apps/core/choices/utils.py b/backend/apps/core/choices/utils.py index 2185859d..489c4935 100644 --- a/backend/apps/core/choices/utils.py +++ b/backend/apps/core/choices/utils.py @@ -4,8 +4,9 @@ Utility Functions for Rich Choices This module provides utility functions for working with rich choice objects. """ -from typing import Any, Dict, List, Optional, Tuple -from .base import RichChoice, ChoiceCategory +from typing import Any + +from .base import ChoiceCategory, RichChoice from .registry import registry @@ -17,27 +18,24 @@ def validate_choice_value( ) -> bool: """ Validate that a choice value is valid for a given choice group. - + Args: value: The choice value to validate choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group allow_deprecated: Whether to allow deprecated choices - + Returns: True if valid, False otherwise """ if not value: return True # Allow empty values (handled by field's null/blank settings) - + choice = registry.get_choice(choice_group, value, domain) if choice is None: return False - - if choice.deprecated and not allow_deprecated: - return False - - return True + + return not (choice.deprecated and not allow_deprecated) def get_choice_display( @@ -47,21 +45,21 @@ def get_choice_display( ) -> str: """ Get the display label for a choice value. - + Args: value: The choice value choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group - + Returns: Display label for the choice - + Raises: ValueError: If the choice value is not found in the registry """ if not value: return "" - + choice = registry.get_choice(choice_group, value, domain) if choice: return choice.label @@ -72,24 +70,24 @@ def get_choice_display( def create_status_choices( - statuses: Dict[str, Dict[str, Any]], + statuses: dict[str, dict[str, Any]], category: ChoiceCategory = ChoiceCategory.STATUS -) -> List[RichChoice]: +) -> list[RichChoice]: """ Create status choices with consistent color coding. - + Args: statuses: Dictionary mapping status value to config dict category: Choice category (defaults to STATUS) - + Returns: List of RichChoice objects for statuses """ choices = [] - + for value, config in statuses.items(): metadata = config.get('metadata', {}) - + # Add default status colors if not specified if 'color' not in metadata: if 'operating' in value.lower() or 'active' in value.lower(): @@ -102,7 +100,7 @@ def create_status_choices( metadata['color'] = 'blue' else: metadata['color'] = 'gray' - + choice = RichChoice( value=value, label=config['label'], @@ -112,26 +110,26 @@ def create_status_choices( category=category ) choices.append(choice) - + return choices def create_type_choices( - types: Dict[str, Dict[str, Any]], + types: dict[str, dict[str, Any]], category: ChoiceCategory = ChoiceCategory.TYPE -) -> List[RichChoice]: +) -> list[RichChoice]: """ Create type/classification choices. - + Args: types: Dictionary mapping type value to config dict category: Choice category (defaults to TYPE) - + Returns: List of RichChoice objects for types """ choices = [] - + for value, config in types.items(): choice = RichChoice( value=value, @@ -142,21 +140,21 @@ def create_type_choices( category=category ) choices.append(choice) - + return choices def merge_choice_metadata( - base_metadata: Dict[str, Any], - override_metadata: Dict[str, Any] -) -> Dict[str, Any]: + base_metadata: dict[str, Any], + override_metadata: dict[str, Any] +) -> dict[str, Any]: """ Merge choice metadata dictionaries. - + Args: base_metadata: Base metadata dictionary override_metadata: Override metadata dictionary - + Returns: Merged metadata dictionary """ @@ -166,16 +164,16 @@ def merge_choice_metadata( def filter_choices_by_category( - choices: List[RichChoice], + choices: list[RichChoice], category: ChoiceCategory -) -> List[RichChoice]: +) -> list[RichChoice]: """ Filter choices by category. - + Args: choices: List of RichChoice objects category: Category to filter by - + Returns: Filtered list of choices """ @@ -183,16 +181,16 @@ def filter_choices_by_category( def sort_choices( - choices: List[RichChoice], + choices: list[RichChoice], sort_by: str = "sort_order" -) -> List[RichChoice]: +) -> list[RichChoice]: """ Sort choices by specified criteria. - + Args: choices: List of RichChoice objects sort_by: Sort criteria ("sort_order", "label", "value") - + Returns: Sorted list of choices """ @@ -209,14 +207,14 @@ def sort_choices( def get_choice_colors( choice_group: str, domain: str = "core" -) -> Dict[str, str]: +) -> dict[str, str]: """ Get a mapping of choice values to their colors. - + Args: choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group - + Returns: Dictionary mapping choice values to colors """ @@ -230,35 +228,35 @@ def get_choice_colors( def validate_choice_group_data( name: str, - choices: List[RichChoice], + choices: list[RichChoice], domain: str = "core" -) -> List[str]: +) -> list[str]: """ Validate choice group data and return list of errors. - + Args: name: Choice group name choices: List of RichChoice objects domain: Domain namespace - + Returns: List of validation error messages """ errors = [] - + if not name: errors.append("Choice group name cannot be empty") - + if not choices: errors.append("Choice group must contain at least one choice") return errors - + # Check for duplicate values values = [choice.value for choice in choices] if len(values) != len(set(values)): duplicates = [v for v in values if values.count(v) > 1] errors.append(f"Duplicate choice values found: {', '.join(set(duplicates))}") - + # Validate individual choices for i, choice in enumerate(choices): try: @@ -273,17 +271,17 @@ def validate_choice_group_data( ) except ValueError as e: errors.append(f"Choice {i}: {str(e)}") - + return errors -def create_choice_from_config(config: Dict[str, Any]) -> RichChoice: +def create_choice_from_config(config: dict[str, Any]) -> RichChoice: """ Create a RichChoice from a configuration dictionary. - + Args: config: Configuration dictionary with choice data - + Returns: RichChoice object """ @@ -300,19 +298,19 @@ def create_choice_from_config(config: Dict[str, Any]) -> RichChoice: def export_choices_to_dict( choice_group: str, domain: str = "core" -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Export a choice group to a dictionary format. - + Args: choice_group: Name of the choice group in the registry domain: Domain namespace for the choice group - + Returns: Dictionary representation of the choice group """ group = registry.get(choice_group, domain) if not group: return {} - + return group.to_dict() diff --git a/backend/apps/core/decorators/cache_decorators.py b/backend/apps/core/decorators/cache_decorators.py index e6fe648e..4b880878 100644 --- a/backend/apps/core/decorators/cache_decorators.py +++ b/backend/apps/core/decorators/cache_decorators.py @@ -4,23 +4,26 @@ Advanced caching decorators for API views and functions. import hashlib import json +import logging import time +from collections.abc import Callable from functools import wraps -from typing import Optional, List, Callable, Any, Dict +from typing import Any + from django.http import HttpRequest, HttpResponseBase from django.utils.decorators import method_decorator -from django.views.decorators.vary import vary_on_headers from django.views import View +from django.views.decorators.vary import vary_on_headers from rest_framework.response import Response as DRFResponse + from apps.core.services.enhanced_cache_service import EnhancedCacheService -import logging logger = logging.getLogger(__name__) def cache_api_response( timeout: int = 1800, - vary_on: Optional[List[str]] = None, + vary_on: list[str] | None = None, key_prefix: str = "api", cache_backend: str = "api", ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: @@ -82,14 +85,14 @@ def cache_api_response( "cache_hit": True, }, ) - + # If cached data is our dict format for DRF responses, reconstruct it if isinstance(cached_response, dict) and '__drf_data__' in cached_response: return DRFResponse( - data=cached_response['__drf_data__'], + data=cached_response['__drf_data__'], status=cached_response.get('status', 200) ) - + return cached_response # Execute view and cache result @@ -108,7 +111,7 @@ def cache_api_response( } else: cache_payload = response - + getattr(cache_service, cache_backend + "_cache").set( cache_key, cache_payload, timeout ) @@ -193,7 +196,7 @@ def cache_queryset_result( def invalidate_cache_on_save( - model_name: str, cache_patterns: Optional[List[str]] = None + model_name: str, cache_patterns: list[str] | None = None ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """ Decorator to invalidate cache when model instances are saved @@ -313,8 +316,8 @@ class CachedAPIViewMixin(View): def smart_cache( timeout: int = 3600, - key_func: Optional[Callable[..., str]] = None, - invalidate_on: Optional[List[str]] = None, + key_func: Callable[..., str] | None = None, + invalidate_on: list[str] | None = None, cache_backend: str = "default", ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """ @@ -378,8 +381,8 @@ def smart_cache( # Add cache invalidation if specified if invalidate_on: - setattr(wrapper, "_cache_invalidate_on", invalidate_on) - setattr(wrapper, "_cache_backend", cache_backend) + wrapper._cache_invalidate_on = invalidate_on + wrapper._cache_backend = cache_backend return wrapper @@ -431,7 +434,7 @@ def generate_model_cache_key(model_instance: Any, suffix: str = "") -> str: def generate_queryset_cache_key( - queryset: Any, params: Optional[Dict[str, Any]] = None + queryset: Any, params: dict[str, Any] | None = None ) -> str: """Generate cache key for queryset with parameters""" model_name = queryset.model._meta.model_name diff --git a/backend/apps/core/exceptions.py b/backend/apps/core/exceptions.py index 5a31445f..e5d9fd63 100644 --- a/backend/apps/core/exceptions.py +++ b/backend/apps/core/exceptions.py @@ -3,7 +3,7 @@ Custom exception classes for ThrillWiki. Provides domain-specific exceptions with proper error codes and messages. """ -from typing import Optional, Dict, Any +from typing import Any class ThrillWikiException(Exception): @@ -15,16 +15,16 @@ class ThrillWikiException(Exception): def __init__( self, - message: Optional[str] = None, - error_code: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, + message: str | None = None, + error_code: str | None = None, + details: dict[str, Any] | None = None, ): self.message = message or self.default_message self.error_code = error_code or self.error_code self.details = details or {} super().__init__(self.message) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert exception to dictionary for API responses.""" return { "error_code": self.error_code, @@ -96,7 +96,7 @@ class ParkNotFoundError(NotFoundError): default_message = "Park not found" error_code = "PARK_NOT_FOUND" - def __init__(self, park_slug: Optional[str] = None, **kwargs): + def __init__(self, park_slug: str | None = None, **kwargs): if park_slug: kwargs["details"] = {"park_slug": park_slug} kwargs["message"] = f"Park with slug '{park_slug}' not found" @@ -122,7 +122,7 @@ class RideNotFoundError(NotFoundError): default_message = "Ride not found" error_code = "RIDE_NOT_FOUND" - def __init__(self, ride_slug: Optional[str] = None, **kwargs): + def __init__(self, ride_slug: str | None = None, **kwargs): if ride_slug: kwargs["details"] = {"ride_slug": ride_slug} kwargs["message"] = f"Ride with slug '{ride_slug}' not found" @@ -150,8 +150,8 @@ class InvalidCoordinatesError(ValidationException): def __init__( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, + latitude: float | None = None, + longitude: float | None = None, **kwargs, ): if latitude is not None or longitude is not None: @@ -198,7 +198,7 @@ class InsufficientPermissionsError(PermissionDeniedError): default_message = "Insufficient permissions" error_code = "INSUFFICIENT_PERMISSIONS" - def __init__(self, required_permission: Optional[str] = None, **kwargs): + def __init__(self, required_permission: str | None = None, **kwargs): if required_permission: kwargs["details"] = {"required_permission": required_permission} kwargs["message"] = f"Permission '{required_permission}' required" @@ -226,7 +226,7 @@ class RoadTripError(ExternalServiceError): default_message = "Road trip planning error" error_code = "ROADTRIP_ERROR" - def __init__(self, service_name: Optional[str] = None, **kwargs): + def __init__(self, service_name: str | None = None, **kwargs): if service_name: kwargs["details"] = {"service": service_name} super().__init__(**kwargs) diff --git a/backend/apps/core/forms.py b/backend/apps/core/forms.py index 4deb4262..0e50a156 100644 --- a/backend/apps/core/forms.py +++ b/backend/apps/core/forms.py @@ -1,11 +1,10 @@ """Core forms and form components.""" +from autocomplete import Autocomplete from django.conf import settings from django.core.exceptions import PermissionDenied from django.utils.translation import gettext_lazy as _ -from autocomplete import Autocomplete - class BaseAutocomplete(Autocomplete): """Base autocomplete class for consistent autocomplete behavior across the project. diff --git a/backend/apps/core/forms/htmx_forms.py b/backend/apps/core/forms/htmx_forms.py index 465071a0..eeabc122 100644 --- a/backend/apps/core/forms/htmx_forms.py +++ b/backend/apps/core/forms/htmx_forms.py @@ -1,8 +1,8 @@ """ Base forms and views for HTMX integration. """ -from django.views.generic.edit import FormView from django.http import JsonResponse +from django.views.generic.edit import FormView class HTMXFormView(FormView): diff --git a/backend/apps/core/health_checks/custom_checks.py b/backend/apps/core/health_checks/custom_checks.py index e23bb013..0159e59d 100644 --- a/backend/apps/core/health_checks/custom_checks.py +++ b/backend/apps/core/health_checks/custom_checks.py @@ -2,9 +2,10 @@ Custom health checks for ThrillWiki application. """ -import time import logging +import time from pathlib import Path + from django.core.cache import cache from django.db import connection from health_check.backends import BaseHealthCheckBackend @@ -165,9 +166,10 @@ class ApplicationHealthCheck(BaseHealthCheckBackend): # Check if we can access critical models try: - from parks.models import Park - from apps.rides.models import Ride from django.contrib.auth import get_user_model + from parks.models import Park + + from apps.rides.models import Ride User = get_user_model() @@ -185,9 +187,10 @@ class ApplicationHealthCheck(BaseHealthCheckBackend): self.add_error(f"Model access check failed: {e}") # Check media and static file configuration - from django.conf import settings import os + from django.conf import settings + if not os.path.exists(settings.MEDIA_ROOT): self.add_error(f"Media directory does not exist: {settings.MEDIA_ROOT}") @@ -208,8 +211,8 @@ class ExternalServiceHealthCheck(BaseHealthCheckBackend): def check_status(self): # Check email service if configured try: - from django.core.mail import get_connection from django.conf import settings + from django.core.mail import get_connection if ( hasattr(settings, "EMAIL_BACKEND") @@ -253,8 +256,8 @@ class ExternalServiceHealthCheck(BaseHealthCheckBackend): # Check Redis connection if configured try: - from django.core.cache import caches from django.conf import settings + from django.core.cache import caches cache_config = settings.CACHES.get("default", {}) if "redis" in cache_config.get("BACKEND", "").lower(): @@ -279,6 +282,7 @@ class DiskSpaceHealthCheck(BaseHealthCheckBackend): def check_status(self): try: import shutil + from django.conf import settings # Check disk space for media directory diff --git a/backend/apps/core/history.py b/backend/apps/core/history.py index 2a5d47b0..53a1ffcb 100644 --- a/backend/apps/core/history.py +++ b/backend/apps/core/history.py @@ -1,8 +1,9 @@ -from django.db import models -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.fields import GenericForeignKey +from typing import TYPE_CHECKING, Any + from django.conf import settings -from typing import Any, Dict, Optional, TYPE_CHECKING +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models from django.db.models import QuerySet if TYPE_CHECKING: @@ -12,7 +13,7 @@ if TYPE_CHECKING: class DiffMixin: """Mixin to add diffing capabilities to models with pghistory""" - def get_prev_record(self) -> Optional[Any]: + def get_prev_record(self) -> Any | None: """Get the previous record for this instance""" try: # Use getattr to safely access objects manager and pghistory fields @@ -37,7 +38,7 @@ class DiffMixin: except (AttributeError, TypeError): return None - def diff_against_previous(self) -> Dict: + def diff_against_previous(self) -> dict: """Compare this record against the previous one""" prev_record = self.get_prev_record() if not prev_record: diff --git a/backend/apps/core/htmx_utils.py b/backend/apps/core/htmx_utils.py index 1bec2ea3..52688670 100644 --- a/backend/apps/core/htmx_utils.py +++ b/backend/apps/core/htmx_utils.py @@ -24,7 +24,7 @@ import json from functools import wraps from typing import TYPE_CHECKING, Any -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse from django.template import TemplateDoesNotExist from django.template.loader import render_to_string diff --git a/backend/apps/core/logging.py b/backend/apps/core/logging.py index 5d513b25..511883ca 100644 --- a/backend/apps/core/logging.py +++ b/backend/apps/core/logging.py @@ -5,7 +5,8 @@ Provides structured logging with proper formatting and context. import logging import sys -from typing import Dict, Any, Optional +from typing import Any + from django.conf import settings from django.utils import timezone @@ -65,7 +66,7 @@ def log_exception( logger: logging.Logger, exception: Exception, *, - context: Optional[Dict[str, Any]] = None, + context: dict[str, Any] | None = None, request=None, level: int = logging.ERROR, ) -> None: @@ -111,7 +112,7 @@ def log_business_event( event_type: str, *, message: str, - context: Optional[Dict[str, Any]] = None, + context: dict[str, Any] | None = None, request=None, level: int = logging.INFO, ) -> None: @@ -149,7 +150,7 @@ def log_performance_metric( operation: str, *, duration_ms: float, - context: Optional[Dict[str, Any]] = None, + context: dict[str, Any] | None = None, level: int = logging.INFO, ) -> None: """ @@ -177,8 +178,8 @@ def log_api_request( logger: logging.Logger, request, *, - response_status: Optional[int] = None, - duration_ms: Optional[float] = None, + response_status: int | None = None, + duration_ms: float | None = None, level: int = logging.INFO, ) -> None: """ @@ -219,7 +220,7 @@ def log_security_event( *, message: str, severity: str = "medium", - context: Optional[Dict[str, Any]] = None, + context: dict[str, Any] | None = None, request=None, ) -> None: """ diff --git a/backend/apps/core/management/commands/calculate_new_content.py b/backend/apps/core/management/commands/calculate_new_content.py index 831b60c1..2e86b0bd 100644 --- a/backend/apps/core/management/commands/calculate_new_content.py +++ b/backend/apps/core/management/commands/calculate_new_content.py @@ -7,11 +7,12 @@ Run with: uv run manage.py calculate_new_content import logging from datetime import datetime, timedelta -from typing import Dict, List, Any -from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone +from typing import Any + from django.core.cache import cache +from django.core.management.base import BaseCommand, CommandError from django.db.models import Q +from django.utils import timezone from apps.parks.models import Park from apps.rides.models import Ride @@ -102,7 +103,7 @@ class Command(BaseCommand): logger.error(f"Error calculating new content: {e}", exc_info=True) raise CommandError(f"Failed to calculate new content: {e}") - def _get_new_parks(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + def _get_new_parks(self, cutoff_date: datetime, limit: int) -> list[dict[str, Any]]: """Get recently added parks using real data.""" new_parks = ( Park.objects.filter( @@ -117,9 +118,8 @@ class Command(BaseCommand): results = [] for park in new_parks: date_added = park.opening_date or park.created_at - if date_added: - if isinstance(date_added, datetime): - date_added = date_added.date() + if date_added and isinstance(date_added, datetime): + date_added = date_added.date() opening_date = getattr(park, "opening_date", None) if opening_date and isinstance(opening_date, datetime): @@ -142,7 +142,7 @@ class Command(BaseCommand): return results - def _get_new_rides(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + def _get_new_rides(self, cutoff_date: datetime, limit: int) -> list[dict[str, Any]]: """Get recently added rides using real data.""" new_rides = ( Ride.objects.filter( @@ -159,9 +159,8 @@ class Command(BaseCommand): date_added = getattr(ride, "opening_date", None) or getattr( ride, "created_at", None ) - if date_added: - if isinstance(date_added, datetime): - date_added = date_added.date() + if date_added and isinstance(date_added, datetime): + date_added = date_added.date() opening_date = getattr(ride, "opening_date", None) if opening_date and isinstance(opening_date, datetime): @@ -186,8 +185,8 @@ class Command(BaseCommand): return results def _format_new_content_results( - self, new_items: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: + self, new_items: list[dict[str, Any]] + ) -> list[dict[str, Any]]: """Format new content results for frontend consumption.""" formatted_results = [] diff --git a/backend/apps/core/management/commands/calculate_trending.py b/backend/apps/core/management/commands/calculate_trending.py index 53ea43d1..16f1c8df 100644 --- a/backend/apps/core/management/commands/calculate_trending.py +++ b/backend/apps/core/management/commands/calculate_trending.py @@ -6,11 +6,12 @@ Run with: uv run manage.py calculate_trending """ import logging -from typing import Dict, List, Any +from typing import Any + +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.core.management.base import BaseCommand, CommandError from django.utils import timezone -from django.core.cache import cache -from django.contrib.contenttypes.models import ContentType from apps.core.analytics import PageView from apps.parks.models import Park @@ -107,7 +108,7 @@ class Command(BaseCommand): def _calculate_trending_parks( self, current_period_hours: int, previous_period_hours: int, limit: int - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Calculate trending scores for parks using real data.""" parks = Park.objects.filter(status="OPERATING").select_related( "location", "operator" @@ -151,7 +152,7 @@ class Command(BaseCommand): def _calculate_trending_rides( self, current_period_hours: int, previous_period_hours: int, limit: int - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Calculate trending scores for rides using real data.""" rides = Ride.objects.filter(status="OPERATING").select_related( "park", "park__location" @@ -339,10 +340,10 @@ class Command(BaseCommand): def _format_trending_results( self, - trending_items: List[Dict[str, Any]], + trending_items: list[dict[str, Any]], current_period_hours: int, previous_period_hours: int, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Format trending results for frontend consumption.""" formatted_results = [] diff --git a/backend/apps/core/management/commands/clear_cache.py b/backend/apps/core/management/commands/clear_cache.py index 0db2ccd8..8d1fba7e 100644 --- a/backend/apps/core/management/commands/clear_cache.py +++ b/backend/apps/core/management/commands/clear_cache.py @@ -15,9 +15,9 @@ import shutil import subprocess from pathlib import Path +from django.conf import settings from django.core.cache import cache, caches from django.core.management.base import BaseCommand -from django.conf import settings class Command(BaseCommand): diff --git a/backend/apps/core/management/commands/list_transition_callbacks.py b/backend/apps/core/management/commands/list_transition_callbacks.py index 3e1393ad..4f93d7e5 100644 --- a/backend/apps/core/management/commands/list_transition_callbacks.py +++ b/backend/apps/core/management/commands/list_transition_callbacks.py @@ -6,11 +6,10 @@ showing which callbacks are registered for each model and transition. """ from django.core.management.base import BaseCommand, CommandParser -from django.apps import apps from apps.core.state_machine.callback_base import ( - callback_registry, CallbackStage, + callback_registry, ) from apps.core.state_machine.config import callback_config diff --git a/backend/apps/core/management/commands/optimize_static.py b/backend/apps/core/management/commands/optimize_static.py index 154c33c4..5efd05cb 100644 --- a/backend/apps/core/management/commands/optimize_static.py +++ b/backend/apps/core/management/commands/optimize_static.py @@ -10,10 +10,10 @@ Usage: python manage.py optimize_static --force """ -import os from pathlib import Path -from django.core.management.base import BaseCommand, CommandError + from django.conf import settings +from django.core.management.base import BaseCommand, CommandError class Command(BaseCommand): diff --git a/backend/apps/core/management/commands/rundev.py b/backend/apps/core/management/commands/rundev.py index eabb2a3b..83c7ba8a 100644 --- a/backend/apps/core/management/commands/rundev.py +++ b/backend/apps/core/management/commands/rundev.py @@ -5,8 +5,8 @@ This command automatically sets up the development environment and starts the server, replacing the need for the dev_server.sh script. """ -from django.core.management.base import BaseCommand from django.core.management import execute_from_command_line +from django.core.management.base import BaseCommand class Command(BaseCommand): @@ -92,7 +92,7 @@ class Command(BaseCommand): def has_runserver_plus(self): """Check if runserver_plus is available (django-extensions).""" try: - import django_extensions + import django_extensions # noqa: F401 return True except ImportError: diff --git a/backend/apps/core/management/commands/security_audit.py b/backend/apps/core/management/commands/security_audit.py index fa36c521..d54ac8ae 100644 --- a/backend/apps/core/management/commands/security_audit.py +++ b/backend/apps/core/management/commands/security_audit.py @@ -10,9 +10,9 @@ Usage: 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 +from django.core.checks import Tags, registry +from django.core.management.base import BaseCommand class Command(BaseCommand): @@ -88,10 +88,7 @@ class Command(BaseCommand): ) else: for error in errors: - if error.is_serious(): - prefix = self.style.ERROR(" ✗ ERROR") - else: - prefix = self.style.WARNING(" ! WARNING") + prefix = self.style.ERROR(" ✗ ERROR") if error.is_serious() else self.style.WARNING(" ! WARNING") self.log(f"{prefix}: {error.msg}", report_lines) if error.hint and self.verbose: @@ -169,10 +166,7 @@ class Command(BaseCommand): ]) for name, is_secure, value in checks: - if is_secure: - status = self.style.SUCCESS("✓") - else: - status = self.style.WARNING("!") + status = self.style.SUCCESS("✓") if is_secure else self.style.WARNING("!") msg = f" {status} {name}" if self.verbose: diff --git a/backend/apps/core/management/commands/setup_dev.py b/backend/apps/core/management/commands/setup_dev.py index d9641e6b..c85277cc 100644 --- a/backend/apps/core/management/commands/setup_dev.py +++ b/backend/apps/core/management/commands/setup_dev.py @@ -6,11 +6,10 @@ allowing the project to run without requiring the shell script. """ import subprocess -import sys from pathlib import Path -from django.core.management.base import BaseCommand from django.conf import settings +from django.core.management.base import BaseCommand class Command(BaseCommand): diff --git a/backend/apps/core/management/commands/test_transition_callbacks.py b/backend/apps/core/management/commands/test_transition_callbacks.py index 2a3c4879..081db37b 100644 --- a/backend/apps/core/management/commands/test_transition_callbacks.py +++ b/backend/apps/core/management/commands/test_transition_callbacks.py @@ -5,14 +5,14 @@ This command allows testing callbacks for specific transitions without actually changing model state. """ -from django.core.management.base import BaseCommand, CommandParser, CommandError from django.apps import apps from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError, CommandParser from apps.core.state_machine.callback_base import ( - callback_registry, CallbackStage, TransitionContext, + callback_registry, ) from apps.core.state_machine.monitoring import callback_monitor diff --git a/backend/apps/core/management/commands/test_trending.py b/backend/apps/core/management/commands/test_trending.py index 0857fec8..588506b7 100644 --- a/backend/apps/core/management/commands/test_trending.py +++ b/backend/apps/core/management/commands/test_trending.py @@ -1,13 +1,15 @@ -from django.core.management.base import BaseCommand +import random +from datetime import datetime, timedelta + from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand from django.utils import timezone -from apps.parks.models.parks import Park -from apps.rides.models.rides import Ride -from apps.parks.models.companies import Company + from apps.core.analytics import PageView from apps.core.services.trending_service import trending_service -from datetime import datetime, timedelta -import random +from apps.parks.models.companies import Company +from apps.parks.models.parks import Park +from apps.rides.models.rides import Ride class Command(BaseCommand): @@ -205,7 +207,7 @@ class Command(BaseCommand): content_type = ContentType.objects.get_for_model(type(content_object)) # Create recent views (last 2 hours) - for i in range(recent_views): + for _i in range(recent_views): view_time = base_time - timedelta( minutes=random.randint(0, 120) # Last 2 hours ) @@ -218,7 +220,7 @@ class Command(BaseCommand): ) # Create older views (2-24 hours ago) - for i in range(older_views): + for _i in range(older_views): view_time = base_time - timedelta(hours=random.randint(2, 24)) PageView.objects.create( content_type=content_type, diff --git a/backend/apps/core/management/commands/update_trending.py b/backend/apps/core/management/commands/update_trending.py index 78de8a9c..e510a7f9 100644 --- a/backend/apps/core/management/commands/update_trending.py +++ b/backend/apps/core/management/commands/update_trending.py @@ -1,8 +1,9 @@ -from django.core.management.base import BaseCommand from django.core.cache import cache +from django.core.management.base import BaseCommand + +from apps.core.analytics import PageView from apps.parks.models import Park from apps.rides.models import Ride -from apps.core.analytics import PageView class Command(BaseCommand): diff --git a/backend/apps/core/management/commands/validate_settings.py b/backend/apps/core/management/commands/validate_settings.py index f3d99565..96e12dff 100644 --- a/backend/apps/core/management/commands/validate_settings.py +++ b/backend/apps/core/management/commands/validate_settings.py @@ -12,14 +12,15 @@ Usage: import json import sys -from django.core.management.base import BaseCommand, CommandError + +from django.core.management.base import BaseCommand + +from config.settings.secrets import ( + check_secret_expiry, + validate_required_secrets, +) from config.settings.validation import ( validate_all_settings, - get_validation_report, -) -from config.settings.secrets import ( - validate_required_secrets, - check_secret_expiry, ) diff --git a/backend/apps/core/management/commands/warm_cache.py b/backend/apps/core/management/commands/warm_cache.py index c3f1e060..ba3dc207 100644 --- a/backend/apps/core/management/commands/warm_cache.py +++ b/backend/apps/core/management/commands/warm_cache.py @@ -12,12 +12,13 @@ Usage: python manage.py warm_cache --dry-run """ -import time import logging -from django.core.management.base import BaseCommand -from django.db.models import Count, Avg +import time -from apps.core.services.enhanced_cache_service import EnhancedCacheService, CacheWarmer +from django.core.management.base import BaseCommand +from django.db.models import Count + +from apps.core.services.enhanced_cache_service import EnhancedCacheService logger = logging.getLogger(__name__) @@ -122,7 +123,7 @@ class Command(BaseCommand): ) warmed_count += 1 if verbose: - self.stdout.write(f" Cached park status counts") + self.stdout.write(" Cached park status counts") except Exception as e: failed_count += 1 self.stdout.write(self.style.ERROR(f" Failed to cache park status counts: {e}")) @@ -191,7 +192,7 @@ class Command(BaseCommand): ) warmed_count += 1 if verbose: - self.stdout.write(f" Cached ride category counts") + self.stdout.write(" Cached ride category counts") except Exception as e: failed_count += 1 self.stdout.write(self.style.ERROR(f" Failed to cache ride category counts: {e}")) diff --git a/backend/apps/core/managers.py b/backend/apps/core/managers.py index 027c3091..0b277831 100644 --- a/backend/apps/core/managers.py +++ b/backend/apps/core/managers.py @@ -3,13 +3,13 @@ Custom managers and QuerySets for optimized database patterns. Following Django styleguide best practices for database access. """ -from typing import Optional, List, Union -from django.db import models -from django.db.models import Q, Count, Avg, Max +from datetime import timedelta + from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance +from django.db import models +from django.db.models import Avg, Count, Max, Q from django.utils import timezone -from datetime import timedelta class BaseQuerySet(models.QuerySet): @@ -32,7 +32,7 @@ class BaseQuerySet(models.QuerySet): cutoff_date = timezone.now() - timedelta(days=days) return self.filter(created_at__gte=cutoff_date) - def search(self, *, query: str, fields: Optional[List[str]] = None): + def search(self, *, query: str, fields: list[str] | None = None): """ Full-text search across specified fields. @@ -81,7 +81,7 @@ class BaseManager(models.Manager): def recent(self, *, days: int = 30): return self.get_queryset().recent(days=days) - def search(self, *, query: str, fields: Optional[List[str]] = None): + def search(self, *, query: str, fields: list[str] | None = None): return self.get_queryset().search(query=query, fields=fields) @@ -245,7 +245,7 @@ class TimestampedManager(BaseManager): class StatusQuerySet(BaseQuerySet): """QuerySet for models with status fields.""" - def with_status(self, *, status: Union[str, List[str]]): + def with_status(self, *, status: str | list[str]): """Filter by status.""" if isinstance(status, list): return self.filter(status__in=status) diff --git a/backend/apps/core/middleware/__init__.py b/backend/apps/core/middleware/__init__.py index 658d440d..2b7e98b6 100644 --- a/backend/apps/core/middleware/__init__.py +++ b/backend/apps/core/middleware/__init__.py @@ -5,9 +5,9 @@ This package contains middleware components for the Django application, including view tracking and other core functionality. """ -from .view_tracking import ViewTrackingMiddleware, get_view_stats_for_content from .analytics import PgHistoryContextMiddleware from .nextjs import APIResponseMiddleware +from .view_tracking import ViewTrackingMiddleware, get_view_stats_for_content __all__ = [ "ViewTrackingMiddleware", diff --git a/backend/apps/core/middleware/htmx_error_middleware.py b/backend/apps/core/middleware/htmx_error_middleware.py index 1c22943d..6b169a47 100644 --- a/backend/apps/core/middleware/htmx_error_middleware.py +++ b/backend/apps/core/middleware/htmx_error_middleware.py @@ -2,6 +2,7 @@ Middleware for handling errors in HTMX requests. """ import logging + from django.http import HttpResponseServerError from django.template.loader import render_to_string diff --git a/backend/apps/core/middleware/performance_middleware.py b/backend/apps/core/middleware/performance_middleware.py index 208342e7..76943981 100644 --- a/backend/apps/core/middleware/performance_middleware.py +++ b/backend/apps/core/middleware/performance_middleware.py @@ -2,11 +2,12 @@ Performance monitoring middleware for tracking request metrics. """ -import time import logging +import time + +from django.conf import settings from django.db import connection from django.utils.deprecation import MiddlewareMixin -from django.conf import settings performance_logger = logging.getLogger("performance") logger = logging.getLogger(__name__) @@ -162,10 +163,7 @@ class PerformanceMiddleware(MiddlewareMixin): def _get_client_ip(self, request): """Extract client IP address from request""" x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - ip = x_forwarded_for.split(",")[0].strip() - else: - ip = request.META.get("REMOTE_ADDR", "") + ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else request.META.get("REMOTE_ADDR", "") return ip def _get_log_level(self, duration, query_count, status_code): diff --git a/backend/apps/core/middleware/rate_limiting.py b/backend/apps/core/middleware/rate_limiting.py index defeb407..6bc4d1ed 100644 --- a/backend/apps/core/middleware/rate_limiting.py +++ b/backend/apps/core/middleware/rate_limiting.py @@ -14,11 +14,10 @@ Usage: """ import logging -from typing import Callable, Optional, Tuple +from collections.abc import Callable from django.core.cache import cache from django.http import HttpRequest, HttpResponse, JsonResponse -from django.conf import settings logger = logging.getLogger(__name__) @@ -88,7 +87,7 @@ class AuthRateLimitMiddleware: return response - def _get_rate_limits(self, path: str) -> Optional[dict]: + def _get_rate_limits(self, path: str) -> dict | None: """Get rate limits for a path, if any.""" # Exact match if path in self.RATE_LIMITED_PATHS: @@ -125,7 +124,7 @@ class AuthRateLimitMiddleware: client_ip: str, path: str, limits: dict - ) -> Tuple[bool, str]: + ) -> tuple[bool, str]: """ Check if the client has exceeded rate limits. diff --git a/backend/apps/core/middleware/request_logging.py b/backend/apps/core/middleware/request_logging.py index b4e65611..504dacd1 100644 --- a/backend/apps/core/middleware/request_logging.py +++ b/backend/apps/core/middleware/request_logging.py @@ -3,9 +3,10 @@ Request logging middleware for comprehensive request/response logging. Logs all HTTP requests with detailed data for debugging and monitoring. """ +import json import logging import time -import json + from django.utils.deprecation import MiddlewareMixin logger = logging.getLogger('request_logging') diff --git a/backend/apps/core/middleware/view_tracking.py b/backend/apps/core/middleware/view_tracking.py index 041aac37..6583a160 100644 --- a/backend/apps/core/middleware/view_tracking.py +++ b/backend/apps/core/middleware/view_tracking.py @@ -9,12 +9,13 @@ analytics for the trending algorithm. import logging import re from datetime import timedelta -from typing import Optional, Union -from django.http import HttpRequest, HttpResponse -from django.utils import timezone +from typing import Union + +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.cache import cache -from django.conf import settings +from django.http import HttpRequest, HttpResponse +from django.utils import timezone from apps.core.analytics import PageView from apps.parks.models import Park @@ -105,10 +106,7 @@ class ViewTrackingMiddleware: return True # Skip AJAX requests (optional - depending on requirements) - if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest": - return True - - return False + return request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" def _track_view_if_applicable(self, request: HttpRequest) -> None: """Track view if the URL matches tracked patterns.""" @@ -159,7 +157,7 @@ class ViewTrackingMiddleware: def _get_content_object( self, content_type: str, slug: str - ) -> Optional[ContentObject]: + ) -> ContentObject | None: """Get the content object by type and slug.""" try: if content_type == "park": @@ -234,7 +232,7 @@ class ViewTrackingMiddleware: content_type = ContentType.objects.get_for_model(content_obj) return f"pageview_dedup:{content_type.id}:{content_obj.pk}:{client_ip}" - def _get_client_ip(self, request: HttpRequest) -> Optional[str]: + def _get_client_ip(self, request: HttpRequest) -> str | None: """Extract client IP address from request.""" # Check for forwarded IP (common in production with load balancers) x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") @@ -270,13 +268,12 @@ class ViewTrackingMiddleware: # Skip localhost and private IPs in production if getattr(settings, "SKIP_LOCAL_IPS", not settings.DEBUG): - if ip.startswith(("127.", "192.168.", "10.")) or ip.startswith("172."): - if any( - 16 <= int(ip.split(".")[1]) <= 31 - for _ in [ip] - if ip.startswith("172.") - ): - return False + if (ip.startswith(("127.", "192.168.", "10.")) or ip.startswith("172.")) and any( + 16 <= int(ip.split(".")[1]) <= 31 + for _ in [ip] + if ip.startswith("172.") + ): + return False return True diff --git a/backend/apps/core/mixins/__init__.py b/backend/apps/core/mixins/__init__.py index 673e574e..2d2f7f10 100644 --- a/backend/apps/core/mixins/__init__.py +++ b/backend/apps/core/mixins/__init__.py @@ -1,6 +1,6 @@ """HTMX mixins for views. Canonical definitions for partial rendering and triggers.""" -from typing import Any, Optional, Type +from typing import Any from django.template import TemplateDoesNotExist from django.template.loader import select_template @@ -11,7 +11,7 @@ from django.views.generic.list import MultipleObjectMixin class HTMXFilterableMixin(MultipleObjectMixin): """Enhance list views to return partial templates for HTMX requests.""" - filter_class: Optional[Type[Any]] = None + filter_class: type[Any] | None = None htmx_partial_suffix = "_partial.html" def get_queryset(self): @@ -47,7 +47,7 @@ class HTMXFilterableMixin(MultipleObjectMixin): class HTMXFormMixin(FormMixin): """FormMixin that returns partials and field-level errors for HTMX requests.""" - htmx_success_trigger: Optional[str] = None + htmx_success_trigger: str | None = None def form_invalid(self, form): """Return partial with errors on invalid form submission via HTMX.""" diff --git a/backend/apps/core/models.py b/backend/apps/core/models.py index f2523739..d69c4719 100644 --- a/backend/apps/core/models.py +++ b/backend/apps/core/models.py @@ -1,9 +1,10 @@ -from django.db import models +import pghistory from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.db import models from django.utils.text import slugify + from apps.core.history import TrackedModel -import pghistory @pghistory.track() diff --git a/backend/apps/core/permissions.py b/backend/apps/core/permissions.py index 384ad1e8..cc40da90 100644 --- a/backend/apps/core/permissions.py +++ b/backend/apps/core/permissions.py @@ -1,5 +1,6 @@ from rest_framework import permissions + class IsOwnerOrReadOnly(permissions.BasePermission): """ Custom permission to only allow owners of an object to edit it. diff --git a/backend/apps/core/selectors.py b/backend/apps/core/selectors.py index af4e4d67..34962ec6 100644 --- a/backend/apps/core/selectors.py +++ b/backend/apps/core/selectors.py @@ -3,24 +3,26 @@ Selectors for core functionality including map services and analytics. Following Django styleguide pattern for separating data access from business logic. """ -from typing import Optional, Dict, Any, List -from django.db.models import QuerySet, Q, Count +from datetime import timedelta +from typing import Any + from django.contrib.gis.geos import Point, Polygon from django.contrib.gis.measure import Distance +from django.db.models import Count, Q, QuerySet from django.utils import timezone -from datetime import timedelta -from .analytics import PageView from apps.parks.models import Park from apps.rides.models import Ride +from .analytics import PageView + def unified_locations_for_map( *, - bounds: Optional[Polygon] = None, - location_types: Optional[List[str]] = None, - filters: Optional[Dict[str, Any]] = None, -) -> Dict[str, QuerySet]: + bounds: Polygon | None = None, + location_types: list[str] | None = None, + filters: dict[str, Any] | None = None, +) -> dict[str, QuerySet]: """ Get unified location data for map display across all location types. @@ -88,9 +90,9 @@ def locations_near_point( *, point: Point, distance_km: float = 50, - location_types: Optional[List[str]] = None, + location_types: list[str] | None = None, limit: int = 20, -) -> Dict[str, QuerySet]: +) -> dict[str, QuerySet]: """ Get locations near a specific geographic point across all types. @@ -149,7 +151,7 @@ def locations_near_point( return results -def search_all_locations(*, query: str, limit: int = 20) -> Dict[str, QuerySet]: +def search_all_locations(*, query: str, limit: int = 20) -> dict[str, QuerySet]: """ Search across all location types for a query string. @@ -193,9 +195,9 @@ def search_all_locations(*, query: str, limit: int = 20) -> Dict[str, QuerySet]: def page_views_for_analytics( *, - start_date: Optional[timezone.datetime] = None, - end_date: Optional[timezone.datetime] = None, - path_pattern: Optional[str] = None, + start_date: timezone.datetime | None = None, + end_date: timezone.datetime | None = None, + path_pattern: str | None = None, ) -> QuerySet[PageView]: """ Get page views for analytics with optional filtering. @@ -222,7 +224,7 @@ def page_views_for_analytics( return queryset.order_by("-timestamp") -def popular_pages_summary(*, days: int = 30) -> Dict[str, Any]: +def popular_pages_summary(*, days: int = 30) -> dict[str, Any]: """ Get summary of most popular pages in the last N days. @@ -261,7 +263,7 @@ def popular_pages_summary(*, days: int = 30) -> Dict[str, Any]: } -def geographic_distribution_summary() -> Dict[str, Any]: +def geographic_distribution_summary() -> dict[str, Any]: """ Get geographic distribution statistics for all locations. @@ -290,7 +292,7 @@ def geographic_distribution_summary() -> Dict[str, Any]: } -def system_health_metrics() -> Dict[str, Any]: +def system_health_metrics() -> dict[str, Any]: """ Get system health and activity metrics. diff --git a/backend/apps/core/services/__init__.py b/backend/apps/core/services/__init__.py index 92207b6e..c5ff3509 100644 --- a/backend/apps/core/services/__init__.py +++ b/backend/apps/core/services/__init__.py @@ -2,17 +2,17 @@ Core services for ThrillWiki unified map functionality. """ -from .map_service import UnifiedMapService from .clustering_service import ClusteringService -from .map_cache_service import MapCacheService from .data_structures import ( - UnifiedLocation, - LocationType, + ClusterData, GeoBounds, + LocationType, MapFilters, MapResponse, - ClusterData, + UnifiedLocation, ) +from .map_cache_service import MapCacheService +from .map_service import UnifiedMapService __all__ = [ "UnifiedMapService", diff --git a/backend/apps/core/services/clustering_service.py b/backend/apps/core/services/clustering_service.py index 1fa320a6..9a65373a 100644 --- a/backend/apps/core/services/clustering_service.py +++ b/backend/apps/core/services/clustering_service.py @@ -3,15 +3,15 @@ Clustering service for map locations to improve performance and user experience. """ import math -from typing import List, Dict, Any, Optional -from dataclasses import dataclass from collections import defaultdict +from dataclasses import dataclass +from typing import Any from .data_structures import ( - UnifiedLocation, ClusterData, GeoBounds, LocationType, + UnifiedLocation, ) @@ -70,10 +70,10 @@ class ClusteringService: def cluster_locations( self, - locations: List[UnifiedLocation], + locations: list[UnifiedLocation], zoom_level: int, - bounds: Optional[GeoBounds] = None, - ) -> tuple[List[UnifiedLocation], List[ClusterData]]: + bounds: GeoBounds | None = None, + ) -> tuple[list[UnifiedLocation], list[ClusterData]]: """ Cluster locations based on zoom level and density. Returns (unclustered_locations, clusters). @@ -115,9 +115,9 @@ class ClusteringService: def _project_locations( self, - locations: List[UnifiedLocation], - bounds: Optional[GeoBounds] = None, - ) -> List[ClusterPoint]: + locations: list[UnifiedLocation], + bounds: GeoBounds | None = None, + ) -> list[ClusterPoint]: """Convert lat/lng coordinates to projected x/y for clustering calculations.""" cluster_points = [] @@ -149,8 +149,8 @@ class ClusteringService: return cluster_points def _cluster_points( - self, points: List[ClusterPoint], radius_pixels: int, min_points: int - ) -> List[List[ClusterPoint]]: + self, points: list[ClusterPoint], radius_pixels: int, min_points: int + ) -> list[list[ClusterPoint]]: """ Cluster points using a simple distance-based approach. Radius is in pixels, converted to meters based on zoom level. @@ -189,7 +189,7 @@ class ClusteringService: dy = point1.y - point2.y return math.sqrt(dx * dx + dy * dy) - def _create_cluster(self, cluster_points: List[ClusterPoint]) -> ClusterData: + def _create_cluster(self, cluster_points: list[ClusterPoint]) -> ClusterData: """Create a ClusterData object from a group of points.""" locations = [cp.location for cp in cluster_points] @@ -205,7 +205,7 @@ class ClusteringService: ) # Collect location types in cluster - types = set(loc.type for loc in locations) + types = {loc.type for loc in locations} # Select representative location (highest weight) representative = self._select_representative_location(locations) @@ -224,8 +224,8 @@ class ClusteringService: ) def _select_representative_location( - self, locations: List[UnifiedLocation] - ) -> Optional[UnifiedLocation]: + self, locations: list[UnifiedLocation] + ) -> UnifiedLocation | None: """Select the most representative location for a cluster.""" if not locations: return None @@ -259,7 +259,7 @@ class ClusteringService: # Fall back to highest weight location return max(locations, key=lambda x: x.cluster_weight) - def get_cluster_breakdown(self, clusters: List[ClusterData]) -> Dict[str, Any]: + def get_cluster_breakdown(self, clusters: list[ClusterData]) -> dict[str, Any]: """Get statistics about clustering results.""" if not clusters: return { @@ -293,7 +293,7 @@ class ClusteringService: def expand_cluster( self, cluster: ClusterData, zoom_level: int - ) -> List[UnifiedLocation]: + ) -> list[UnifiedLocation]: """ Expand a cluster to show individual locations (for drill-down functionality). This would typically require re-querying the database with the cluster bounds. @@ -335,7 +335,7 @@ class SmartClusteringRules: @staticmethod def calculate_cluster_priority( - locations: List[UnifiedLocation], + locations: list[UnifiedLocation], ) -> UnifiedLocation: """Select the representative location for a cluster based on priority rules.""" # Prioritize by: 1) Parks over rides, 2) Higher weight, 3) Better diff --git a/backend/apps/core/services/data_structures.py b/backend/apps/core/services/data_structures.py index 6fad33c9..8505941b 100644 --- a/backend/apps/core/services/data_structures.py +++ b/backend/apps/core/services/data_structures.py @@ -4,7 +4,8 @@ Data structures for the unified map service. from dataclasses import dataclass, field from enum import Enum -from typing import Dict, List, Optional, Set, Any +from typing import Any + from django.contrib.gis.geos import Polygon @@ -60,7 +61,7 @@ class GeoBounds: """Check if a point is within these bounds.""" return self.south <= lat <= self.north and self.west <= lng <= self.east - def to_dict(self) -> Dict[str, float]: + def to_dict(self) -> dict[str, float]: """Convert to dictionary for JSON serialization.""" return { "north": self.north, @@ -74,18 +75,18 @@ class GeoBounds: class MapFilters: """Filtering options for map queries.""" - location_types: Optional[Set[LocationType]] = None - park_status: Optional[Set[str]] = None # OPERATING, CLOSED_TEMP, etc. - ride_types: Optional[Set[str]] = None - company_roles: Optional[Set[str]] = None # OPERATOR, MANUFACTURER, etc. - search_query: Optional[str] = None - min_rating: Optional[float] = None + location_types: set[LocationType] | None = None + park_status: set[str] | None = None # OPERATING, CLOSED_TEMP, etc. + ride_types: set[str] | None = None + company_roles: set[str] | None = None # OPERATOR, MANUFACTURER, etc. + search_query: str | None = None + min_rating: float | None = None has_coordinates: bool = True - country: Optional[str] = None - state: Optional[str] = None - city: Optional[str] = None + country: str | None = None + state: str | None = None + city: str | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for caching and serialization.""" return { "location_types": ( @@ -110,10 +111,10 @@ class UnifiedLocation: id: str # Composite: f"{type}_{id}" type: LocationType name: str - coordinates: List[float] # [lat, lng] - address: Optional[str] = None - metadata: Dict[str, Any] = field(default_factory=dict) - type_data: Dict[str, Any] = field(default_factory=dict) + coordinates: list[float] # [lat, lng] + address: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + type_data: dict[str, Any] = field(default_factory=dict) cluster_weight: int = 1 cluster_category: str = "default" @@ -127,7 +128,7 @@ class UnifiedLocation: """Get longitude from coordinates.""" return self.coordinates[1] - def to_geojson_feature(self) -> Dict[str, Any]: + def to_geojson_feature(self) -> dict[str, Any]: """Convert to GeoJSON feature for mapping libraries.""" return { "type": "Feature", @@ -148,7 +149,7 @@ class UnifiedLocation: }, } - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for JSON responses.""" return { "id": self.id, @@ -168,13 +169,13 @@ class ClusterData: """Represents a cluster of locations for map display.""" id: str - coordinates: List[float] # [lat, lng] + coordinates: list[float] # [lat, lng] count: int - types: Set[LocationType] + types: set[LocationType] bounds: GeoBounds - representative_location: Optional[UnifiedLocation] = None + representative_location: UnifiedLocation | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for JSON responses.""" return { "id": self.id, @@ -194,18 +195,18 @@ class ClusterData: class MapResponse: """Response structure for map API calls.""" - locations: List[UnifiedLocation] = field(default_factory=list) - clusters: List[ClusterData] = field(default_factory=list) - bounds: Optional[GeoBounds] = None + locations: list[UnifiedLocation] = field(default_factory=list) + clusters: list[ClusterData] = field(default_factory=list) + bounds: GeoBounds | None = None total_count: int = 0 filtered_count: int = 0 - zoom_level: Optional[int] = None + zoom_level: int | None = None clustered: bool = False cache_hit: bool = False - query_time_ms: Optional[int] = None - filters_applied: List[str] = field(default_factory=list) + query_time_ms: int | None = None + filters_applied: list[str] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for JSON responses.""" return { "status": "success", @@ -241,7 +242,7 @@ class QueryPerformanceMetrics: bounds_used: bool clustering_used: bool - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for logging.""" return { "query_time_ms": self.query_time_ms, diff --git a/backend/apps/core/services/enhanced_cache_service.py b/backend/apps/core/services/enhanced_cache_service.py index 6ae53666..0b426305 100644 --- a/backend/apps/core/services/enhanced_cache_service.py +++ b/backend/apps/core/services/enhanced_cache_service.py @@ -2,13 +2,15 @@ Enhanced caching service with multiple cache backends and strategies. """ -from typing import Optional, Any, Dict, Callable -from django.core.cache import caches import hashlib import json import logging import time +from collections.abc import Callable from functools import wraps +from typing import Any + +from django.core.cache import caches logger = logging.getLogger(__name__) @@ -64,7 +66,7 @@ class EnhancedCacheService: def cache_api_response( self, view_name: str, - params: Dict, + params: dict, response_data: Any, timeout: int = 1800, ): @@ -73,7 +75,7 @@ class EnhancedCacheService: self.api_cache.set(cache_key, response_data, timeout) logger.debug(f"Cached API response for view '{view_name}'") - def get_cached_api_response(self, view_name: str, params: Dict) -> Optional[Any]: + def get_cached_api_response(self, view_name: str, params: dict) -> Any | None: """Retrieve cached API response""" cache_key = self._generate_api_cache_key(view_name, params) result = self.api_cache.get(cache_key) @@ -103,7 +105,7 @@ class EnhancedCacheService: def get_cached_geographic_data( self, bounds: "GeoBounds", zoom_level: int - ) -> Optional[Any]: + ) -> Any | None: """Retrieve cached geographic data""" cache_key = f"geo:{bounds.min_lat}:{bounds.min_lng}:{bounds.max_lat}:{ bounds.max_lng @@ -129,13 +131,10 @@ class EnhancedCacheService: logger.error(f"Error invalidating cache pattern '{pattern}': {e}") def invalidate_model_cache( - self, model_name: str, instance_id: Optional[int] = None + self, model_name: str, instance_id: int | None = None ): """Invalidate cache keys related to a specific model""" - if instance_id: - pattern = f"*{model_name}:{instance_id}*" - else: - pattern = f"*{model_name}*" + pattern = f"*{model_name}:{instance_id}*" if instance_id else f"*{model_name}*" self.invalidate_pattern(pattern) @@ -155,7 +154,7 @@ class EnhancedCacheService: except Exception as e: logger.error(f"Error warming cache for key '{cache_key}': {e}") - def _generate_api_cache_key(self, view_name: str, params: Dict) -> str: + def _generate_api_cache_key(self, view_name: str, params: dict) -> str: """Generate consistent cache keys for API responses""" # Sort params to ensure consistent key generation params_str = json.dumps(params, sort_keys=True, default=str) @@ -275,7 +274,7 @@ class CacheMonitor: def __init__(self): self.cache_service = EnhancedCacheService() - def get_cache_stats(self) -> Dict[str, Any]: + def get_cache_stats(self) -> dict[str, Any]: """Get cache statistics if available""" stats = {} @@ -319,7 +318,7 @@ class CacheMonitor: if stats: logger.info("Cache performance statistics", extra=stats) - def get_cache_statistics(self, key_prefix: str = "") -> Dict[str, Any]: + def get_cache_statistics(self, key_prefix: str = "") -> dict[str, Any]: """ Get cache statistics for a given key prefix. diff --git a/backend/apps/core/services/entity_fuzzy_matching.py b/backend/apps/core/services/entity_fuzzy_matching.py index acdafacb..607963fa 100644 --- a/backend/apps/core/services/entity_fuzzy_matching.py +++ b/backend/apps/core/services/entity_fuzzy_matching.py @@ -13,16 +13,15 @@ Features: """ import re -from difflib import SequenceMatcher -from typing import List, Dict, Any, Optional from dataclasses import dataclass +from difflib import SequenceMatcher from enum import Enum +from typing import Any from django.db.models import Q -from apps.parks.models import Park +from apps.parks.models import Company, Park from apps.rides.models import Ride -from apps.parks.models import Company class EntityType(Enum): @@ -44,9 +43,9 @@ class FuzzyMatchResult: score: float # 0.0 to 1.0, higher is better match match_reason: str # Description of why this was matched confidence: str # 'high', 'medium', 'low' - url: Optional[str] = None + url: str | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for API responses.""" return { "entity_type": self.entity_type.value, @@ -180,8 +179,8 @@ class EntityFuzzyMatcher: self.algorithms = FuzzyMatchingAlgorithms() def find_entity( - self, query: str, entity_types: Optional[List[EntityType]] = None, user=None - ) -> tuple[List[FuzzyMatchResult], Optional[EntitySuggestion]]: + self, query: str, entity_types: list[EntityType] | None = None, user=None + ) -> tuple[list[FuzzyMatchResult], EntitySuggestion | None]: """ Find entities matching the query with fuzzy matching. @@ -221,7 +220,7 @@ class EntityFuzzyMatcher: def _get_candidates( self, query: str, entity_type: EntityType - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Get potential matching candidates for an entity type.""" candidates = [] @@ -286,8 +285,8 @@ class EntityFuzzyMatcher: return candidates def _score_and_rank_candidates( - self, query: str, candidates: List[Dict[str, Any]] - ) -> List[FuzzyMatchResult]: + self, query: str, candidates: list[dict[str, Any]] + ) -> list[FuzzyMatchResult]: """Score and rank all candidates using multiple algorithms.""" scored_matches = [] @@ -356,7 +355,7 @@ class EntityFuzzyMatcher: return sorted(scored_matches, key=lambda x: x.score, reverse=True) def _generate_entity_suggestion( - self, query: str, entity_types: List[EntityType], user + self, query: str, entity_types: list[EntityType], user ) -> EntitySuggestion: """Generate suggestion for creating new entity when no matches found.""" diff --git a/backend/apps/core/services/location_adapters.py b/backend/apps/core/services/location_adapters.py index 05db2d48..9ce6b433 100644 --- a/backend/apps/core/services/location_adapters.py +++ b/backend/apps/core/services/location_adapters.py @@ -2,36 +2,37 @@ Location adapters for converting between domain-specific models and UnifiedLocation. """ -from typing import List, Optional + from django.db.models import QuerySet from django.urls import reverse -from .data_structures import ( - UnifiedLocation, - LocationType, - GeoBounds, - MapFilters, -) -from apps.parks.models import ParkLocation, CompanyHeadquarters +from apps.parks.models import CompanyHeadquarters, ParkLocation from apps.rides.models import RideLocation +from .data_structures import ( + GeoBounds, + LocationType, + MapFilters, + UnifiedLocation, +) + class BaseLocationAdapter: """Base adapter class for location conversions.""" - def to_unified_location(self, location_obj) -> Optional[UnifiedLocation]: + def to_unified_location(self, location_obj) -> UnifiedLocation | None: """Convert model instance to UnifiedLocation.""" raise NotImplementedError def get_queryset( self, - bounds: Optional[GeoBounds] = None, - filters: Optional[MapFilters] = None, + bounds: GeoBounds | None = None, + filters: MapFilters | None = None, ) -> QuerySet: """Get optimized queryset for this location type.""" raise NotImplementedError - def bulk_convert(self, queryset: QuerySet) -> List[UnifiedLocation]: + def bulk_convert(self, queryset: QuerySet) -> list[UnifiedLocation]: """Convert multiple location objects efficiently.""" unified_locations = [] for obj in queryset: @@ -46,7 +47,7 @@ class ParkLocationAdapter(BaseLocationAdapter): def to_unified_location( self, location_obj: ParkLocation - ) -> Optional[UnifiedLocation]: + ) -> UnifiedLocation | None: """Convert ParkLocation to UnifiedLocation.""" if ( not location_obj.point @@ -106,8 +107,8 @@ class ParkLocationAdapter(BaseLocationAdapter): def get_queryset( self, - bounds: Optional[GeoBounds] = None, - filters: Optional[MapFilters] = None, + bounds: GeoBounds | None = None, + filters: MapFilters | None = None, ) -> QuerySet: """Get optimized queryset for park locations.""" queryset = ParkLocation.objects.select_related("park", "park__operator").filter( @@ -177,7 +178,7 @@ class RideLocationAdapter(BaseLocationAdapter): def to_unified_location( self, location_obj: RideLocation - ) -> Optional[UnifiedLocation]: + ) -> UnifiedLocation | None: """Convert RideLocation to UnifiedLocation.""" if ( not location_obj.point @@ -235,8 +236,8 @@ class RideLocationAdapter(BaseLocationAdapter): def get_queryset( self, - bounds: Optional[GeoBounds] = None, - filters: Optional[MapFilters] = None, + bounds: GeoBounds | None = None, + filters: MapFilters | None = None, ) -> QuerySet: """Get optimized queryset for ride locations.""" queryset = RideLocation.objects.select_related( @@ -293,7 +294,7 @@ class CompanyLocationAdapter(BaseLocationAdapter): def to_unified_location( self, location_obj: CompanyHeadquarters - ) -> Optional[UnifiedLocation]: + ) -> UnifiedLocation | None: """Convert CompanyHeadquarters to UnifiedLocation.""" # Note: CompanyHeadquarters doesn't have coordinates, so we need to geocode # For now, we'll skip companies without coordinates @@ -302,8 +303,8 @@ class CompanyLocationAdapter(BaseLocationAdapter): def get_queryset( self, - bounds: Optional[GeoBounds] = None, - filters: Optional[MapFilters] = None, + bounds: GeoBounds | None = None, + filters: MapFilters | None = None, ) -> QuerySet: """Get optimized queryset for company locations.""" queryset = CompanyHeadquarters.objects.select_related("company") @@ -346,9 +347,9 @@ class LocationAbstractionLayer: def get_all_locations( self, - bounds: Optional[GeoBounds] = None, - filters: Optional[MapFilters] = None, - ) -> List[UnifiedLocation]: + bounds: GeoBounds | None = None, + filters: MapFilters | None = None, + ) -> list[UnifiedLocation]: """Get locations from all sources within bounds.""" all_locations = [] @@ -370,9 +371,9 @@ class LocationAbstractionLayer: def get_locations_by_type( self, location_type: LocationType, - bounds: Optional[GeoBounds] = None, - filters: Optional[MapFilters] = None, - ) -> List[UnifiedLocation]: + bounds: GeoBounds | None = None, + filters: MapFilters | None = None, + ) -> list[UnifiedLocation]: """Get locations of specific type.""" adapter = self.adapters[location_type] queryset = adapter.get_queryset(bounds, filters) @@ -380,7 +381,7 @@ class LocationAbstractionLayer: def get_location_by_id( self, location_type: LocationType, location_id: int - ) -> Optional[UnifiedLocation]: + ) -> UnifiedLocation | None: """Get single location with full details.""" adapter = self.adapters[location_type] diff --git a/backend/apps/core/services/location_search.py b/backend/apps/core/services/location_search.py index 59152bc7..a9f05b29 100644 --- a/backend/apps/core/services/location_search.py +++ b/backend/apps/core/services/location_search.py @@ -6,13 +6,14 @@ to provide proximity-based search, location filtering, and geographic search capabilities. """ +from dataclasses import dataclass +from typing import Any + from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance from django.db.models import Q -from typing import Optional, List, Dict, Any, Set -from dataclasses import dataclass -from apps.parks.models import Park, Company, ParkLocation +from apps.parks.models import Company, Park, ParkLocation from apps.rides.models import Ride @@ -21,22 +22,22 @@ class LocationSearchFilters: """Filters for location-aware search queries.""" # Text search - search_query: Optional[str] = None + search_query: str | None = None # Location-based filters - location_point: Optional[Point] = None - radius_km: Optional[float] = None - location_types: Optional[Set[str]] = None # 'park', 'ride', 'company' + location_point: Point | None = None + radius_km: float | None = None + location_types: set[str] | None = None # 'park', 'ride', 'company' # Geographic filters - country: Optional[str] = None - state: Optional[str] = None - city: Optional[str] = None + country: str | None = None + state: str | None = None + city: str | None = None # Content-specific filters - park_status: Optional[List[str]] = None - ride_types: Optional[List[str]] = None - company_roles: Optional[List[str]] = None + park_status: list[str] | None = None + ride_types: list[str] | None = None + company_roles: list[str] | None = None # Result options include_distance: bool = True @@ -51,26 +52,26 @@ class LocationSearchResult: content_type: str # 'park', 'ride', 'company' object_id: int name: str - description: Optional[str] = None - url: Optional[str] = None + description: str | None = None + url: str | None = None # Location data - latitude: Optional[float] = None - longitude: Optional[float] = None - address: Optional[str] = None - city: Optional[str] = None - state: Optional[str] = None - country: Optional[str] = None + latitude: float | None = None + longitude: float | None = None + address: str | None = None + city: str | None = None + state: str | None = None + country: str | None = None # Distance data (if proximity search) - distance_km: Optional[float] = None + distance_km: float | None = None # Additional metadata - status: Optional[str] = None - tags: Optional[List[str]] = None - rating: Optional[float] = None + status: str | None = None + tags: list[str] | None = None + rating: float | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for JSON serialization.""" return { "content_type": self.content_type, @@ -96,7 +97,7 @@ class LocationSearchResult: class LocationSearchService: """Service for performing location-aware searches across ThrillWiki content.""" - def search(self, filters: LocationSearchFilters) -> List[LocationSearchResult]: + def search(self, filters: LocationSearchFilters) -> list[LocationSearchResult]: """ Perform a comprehensive location-aware search. @@ -129,7 +130,7 @@ class LocationSearchService: def _search_parks( self, filters: LocationSearchFilters - ) -> List[LocationSearchResult]: + ) -> list[LocationSearchResult]: """Search parks with location data.""" queryset = Park.objects.select_related("location", "operator").all() @@ -199,7 +200,7 @@ class LocationSearchService: def _search_rides( self, filters: LocationSearchFilters - ) -> List[LocationSearchResult]: + ) -> list[LocationSearchResult]: """Search rides with location data.""" queryset = Ride.objects.select_related("park", "location").all() @@ -282,7 +283,7 @@ class LocationSearchService: def _search_companies( self, filters: LocationSearchFilters - ) -> List[LocationSearchResult]: + ) -> list[LocationSearchResult]: """Search companies with headquarters location data.""" queryset = Company.objects.select_related("headquarters").all() @@ -398,7 +399,7 @@ class LocationSearchService: return queryset - def suggest_locations(self, query: str, limit: int = 10) -> List[Dict[str, Any]]: + def suggest_locations(self, query: str, limit: int = 10) -> list[dict[str, Any]]: """ Get location suggestions for autocomplete. diff --git a/backend/apps/core/services/map_cache_service.py b/backend/apps/core/services/map_cache_service.py index f43e1d90..61e57e8f 100644 --- a/backend/apps/core/services/map_cache_service.py +++ b/backend/apps/core/services/map_cache_service.py @@ -5,18 +5,18 @@ Caching service for map data to improve performance and reduce database load. import hashlib import json import time -from typing import Dict, List, Optional, Any +from typing import Any from django.core.cache import cache from django.utils import timezone from .data_structures import ( - UnifiedLocation, ClusterData, GeoBounds, MapFilters, MapResponse, QueryPerformanceMetrics, + UnifiedLocation, ) @@ -52,9 +52,9 @@ class MapCacheService: def get_locations_cache_key( self, - bounds: Optional[GeoBounds], - filters: Optional[MapFilters], - zoom_level: Optional[int] = None, + bounds: GeoBounds | None, + filters: MapFilters | None, + zoom_level: int | None = None, ) -> str: """Generate cache key for location queries.""" key_parts = [self.LOCATIONS_PREFIX] @@ -76,8 +76,8 @@ class MapCacheService: def get_clusters_cache_key( self, - bounds: Optional[GeoBounds], - filters: Optional[MapFilters], + bounds: GeoBounds | None, + filters: MapFilters | None, zoom_level: int, ) -> str: """Generate cache key for cluster queries.""" @@ -102,8 +102,8 @@ class MapCacheService: def cache_locations( self, cache_key: str, - locations: List[UnifiedLocation], - ttl: Optional[int] = None, + locations: list[UnifiedLocation], + ttl: int | None = None, ) -> None: """Cache location data.""" try: @@ -122,8 +122,8 @@ class MapCacheService: def cache_clusters( self, cache_key: str, - clusters: List[ClusterData], - ttl: Optional[int] = None, + clusters: list[ClusterData], + ttl: int | None = None, ) -> None: """Cache cluster data.""" try: @@ -138,7 +138,7 @@ class MapCacheService: print(f"Cache write error for clusters {cache_key}: {e}") def cache_map_response( - self, cache_key: str, response: MapResponse, ttl: Optional[int] = None + self, cache_key: str, response: MapResponse, ttl: int | None = None ) -> None: """Cache complete map response.""" try: @@ -149,7 +149,7 @@ class MapCacheService: except Exception as e: print(f"Cache write error for response {cache_key}: {e}") - def get_cached_locations(self, cache_key: str) -> Optional[List[UnifiedLocation]]: + def get_cached_locations(self, cache_key: str) -> list[UnifiedLocation] | None: """Retrieve cached location data.""" try: cache_data = cache.get(cache_key) @@ -172,7 +172,7 @@ class MapCacheService: self.cache_stats["misses"] += 1 return None - def get_cached_clusters(self, cache_key: str) -> Optional[List[ClusterData]]: + def get_cached_clusters(self, cache_key: str) -> list[ClusterData] | None: """Retrieve cached cluster data.""" try: cache_data = cache.get(cache_key) @@ -194,7 +194,7 @@ class MapCacheService: self.cache_stats["misses"] += 1 return None - def get_cached_map_response(self, cache_key: str) -> Optional[MapResponse]: + def get_cached_map_response(self, cache_key: str) -> MapResponse | None: """Retrieve cached map response.""" try: cache_data = cache.get(cache_key) @@ -213,7 +213,7 @@ class MapCacheService: return None def invalidate_location_cache( - self, location_type: str, location_id: Optional[int] = None + self, location_type: str, location_id: int | None = None ) -> None: """Invalidate cache for specific location or all locations of a type.""" try: @@ -268,7 +268,7 @@ class MapCacheService: except Exception as e: print(f"Cache clear error: {e}") - def get_cache_stats(self) -> Dict[str, Any]: + def get_cache_stats(self) -> dict[str, Any]: """Get cache performance statistics.""" total_requests = self.cache_stats["hits"] + self.cache_stats["misses"] hit_rate = ( @@ -370,7 +370,7 @@ class MapCacheService: filter_str = json.dumps(filter_dict, sort_keys=True) return hashlib.md5(filter_str.encode()).hexdigest()[:8] - def _dict_to_unified_location(self, data: Dict[str, Any]) -> UnifiedLocation: + def _dict_to_unified_location(self, data: dict[str, Any]) -> UnifiedLocation: """Convert dictionary back to UnifiedLocation object.""" from .data_structures import LocationType @@ -386,7 +386,7 @@ class MapCacheService: cluster_category=data.get("cluster_category", "default"), ) - def _dict_to_cluster_data(self, data: Dict[str, Any]) -> ClusterData: + def _dict_to_cluster_data(self, data: dict[str, Any]) -> ClusterData: """Convert dictionary back to ClusterData object.""" from .data_structures import LocationType @@ -406,7 +406,7 @@ class MapCacheService: representative_location=representative, ) - def _dict_to_map_response(self, data: Dict[str, Any]) -> MapResponse: + def _dict_to_map_response(self, data: dict[str, Any]) -> MapResponse: """Convert dictionary back to MapResponse object.""" locations = [ self._dict_to_unified_location(loc) for loc in data.get("locations", []) diff --git a/backend/apps/core/services/map_service.py b/backend/apps/core/services/map_service.py index 6e07678a..50cbf473 100644 --- a/backend/apps/core/services/map_service.py +++ b/backend/apps/core/services/map_service.py @@ -3,20 +3,21 @@ Unified Map Service - Main orchestrating service for all map functionality. """ import time -from typing import List, Optional, Dict, Any, Set +from typing import Any + from django.db import connection +from .clustering_service import ClusteringService from .data_structures import ( - UnifiedLocation, ClusterData, GeoBounds, + LocationType, MapFilters, MapResponse, - LocationType, QueryPerformanceMetrics, + UnifiedLocation, ) from .location_adapters import LocationAbstractionLayer -from .clustering_service import ClusteringService from .map_cache_service import MapCacheService @@ -39,8 +40,8 @@ class UnifiedMapService: def get_map_data( self, *, - bounds: Optional[GeoBounds] = None, - filters: Optional[MapFilters] = None, + bounds: GeoBounds | None = None, + filters: MapFilters | None = None, zoom_level: int = DEFAULT_ZOOM_LEVEL, cluster: bool = True, use_cache: bool = True, @@ -145,7 +146,7 @@ class UnifiedMapService: def get_location_details( self, location_type: str, location_id: int - ) -> Optional[UnifiedLocation]: + ) -> UnifiedLocation | None: """ Get detailed information for a specific location. @@ -188,10 +189,10 @@ class UnifiedMapService: def search_locations( self, query: str, - bounds: Optional[GeoBounds] = None, - location_types: Optional[Set[LocationType]] = None, + bounds: GeoBounds | None = None, + location_types: set[LocationType] | None = None, limit: int = 50, - ) -> List[UnifiedLocation]: + ) -> list[UnifiedLocation]: """ Search locations with text query. @@ -228,7 +229,7 @@ class UnifiedMapService: south: float, east: float, west: float, - location_types: Optional[Set[LocationType]] = None, + location_types: set[LocationType] | None = None, zoom_level: int = DEFAULT_ZOOM_LEVEL, ) -> MapResponse: """ @@ -261,8 +262,8 @@ class UnifiedMapService: def get_clustered_locations( self, zoom_level: int, - bounds: Optional[GeoBounds] = None, - filters: Optional[MapFilters] = None, + bounds: GeoBounds | None = None, + filters: MapFilters | None = None, ) -> MapResponse: """ Get clustered location data for map display. @@ -282,9 +283,9 @@ class UnifiedMapService: def get_locations_by_type( self, location_type: LocationType, - bounds: Optional[GeoBounds] = None, - limit: Optional[int] = None, - ) -> List[UnifiedLocation]: + bounds: GeoBounds | None = None, + limit: int | None = None, + ) -> list[UnifiedLocation]: """ Get locations of a specific type. @@ -313,9 +314,9 @@ class UnifiedMapService: def invalidate_cache( self, - location_type: Optional[str] = None, - location_id: Optional[int] = None, - bounds: Optional[GeoBounds] = None, + location_type: str | None = None, + location_id: int | None = None, + bounds: GeoBounds | None = None, ) -> None: """ Invalidate cached map data. @@ -332,7 +333,7 @@ class UnifiedMapService: else: self.cache_service.clear_all_map_cache() - def get_service_stats(self) -> Dict[str, Any]: + def get_service_stats(self) -> dict[str, Any]: """Get service performance and usage statistics.""" cache_stats = self.cache_service.get_cache_stats() @@ -346,17 +347,17 @@ class UnifiedMapService: } def _get_locations_from_db( - self, bounds: Optional[GeoBounds], filters: Optional[MapFilters] - ) -> List[UnifiedLocation]: + self, bounds: GeoBounds | None, filters: MapFilters | None + ) -> list[UnifiedLocation]: """Get locations from database using the abstraction layer.""" return self.location_layer.get_all_locations(bounds, filters) def _apply_smart_limiting( self, - locations: List[UnifiedLocation], - bounds: Optional[GeoBounds], + locations: list[UnifiedLocation], + bounds: GeoBounds | None, zoom_level: int, - ) -> List[UnifiedLocation]: + ) -> list[UnifiedLocation]: """Apply intelligent limiting based on zoom level and density.""" if zoom_level < 6: # Very zoomed out - show only major parks major_parks = [ @@ -375,10 +376,10 @@ class UnifiedMapService: def _calculate_response_bounds( self, - locations: List[UnifiedLocation], - clusters: List[ClusterData], - request_bounds: Optional[GeoBounds], - ) -> Optional[GeoBounds]: + locations: list[UnifiedLocation], + clusters: list[ClusterData], + request_bounds: GeoBounds | None, + ) -> GeoBounds | None: """Calculate the actual bounds of the response data.""" if request_bounds: return request_bounds @@ -396,12 +397,12 @@ class UnifiedMapService: if not all_coords: return None - lats, lngs = zip(*all_coords) + lats, lngs = zip(*all_coords, strict=False) return GeoBounds( north=max(lats), south=min(lats), east=max(lngs), west=min(lngs) ) - def _get_applied_filters_list(self, filters: Optional[MapFilters]) -> List[str]: + def _get_applied_filters_list(self, filters: MapFilters | None) -> list[str]: """Get list of applied filter types for metadata.""" if not filters: return [] @@ -430,8 +431,8 @@ class UnifiedMapService: def _generate_cache_key( self, - bounds: Optional[GeoBounds], - filters: Optional[MapFilters], + bounds: GeoBounds | None, + filters: MapFilters | None, zoom_level: int, cluster: bool, ) -> str: diff --git a/backend/apps/core/services/media_service.py b/backend/apps/core/services/media_service.py index c6bb5d5e..8b198468 100644 --- a/backend/apps/core/services/media_service.py +++ b/backend/apps/core/services/media_service.py @@ -6,12 +6,13 @@ that can be used across all domain-specific media implementations. """ import logging -from typing import Any, Optional, Dict -from datetime import datetime -from django.core.files.uploadedfile import UploadedFile -from django.conf import settings -from PIL import Image, ExifTags import os +from datetime import datetime +from typing import Any + +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile +from PIL import ExifTags, Image logger = logging.getLogger(__name__) @@ -21,7 +22,7 @@ class MediaService: @staticmethod def generate_upload_path( - domain: str, identifier: str, filename: str, subdirectory: Optional[str] = None + domain: str, identifier: str, filename: str, subdirectory: str | None = None ) -> str: """ Generate standardized upload path for media files. @@ -44,7 +45,7 @@ class MediaService: return f"{domain}/{identifier}/{base_filename}" @staticmethod - def extract_exif_date(image_file: UploadedFile) -> Optional[datetime]: + def extract_exif_date(image_file: UploadedFile) -> datetime | None: """ Extract the date taken from image EXIF data. @@ -60,18 +61,17 @@ class MediaService: if exif: # Find the DateTime tag ID for tag_id in ExifTags.TAGS: - if ExifTags.TAGS[tag_id] == "DateTimeOriginal": - if tag_id in exif: - # EXIF dates are typically in format: '2024:02:15 14:30:00' - date_str = exif[tag_id] - return datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S") + if ExifTags.TAGS[tag_id] == "DateTimeOriginal" and tag_id in exif: + # EXIF dates are typically in format: '2024:02:15 14:30:00' + date_str = exif[tag_id] + return datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S") return None except Exception as e: logger.warning(f"Failed to extract EXIF date: {str(e)}") return None @staticmethod - def validate_image_file(image_file: UploadedFile) -> tuple[bool, Optional[str]]: + def validate_image_file(image_file: UploadedFile) -> tuple[bool, str | None]: """ Validate uploaded image file. @@ -144,6 +144,7 @@ class MediaService: # Save processed image from io import BytesIO + from django.core.files.uploadedfile import InMemoryUploadedFile output = BytesIO() @@ -180,7 +181,7 @@ class MediaService: return f"Uploaded by {username} on {current_time.strftime('%B %d, %Y at %I:%M %p')}" @staticmethod - def get_storage_stats() -> Dict[str, Any]: + def get_storage_stats() -> dict[str, Any]: """ Get media storage statistics. diff --git a/backend/apps/core/services/media_url_service.py b/backend/apps/core/services/media_url_service.py index c239fad6..6c8b0c75 100644 --- a/backend/apps/core/services/media_url_service.py +++ b/backend/apps/core/services/media_url_service.py @@ -6,7 +6,8 @@ while maintaining compatibility with Cloudflare Images. """ import re -from typing import Optional, Dict, Any +from typing import Any + from django.utils.text import slugify @@ -83,7 +84,7 @@ class MediaURLService: return f"/parks/{park_slug}/rides/{ride_slug}/photos/{filename}" @staticmethod - def parse_photo_filename(filename: str) -> Optional[Dict[str, Any]]: + def parse_photo_filename(filename: str) -> dict[str, Any] | None: """ Parse a friendly filename to extract photo ID and variant. @@ -118,7 +119,7 @@ class MediaURLService: return None @staticmethod - def get_cloudflare_url_with_fallback(cloudflare_image, variant: str = "public") -> Optional[str]: + def get_cloudflare_url_with_fallback(cloudflare_image, variant: str = "public") -> str | None: """ Get Cloudflare URL with fallback handling. diff --git a/backend/apps/core/services/performance_monitoring.py b/backend/apps/core/services/performance_monitoring.py index 6c5500dd..0a390b92 100644 --- a/backend/apps/core/services/performance_monitoring.py +++ b/backend/apps/core/services/performance_monitoring.py @@ -2,13 +2,14 @@ Performance monitoring utilities and context managers. """ -import time import logging +import time from contextlib import contextmanager from functools import wraps -from typing import Optional, Dict, Any, List -from django.db import connection +from typing import Any + from django.conf import settings +from django.db import connection from django.utils import timezone logger = logging.getLogger("performance") @@ -271,7 +272,7 @@ class DatabaseQueryAnalyzer: """Analyze database query patterns and performance""" @staticmethod - def analyze_queries(queries: List[Dict]) -> Dict[str, Any]: + def analyze_queries(queries: list[dict]) -> dict[str, Any]: """Analyze a list of queries for patterns and issues""" if not queries: return {} @@ -332,7 +333,7 @@ class DatabaseQueryAnalyzer: return analysis @classmethod - def analyze_current_queries(cls) -> Dict[str, Any]: + def analyze_current_queries(cls) -> dict[str, Any]: """Analyze the current request's queries""" if hasattr(connection, "queries"): return cls.analyze_queries(connection.queries) @@ -340,7 +341,7 @@ class DatabaseQueryAnalyzer: # Performance monitoring decorators -def monitor_function_performance(operation_name: Optional[str] = None): +def monitor_function_performance(operation_name: str | None = None): """Decorator to monitor function performance""" def decorator(func): @@ -379,7 +380,7 @@ class PerformanceMetrics: def __init__(self): self.metrics = [] - def record_metric(self, name: str, value: float, tags: Optional[Dict] = None): + def record_metric(self, name: str, value: float, tags: dict | None = None): """Record a performance metric""" metric = { "name": name, @@ -392,7 +393,7 @@ class PerformanceMetrics: # Log the metric logger.info(f"Performance metric: {name} = {value}", extra=metric) - def get_metrics(self, name: Optional[str] = None) -> List[Dict]: + def get_metrics(self, name: str | None = None) -> list[dict]: """Get recorded metrics, optionally filtered by name""" if name: return [m for m in self.metrics if m["name"] == name] diff --git a/backend/apps/core/services/trending_service.py b/backend/apps/core/services/trending_service.py index 0a92e42f..6607a2bd 100644 --- a/backend/apps/core/services/trending_service.py +++ b/backend/apps/core/services/trending_service.py @@ -12,11 +12,12 @@ Results are cached in Redis for performance optimization. import logging from datetime import datetime, timedelta -from typing import Dict, List, Any -from django.utils import timezone +from typing import Any + from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db.models import Q +from django.utils import timezone from apps.core.analytics import PageView from apps.parks.models import Park @@ -56,7 +57,7 @@ class TrendingService: def get_trending_content( self, content_type: str = "all", limit: int = 20, force_refresh: bool = False - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Get trending content using direct calculation. @@ -121,7 +122,7 @@ class TrendingService: limit: int = 20, days_back: int = 30, force_refresh: bool = False, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Get recently added content using direct calculation. @@ -182,7 +183,7 @@ class TrendingService: self.logger.error(f"Error getting new content: {e}", exc_info=True) return [] - def _calculate_trending_parks(self, limit: int) -> List[Dict[str, Any]]: + def _calculate_trending_parks(self, limit: int) -> list[dict[str, Any]]: """Calculate trending scores for parks.""" parks = Park.objects.filter(status="OPERATING").select_related( "location", "operator", "card_image" @@ -253,7 +254,7 @@ class TrendingService: return trending_parks - def _calculate_trending_rides(self, limit: int) -> List[Dict[str, Any]]: + def _calculate_trending_rides(self, limit: int) -> list[dict[str, Any]]: """Calculate trending scores for rides.""" rides = Ride.objects.filter(status="OPERATING").select_related( "park", "park__location", "card_image" @@ -456,7 +457,7 @@ class TrendingService: self.logger.warning(f"Error calculating popularity score: {e}") return 0.0 - def _get_new_parks(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + def _get_new_parks(self, cutoff_date: datetime, limit: int) -> list[dict[str, Any]]: """Get recently added parks.""" new_parks = ( Park.objects.filter( @@ -528,7 +529,7 @@ class TrendingService: return results - def _get_new_rides(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + def _get_new_rides(self, cutoff_date: datetime, limit: int) -> list[dict[str, Any]]: """Get recently added rides.""" new_rides = ( Ride.objects.filter( @@ -584,8 +585,8 @@ class TrendingService: return results def _format_trending_results( - self, trending_items: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: + self, trending_items: list[dict[str, Any]] + ) -> list[dict[str, Any]]: """Format trending results for frontend consumption.""" formatted_results = [] @@ -649,8 +650,8 @@ class TrendingService: return formatted_results def _format_new_content_results( - self, new_items: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: + self, new_items: list[dict[str, Any]] + ) -> list[dict[str, Any]]: """Format new content results for frontend consumption.""" formatted_results = [] diff --git a/backend/apps/core/state_machine/__init__.py b/backend/apps/core/state_machine/__init__.py index 52a8e676..e11bc327 100644 --- a/backend/apps/core/state_machine/__init__.py +++ b/backend/apps/core/state_machine/__init__.py @@ -1,106 +1,106 @@ """State machine utilities for core app.""" -from .fields import RichFSMField -from .mixins import StateMachineMixin from .builder import ( StateTransitionBuilder, determine_method_name_for_transition, ) -from .decorators import ( - generate_transition_decorator, - TransitionMethodFactory, - with_callbacks, - register_method_callbacks, -) -from .registry import ( - TransitionRegistry, - TransitionInfo, - registry_instance, - register_callback, - register_notification_callback, - register_cache_invalidation, - register_related_update, - register_transition_callbacks, - discover_and_register_callbacks, -) from .callback_base import ( BaseTransitionCallback, - PreTransitionCallback, - PostTransitionCallback, - ErrorTransitionCallback, - TransitionContext, - TransitionCallbackRegistry, - callback_registry, CallbackStage, -) -from .signals import ( - pre_state_transition, - post_state_transition, - state_transition_failed, - register_transition_handler, - on_transition, - on_pre_transition, - on_post_transition, - on_transition_error, + ErrorTransitionCallback, + PostTransitionCallback, + PreTransitionCallback, + TransitionCallbackRegistry, + TransitionContext, + callback_registry, ) from .config import ( CallbackConfig, callback_config, get_callback_config, ) -from .monitoring import ( - CallbackMonitor, - callback_monitor, - TimedCallbackExecution, -) -from .validators import MetadataValidator, ValidationResult -from .guards import ( - # Role constants - VALID_ROLES, - MODERATOR_ROLES, - ADMIN_ROLES, - SUPERUSER_ROLES, - ESCALATION_LEVEL_ROLES, - # Guard classes - PermissionGuard, - OwnershipGuard, - AssignmentGuard, - StateGuard, - MetadataGuard, - CompositeGuard, - # Guard extraction and creation - extract_guards_from_metadata, - create_permission_guard, - create_ownership_guard, - create_assignment_guard, - create_composite_guard, - validate_guard_metadata, - # Registry - GuardRegistry, - guard_registry, - # Role checking functions - get_user_role, - has_role, - is_moderator_or_above, - is_admin_or_above, - is_superuser_role, - has_permission, +from .decorators import ( + TransitionMethodFactory, + generate_transition_decorator, + register_method_callbacks, + with_callbacks, ) from .exceptions import ( + ERROR_MESSAGES, + TransitionNotAvailable, TransitionPermissionDenied, TransitionValidationError, - TransitionNotAvailable, - ERROR_MESSAGES, + format_transition_error, get_permission_error_message, get_state_error_message, - format_transition_error, raise_permission_denied, raise_validation_error, ) +from .fields import RichFSMField +from .guards import ( + ADMIN_ROLES, + ESCALATION_LEVEL_ROLES, + MODERATOR_ROLES, + SUPERUSER_ROLES, + # Role constants + VALID_ROLES, + AssignmentGuard, + CompositeGuard, + # Registry + GuardRegistry, + MetadataGuard, + OwnershipGuard, + # Guard classes + PermissionGuard, + StateGuard, + create_assignment_guard, + create_composite_guard, + create_ownership_guard, + create_permission_guard, + # Guard extraction and creation + extract_guards_from_metadata, + # Role checking functions + get_user_role, + guard_registry, + has_permission, + has_role, + is_admin_or_above, + is_moderator_or_above, + is_superuser_role, + validate_guard_metadata, +) from .integration import ( - apply_state_machine, StateMachineModelMixin, + apply_state_machine, state_machine_model, ) +from .mixins import StateMachineMixin +from .monitoring import ( + CallbackMonitor, + TimedCallbackExecution, + callback_monitor, +) +from .registry import ( + TransitionInfo, + TransitionRegistry, + discover_and_register_callbacks, + register_cache_invalidation, + register_callback, + register_notification_callback, + register_related_update, + register_transition_callbacks, + registry_instance, +) +from .signals import ( + on_post_transition, + on_pre_transition, + on_transition, + on_transition_error, + post_state_transition, + pre_state_transition, + register_transition_handler, + state_transition_failed, +) +from .validators import MetadataValidator, ValidationResult __all__ = [ # Fields and mixins diff --git a/backend/apps/core/state_machine/builder.py b/backend/apps/core/state_machine/builder.py index 76f247b5..0fc8a714 100644 --- a/backend/apps/core/state_machine/builder.py +++ b/backend/apps/core/state_machine/builder.py @@ -60,11 +60,12 @@ See Also: - apps.core.choices.registry: Central choice registry - apps.core.state_machine.guards: Guard extraction from metadata """ -from typing import Dict, List, Optional, Any +from typing import Any + from django.core.exceptions import ImproperlyConfigured -from apps.core.choices.registry import registry from apps.core.choices.base import RichChoice +from apps.core.choices.registry import registry class StateTransitionBuilder: @@ -123,7 +124,7 @@ class StateTransitionBuilder: """ self.choice_group = choice_group self.domain = domain - self._cache: Dict[str, Any] = {} + self._cache: dict[str, Any] = {} # Validate choice group exists group = registry.get(choice_group, domain) @@ -134,7 +135,7 @@ class StateTransitionBuilder: self.choices = registry.get_choices(choice_group, domain) - def get_choice_metadata(self, state_value: str) -> Dict[str, Any]: + def get_choice_metadata(self, state_value: str) -> dict[str, Any]: """ Retrieve metadata for a specific state. @@ -156,7 +157,7 @@ class StateTransitionBuilder: self._cache[cache_key] = metadata return metadata - def extract_valid_transitions(self, state_value: str) -> List[str]: + def extract_valid_transitions(self, state_value: str) -> list[str]: """ Get can_transition_to list from metadata. @@ -184,7 +185,7 @@ class StateTransitionBuilder: def extract_permission_requirements( self, state_value: str - ) -> Dict[str, bool]: + ) -> dict[str, bool]: """ Extract permission requirements from metadata. @@ -228,7 +229,7 @@ class StateTransitionBuilder: metadata = self.get_choice_metadata(state_value) return metadata.get("is_actionable", False) - def build_transition_graph(self) -> Dict[str, List[str]]: + def build_transition_graph(self) -> dict[str, list[str]]: """ Create a complete state transition graph. @@ -247,7 +248,7 @@ class StateTransitionBuilder: self._cache[cache_key] = graph return graph - def get_all_states(self) -> List[str]: + def get_all_states(self) -> list[str]: """ Get all state values in the choice group. @@ -256,7 +257,7 @@ class StateTransitionBuilder: """ return [choice.value for choice in self.choices] - def get_choice(self, state_value: str) -> Optional[RichChoice]: + def get_choice(self, state_value: str) -> RichChoice | None: """ Get the RichChoice object for a state. @@ -276,7 +277,7 @@ class StateTransitionBuilder: def determine_method_name_for_transition(source: str, target: str) -> str: """ Determine appropriate method name for a transition. - + Always uses transition_to_ pattern to avoid conflicts with business logic methods (approve, reject, escalate, etc.). diff --git a/backend/apps/core/state_machine/callback_base.py b/backend/apps/core/state_machine/callback_base.py index 82c20a17..6f5db649 100644 --- a/backend/apps/core/state_machine/callback_base.py +++ b/backend/apps/core/state_machine/callback_base.py @@ -66,16 +66,15 @@ See Also: - apps.core.state_machine.callbacks.related_updates: Related model callbacks """ +import logging from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union -import logging +from typing import Any, Optional from django.db import models - logger = logging.getLogger(__name__) @@ -167,12 +166,12 @@ class TransitionContext: field_name: str source_state: str target_state: str - user: Optional[Any] = None + user: Any | None = None timestamp: datetime = field(default_factory=datetime.now) - extra_data: Dict[str, Any] = field(default_factory=dict) + extra_data: dict[str, Any] = field(default_factory=dict) @property - def model_class(self) -> Type[models.Model]: + def model_class(self) -> type[models.Model]: """Get the model class of the instance.""" return type(self.instance) @@ -206,9 +205,9 @@ class BaseTransitionCallback(ABC): def __init__( self, - priority: Optional[int] = None, - continue_on_error: Optional[bool] = None, - name: Optional[str] = None, + priority: int | None = None, + continue_on_error: bool | None = None, + name: str | None = None, ): if priority is not None: self.priority = priority @@ -288,7 +287,7 @@ class ErrorTransitionCallback(BaseTransitionCallback): # Error callbacks should always continue continue_on_error: bool = True - def execute(self, context: TransitionContext, exception: Optional[Exception] = None) -> bool: + def execute(self, context: TransitionContext, exception: Exception | None = None) -> bool: """ Execute the error callback. @@ -307,7 +306,7 @@ class CallbackRegistration: """Represents a registered callback with its configuration.""" callback: BaseTransitionCallback - model_class: Type[models.Model] + model_class: type[models.Model] field_name: str source: str # Can be '*' for wildcard target: str # Can be '*' for wildcard @@ -315,7 +314,7 @@ class CallbackRegistration: def matches( self, - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, @@ -327,9 +326,7 @@ class CallbackRegistration: return False if self.source != '*' and self.source != source: return False - if self.target != '*' and self.target != target: - return False - return True + return not (self.target != '*' and self.target != target) class TransitionCallbackRegistry: @@ -351,7 +348,7 @@ class TransitionCallbackRegistry: def __init__(self): if self._initialized: return - self._callbacks: Dict[CallbackStage, List[CallbackRegistration]] = { + self._callbacks: dict[CallbackStage, list[CallbackRegistration]] = { CallbackStage.PRE: [], CallbackStage.POST: [], CallbackStage.ERROR: [], @@ -360,12 +357,12 @@ class TransitionCallbackRegistry: def register( self, - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, callback: BaseTransitionCallback, - stage: Union[CallbackStage, str] = CallbackStage.POST, + stage: CallbackStage | str = CallbackStage.POST, ) -> None: """ Register a callback for a specific transition. @@ -402,10 +399,10 @@ class TransitionCallbackRegistry: def register_bulk( self, - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, - callbacks_config: Dict[Tuple[str, str], List[BaseTransitionCallback]], - stage: Union[CallbackStage, str] = CallbackStage.POST, + callbacks_config: dict[tuple[str, str], list[BaseTransitionCallback]], + stage: CallbackStage | str = CallbackStage.POST, ) -> None: """ Register multiple callbacks for multiple transitions. @@ -422,12 +419,12 @@ class TransitionCallbackRegistry: def get_callbacks( self, - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, - stage: Union[CallbackStage, str] = CallbackStage.POST, - ) -> List[BaseTransitionCallback]: + stage: CallbackStage | str = CallbackStage.POST, + ) -> list[BaseTransitionCallback]: """ Get all callbacks matching the given transition. @@ -454,9 +451,9 @@ class TransitionCallbackRegistry: def execute_callbacks( self, context: TransitionContext, - stage: Union[CallbackStage, str] = CallbackStage.POST, - exception: Optional[Exception] = None, - ) -> Tuple[bool, List[Tuple[BaseTransitionCallback, Optional[Exception]]]]: + stage: CallbackStage | str = CallbackStage.POST, + exception: Exception | None = None, + ) -> tuple[bool, list[tuple[BaseTransitionCallback, Exception | None]]]: """ Execute all callbacks for a transition. @@ -479,7 +476,7 @@ class TransitionCallbackRegistry: stage, ) - failures: List[Tuple[BaseTransitionCallback, Optional[Exception]]] = [] + failures: list[tuple[BaseTransitionCallback, Exception | None]] = [] overall_success = True for callback in callbacks: @@ -530,7 +527,7 @@ class TransitionCallbackRegistry: return overall_success, failures - def clear(self, model_class: Optional[Type[models.Model]] = None) -> None: + def clear(self, model_class: type[models.Model] | None = None) -> None: """ Clear registered callbacks. @@ -550,8 +547,8 @@ class TransitionCallbackRegistry: def get_all_registrations( self, - model_class: Optional[Type[models.Model]] = None, - ) -> Dict[CallbackStage, List[CallbackRegistration]]: + model_class: type[models.Model] | None = None, + ) -> dict[CallbackStage, list[CallbackRegistration]]: """ Get all registered callbacks, optionally filtered by model class. @@ -585,19 +582,19 @@ callback_registry = TransitionCallbackRegistry() # Convenience functions for common operations def register_callback( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, callback: BaseTransitionCallback, - stage: Union[CallbackStage, str] = CallbackStage.POST, + stage: CallbackStage | str = CallbackStage.POST, ) -> None: """Convenience function to register a callback.""" callback_registry.register(model_class, field_name, source, target, callback, stage) def register_pre_callback( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, @@ -610,7 +607,7 @@ def register_pre_callback( def register_post_callback( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, @@ -623,7 +620,7 @@ def register_post_callback( def register_error_callback( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, diff --git a/backend/apps/core/state_machine/callbacks/__init__.py b/backend/apps/core/state_machine/callbacks/__init__.py index 85565067..b27f5d8f 100644 --- a/backend/apps/core/state_machine/callbacks/__init__.py +++ b/backend/apps/core/state_machine/callbacks/__init__.py @@ -5,29 +5,28 @@ This package provides specialized callback implementations for FSM state transitions. """ -from .notifications import ( - NotificationCallback, - SubmissionApprovedNotification, - SubmissionRejectedNotification, - SubmissionEscalatedNotification, - StatusChangeNotification, - ModerationNotificationCallback, -) from .cache import ( + APICacheInvalidation, CacheInvalidationCallback, ModelCacheInvalidation, - RelatedModelCacheInvalidation, PatternCacheInvalidation, - APICacheInvalidation, + RelatedModelCacheInvalidation, +) +from .notifications import ( + ModerationNotificationCallback, + NotificationCallback, + StatusChangeNotification, + SubmissionApprovedNotification, + SubmissionEscalatedNotification, + SubmissionRejectedNotification, ) from .related_updates import ( - RelatedModelUpdateCallback, - ParkCountUpdateCallback, - SearchTextUpdateCallback, ComputedFieldUpdateCallback, + ParkCountUpdateCallback, + RelatedModelUpdateCallback, + SearchTextUpdateCallback, ) - __all__ = [ # Notification callbacks "NotificationCallback", diff --git a/backend/apps/core/state_machine/callbacks/cache.py b/backend/apps/core/state_machine/callbacks/cache.py index 15657d5b..72958803 100644 --- a/backend/apps/core/state_machine/callbacks/cache.py +++ b/backend/apps/core/state_machine/callbacks/cache.py @@ -5,15 +5,12 @@ This module provides callback implementations that invalidate cache entries when state transitions occur. """ -from typing import Any, Dict, List, Optional, Set, Type import logging from django.conf import settings -from django.db import models from ..callback_base import PostTransitionCallback, TransitionContext - logger = logging.getLogger(__name__) @@ -29,7 +26,7 @@ class CacheInvalidationCallback(PostTransitionCallback): def __init__( self, - patterns: Optional[List[str]] = None, + patterns: list[str] | None = None, include_instance_patterns: bool = True, **kwargs, ): @@ -62,7 +59,7 @@ class CacheInvalidationCallback(PostTransitionCallback): logger.warning("EnhancedCacheService not available") return None - def _get_instance_patterns(self, context: TransitionContext) -> List[str]: + def _get_instance_patterns(self, context: TransitionContext) -> list[str]: """Generate cache key patterns specific to the instance.""" patterns = [] model_name = context.model_name.lower() @@ -75,7 +72,7 @@ class CacheInvalidationCallback(PostTransitionCallback): return patterns - def _get_all_patterns(self, context: TransitionContext) -> Set[str]: + def _get_all_patterns(self, context: TransitionContext) -> set[str]: """Get all patterns to invalidate, including generated ones.""" all_patterns = set(self.patterns) @@ -130,7 +127,6 @@ class CacheInvalidationCallback(PostTransitionCallback): def _fallback_invalidation(self, context: TransitionContext) -> bool: """Fallback cache invalidation using Django's cache framework.""" try: - from django.core.cache import cache patterns = self._get_all_patterns(context) @@ -171,7 +167,7 @@ class ModelCacheInvalidation(CacheInvalidationCallback): def __init__(self, **kwargs): super().__init__(**kwargs) - def _get_instance_patterns(self, context: TransitionContext) -> List[str]: + def _get_instance_patterns(self, context: TransitionContext) -> list[str]: """Get model-specific patterns.""" base_patterns = super()._get_instance_patterns(context) @@ -198,7 +194,7 @@ class RelatedModelCacheInvalidation(CacheInvalidationCallback): def __init__( self, - related_fields: Optional[List[str]] = None, + related_fields: list[str] | None = None, **kwargs, ): """ @@ -211,7 +207,7 @@ class RelatedModelCacheInvalidation(CacheInvalidationCallback): super().__init__(**kwargs) self.related_fields = related_fields or [] - def _get_related_patterns(self, context: TransitionContext) -> List[str]: + def _get_related_patterns(self, context: TransitionContext) -> list[str]: """Get cache patterns for related models.""" patterns = [] @@ -236,7 +232,7 @@ class RelatedModelCacheInvalidation(CacheInvalidationCallback): return patterns - def _get_all_patterns(self, context: TransitionContext) -> Set[str]: + def _get_all_patterns(self, context: TransitionContext) -> set[str]: """Get all patterns including related model patterns.""" patterns = super()._get_all_patterns(context) patterns.update(self._get_related_patterns(context)) @@ -254,7 +250,7 @@ class PatternCacheInvalidation(CacheInvalidationCallback): def __init__( self, - patterns: List[str], + patterns: list[str], include_instance_patterns: bool = False, **kwargs, ): @@ -284,7 +280,7 @@ class APICacheInvalidation(CacheInvalidationCallback): def __init__( self, - api_prefixes: Optional[List[str]] = None, + api_prefixes: list[str] | None = None, include_geo_cache: bool = False, **kwargs, ): @@ -300,7 +296,7 @@ class APICacheInvalidation(CacheInvalidationCallback): self.api_prefixes = api_prefixes or ['api:*'] self.include_geo_cache = include_geo_cache - def _get_all_patterns(self, context: TransitionContext) -> Set[str]: + def _get_all_patterns(self, context: TransitionContext) -> set[str]: """Get API-specific cache patterns.""" patterns = set() @@ -358,7 +354,7 @@ class RideCacheInvalidation(CacheInvalidationCallback): **kwargs, ) - def _get_instance_patterns(self, context: TransitionContext) -> List[str]: + def _get_instance_patterns(self, context: TransitionContext) -> list[str]: """Include parent park cache patterns.""" patterns = super()._get_instance_patterns(context) diff --git a/backend/apps/core/state_machine/callbacks/notifications.py b/backend/apps/core/state_machine/callbacks/notifications.py index a7a20824..0a5e47a2 100644 --- a/backend/apps/core/state_machine/callbacks/notifications.py +++ b/backend/apps/core/state_machine/callbacks/notifications.py @@ -5,15 +5,14 @@ This module provides callback implementations that send notifications when state transitions occur. """ -from typing import Any, Dict, List, Optional, Type import logging +from typing import Any from django.conf import settings from django.db import models from ..callback_base import PostTransitionCallback, TransitionContext - logger = logging.getLogger(__name__) @@ -31,7 +30,7 @@ class NotificationCallback(PostTransitionCallback): self, notification_type: str, recipient_field: str = "submitted_by", - template_name: Optional[str] = None, + template_name: str | None = None, include_transition_data: bool = True, **kwargs, ): @@ -69,7 +68,7 @@ class NotificationCallback(PostTransitionCallback): return True - def _get_recipient(self, instance: models.Model) -> Optional[Any]: + def _get_recipient(self, instance: models.Model) -> Any | None: """Get the notification recipient from the instance.""" return getattr(instance, self.recipient_field, None) @@ -82,7 +81,7 @@ class NotificationCallback(PostTransitionCallback): logger.warning("NotificationService not available") return None - def _build_extra_data(self, context: TransitionContext) -> Dict[str, Any]: + def _build_extra_data(self, context: TransitionContext) -> dict[str, Any]: """Build extra data for the notification.""" extra_data = {} @@ -401,7 +400,7 @@ class StatusChangeNotification(NotificationCallback): def __init__( self, - significant_states: Optional[List[str]] = None, + significant_states: list[str] | None = None, notify_admins: bool = True, **kwargs, ): @@ -429,10 +428,7 @@ class StatusChangeNotification(NotificationCallback): return False # Only notify for significant status changes - if context.target_state not in self.significant_states: - return False - - return True + return context.target_state in self.significant_states def execute(self, context: TransitionContext) -> bool: """Execute the status change notification.""" @@ -518,12 +514,12 @@ class ModerationNotificationCallback(NotificationCallback): **kwargs, ) - def _get_notification_type(self, context: TransitionContext) -> Optional[str]: + def _get_notification_type(self, context: TransitionContext) -> str | None: """Get the specific notification type based on model and state.""" key = (context.model_name, context.target_state) return self.NOTIFICATION_MAPPING.get(key) - def _get_recipient(self, instance: models.Model) -> Optional[Any]: + def _get_recipient(self, instance: models.Model) -> Any | None: """Get the appropriate recipient based on model type.""" # Try common recipient fields for field in ['reporter', 'assigned_to', 'created_by', 'submitted_by']: diff --git a/backend/apps/core/state_machine/callbacks/related_updates.py b/backend/apps/core/state_machine/callbacks/related_updates.py index fb22ebf9..993a43f2 100644 --- a/backend/apps/core/state_machine/callbacks/related_updates.py +++ b/backend/apps/core/state_machine/callbacks/related_updates.py @@ -5,15 +5,14 @@ This module provides callback implementations that update related models when state transitions occur. """ -from typing import Any, Callable, Dict, List, Optional, Set, Type import logging +from collections.abc import Callable from django.conf import settings from django.db import models, transaction from ..callback_base import PostTransitionCallback, TransitionContext - logger = logging.getLogger(__name__) @@ -28,7 +27,7 @@ class RelatedModelUpdateCallback(PostTransitionCallback): def __init__( self, - update_function: Optional[Callable[[TransitionContext], bool]] = None, + update_function: Callable[[TransitionContext], bool] | None = None, use_transaction: bool = True, **kwargs, ): @@ -251,8 +250,8 @@ class ComputedFieldUpdateCallback(RelatedModelUpdateCallback): def __init__( self, - computed_fields: Optional[List[str]] = None, - update_method: Optional[str] = None, + computed_fields: list[str] | None = None, + update_method: str | None = None, **kwargs, ): """ @@ -321,10 +320,7 @@ class RideStatusUpdateCallback(RelatedModelUpdateCallback): return False # Only execute for Ride model - if context.model_name != 'Ride': - return False - - return True + return context.model_name == 'Ride' def perform_update(self, context: TransitionContext) -> bool: """Perform ride-specific status updates.""" @@ -425,7 +421,7 @@ class ModerationQueueUpdateCallback(RelatedModelUpdateCallback): except Exception as e: logger.warning(f"Failed to update queue items: {e}") - def _get_content_type_id(self, instance: models.Model) -> Optional[int]: + def _get_content_type_id(self, instance: models.Model) -> int | None: """Get content type ID for the instance.""" try: from django.contrib.contenttypes.models import ContentType diff --git a/backend/apps/core/state_machine/config.py b/backend/apps/core/state_machine/config.py index b19c005b..2a0ff62e 100644 --- a/backend/apps/core/state_machine/config.py +++ b/backend/apps/core/state_machine/config.py @@ -5,14 +5,13 @@ This module provides centralized configuration for all FSM transition callbacks, including enable/disable settings, priorities, and environment-specific overrides. """ -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Type import logging +from dataclasses import dataclass, field +from typing import Any from django.conf import settings from django.db import models - logger = logging.getLogger(__name__) @@ -23,10 +22,10 @@ class TransitionCallbackConfig: notifications_enabled: bool = True cache_invalidation_enabled: bool = True related_updates_enabled: bool = True - notification_template: Optional[str] = None - cache_patterns: List[str] = field(default_factory=list) + notification_template: str | None = None + cache_patterns: list[str] = field(default_factory=list) priority: int = 100 - extra_data: Dict[str, Any] = field(default_factory=dict) + extra_data: dict[str, Any] = field(default_factory=dict) @dataclass @@ -35,7 +34,7 @@ class ModelCallbackConfig: model_name: str field_name: str = 'status' - transitions: Dict[tuple, TransitionCallbackConfig] = field(default_factory=dict) + transitions: dict[tuple, TransitionCallbackConfig] = field(default_factory=dict) default_config: TransitionCallbackConfig = field(default_factory=TransitionCallbackConfig) @@ -63,20 +62,20 @@ class CallbackConfig: } # Model-specific configurations - MODEL_CONFIGS: Dict[str, ModelCallbackConfig] = {} + MODEL_CONFIGS: dict[str, ModelCallbackConfig] = {} def __init__(self): self._settings = self._load_settings() self._model_configs = self._build_model_configs() - def _load_settings(self) -> Dict[str, Any]: + def _load_settings(self) -> dict[str, Any]: """Load settings from Django configuration.""" django_settings = getattr(settings, 'STATE_MACHINE_CALLBACKS', {}) merged = dict(self.DEFAULT_SETTINGS) merged.update(django_settings) return merged - def _build_model_configs(self) -> Dict[str, ModelCallbackConfig]: + def _build_model_configs(self) -> dict[str, ModelCallbackConfig]: """Build model-specific configurations.""" return { 'EditSubmission': ModelCallbackConfig( @@ -315,7 +314,7 @@ class CallbackConfig: model_name: str, source: str, target: str, - ) -> List[str]: + ) -> list[str]: """Get cache invalidation patterns for a transition.""" config = self.get_config(model_name, source, target) return config.cache_patterns @@ -325,14 +324,14 @@ class CallbackConfig: model_name: str, source: str, target: str, - ) -> Optional[str]: + ) -> str | None: """Get notification template for a transition.""" config = self.get_config(model_name, source, target) return config.notification_template def register_model_config( self, - model_class: Type[models.Model], + model_class: type[models.Model], config: ModelCallbackConfig, ) -> None: """ diff --git a/backend/apps/core/state_machine/decorators.py b/backend/apps/core/state_machine/decorators.py index 9bd128cf..c0afdd51 100644 --- a/backend/apps/core/state_machine/decorators.py +++ b/backend/apps/core/state_machine/decorators.py @@ -1,7 +1,8 @@ """Transition decorator generation for django-fsm integration.""" -from typing import Any, Callable, List, Optional, Type, Union -from functools import wraps import logging +from collections.abc import Callable +from functools import wraps +from typing import Any from django.db import models from django_fsm import transition @@ -14,12 +15,11 @@ from .callback_base import ( callback_registry, ) from .signals import ( - pre_state_transition, post_state_transition, + pre_state_transition, state_transition_failed, ) - logger = logging.getLogger(__name__) @@ -193,10 +193,10 @@ def create_transition_method( source: str, target: str, field_name: str, - permission_guard: Optional[Callable] = None, - on_success: Optional[Callable] = None, - on_error: Optional[Callable] = None, - callbacks: Optional[List[BaseTransitionCallback]] = None, + permission_guard: Callable | None = None, + on_success: Callable | None = None, + on_error: Callable | None = None, + callbacks: list[BaseTransitionCallback] | None = None, enable_callbacks: bool = True, emit_signals: bool = True, ) -> Callable: @@ -259,7 +259,7 @@ def create_transition_method( def register_method_callbacks( - model_class: Type[models.Model], + model_class: type[models.Model], method: Callable, ) -> None: """ @@ -275,14 +275,11 @@ def register_method_callbacks( if not metadata or not metadata.get('callbacks'): return - from .callback_base import CallbackStage, PostTransitionCallback, PreTransitionCallback + from .callback_base import CallbackStage, PreTransitionCallback for callback in metadata['callbacks']: # Determine stage from callback type - if isinstance(callback, PreTransitionCallback): - stage = CallbackStage.PRE - else: - stage = CallbackStage.POST + stage = CallbackStage.PRE if isinstance(callback, PreTransitionCallback) else CallbackStage.POST callback_registry.register( model_class=model_class, @@ -302,7 +299,7 @@ class TransitionMethodFactory: source: str, target: str, field_name: str = "status", - permission_guard: Optional[Callable] = None, + permission_guard: Callable | None = None, enable_callbacks: bool = True, emit_signals: bool = True, ) -> Callable: @@ -353,7 +350,7 @@ class TransitionMethodFactory: source: str, target: str, field_name: str = "status", - permission_guard: Optional[Callable] = None, + permission_guard: Callable | None = None, enable_callbacks: bool = True, emit_signals: bool = True, ) -> Callable: @@ -404,7 +401,7 @@ class TransitionMethodFactory: source: str, target: str, field_name: str = "status", - permission_guard: Optional[Callable] = None, + permission_guard: Callable | None = None, enable_callbacks: bool = True, emit_signals: bool = True, ) -> Callable: @@ -456,8 +453,8 @@ class TransitionMethodFactory: source: str, target: str, field_name: str = "status", - permission_guard: Optional[Callable] = None, - docstring: Optional[str] = None, + permission_guard: Callable | None = None, + docstring: str | None = None, enable_callbacks: bool = True, emit_signals: bool = True, ) -> Callable: diff --git a/backend/apps/core/state_machine/exceptions.py b/backend/apps/core/state_machine/exceptions.py index dc2b733d..a36b43e6 100644 --- a/backend/apps/core/state_machine/exceptions.py +++ b/backend/apps/core/state_machine/exceptions.py @@ -12,7 +12,8 @@ Example usage: 'code': e.error_code }, status=403) """ -from typing import Any, Optional, List, Dict +from typing import Any + from django_fsm import TransitionNotAllowed @@ -42,10 +43,10 @@ class TransitionPermissionDenied(TransitionNotAllowed): self, message: str = "Permission denied for this transition", error_code: str = "PERMISSION_DENIED", - user_message: Optional[str] = None, - required_roles: Optional[List[str]] = None, - user_role: Optional[str] = None, - guard: Optional[Any] = None, + user_message: str | None = None, + required_roles: list[str] | None = None, + user_role: str | None = None, + guard: Any | None = None, ): """ Initialize permission denied exception. @@ -65,7 +66,7 @@ class TransitionPermissionDenied(TransitionNotAllowed): self.user_role = user_role self.guard = guard - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """ Convert exception to dictionary for API responses. @@ -106,10 +107,10 @@ class TransitionValidationError(TransitionNotAllowed): self, message: str = "Transition validation failed", error_code: str = "VALIDATION_FAILED", - user_message: Optional[str] = None, - field_name: Optional[str] = None, - current_state: Optional[str] = None, - guard: Optional[Any] = None, + user_message: str | None = None, + field_name: str | None = None, + current_state: str | None = None, + guard: Any | None = None, ): """ Initialize validation error exception. @@ -129,7 +130,7 @@ class TransitionValidationError(TransitionNotAllowed): self.current_state = current_state self.guard = guard - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """ Convert exception to dictionary for API responses. @@ -168,10 +169,10 @@ class TransitionNotAvailable(TransitionNotAllowed): self, message: str = "This transition is not available", error_code: str = "TRANSITION_NOT_AVAILABLE", - user_message: Optional[str] = None, - current_state: Optional[str] = None, - requested_transition: Optional[str] = None, - available_transitions: Optional[List[str]] = None, + user_message: str | None = None, + current_state: str | None = None, + requested_transition: str | None = None, + available_transitions: list[str] | None = None, ): """ Initialize transition not available exception. @@ -191,7 +192,7 @@ class TransitionNotAvailable(TransitionNotAllowed): self.requested_transition = requested_transition self.available_transitions = available_transitions or [] - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """ Convert exception to dictionary for API responses. @@ -267,12 +268,12 @@ def get_permission_error_message( # "You need moderator permissions to approve submissions..." """ from .guards import ( - PermissionGuard, - OwnershipGuard, - AssignmentGuard, - MODERATOR_ROLES, ADMIN_ROLES, + MODERATOR_ROLES, SUPERUSER_ROLES, + AssignmentGuard, + OwnershipGuard, + PermissionGuard, ) if hasattr(guard, "get_error_message"): @@ -348,7 +349,7 @@ def get_state_error_message( def format_transition_error( exception: Exception, include_details: bool = False, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Format a transition exception for API response. @@ -412,7 +413,7 @@ def raise_permission_denied( user_role = get_user_role(user) if user else None error_code = TransitionPermissionDenied.ERROR_CODE_PERMISSION_DENIED_ROLE - required_roles: List[str] = [] + required_roles: list[str] = [] if isinstance(guard, PermissionGuard): required_roles = guard.get_required_roles() @@ -431,8 +432,8 @@ def raise_permission_denied( def raise_validation_error( guard: Any, - current_state: Optional[str] = None, - field_name: Optional[str] = None, + current_state: str | None = None, + field_name: str | None = None, ) -> None: """ Raise a TransitionValidationError exception with proper context. @@ -445,7 +446,7 @@ def raise_validation_error( Raises: TransitionValidationError: Always raised with proper context """ - from .guards import StateGuard, MetadataGuard + from .guards import MetadataGuard, StateGuard error_code = TransitionValidationError.ERROR_CODE_VALIDATION_FAILED user_message = "Validation failed for this transition" diff --git a/backend/apps/core/state_machine/fields.py b/backend/apps/core/state_machine/fields.py index b0dd147d..ddbb53a1 100644 --- a/backend/apps/core/state_machine/fields.py +++ b/backend/apps/core/state_machine/fields.py @@ -47,7 +47,7 @@ See Also: - apps.core.choices.registry: The central choice registry - apps.core.state_machine.mixins.StateMachineMixin: Convenience helpers """ -from typing import Any, Optional +from typing import Any from django.core.exceptions import ValidationError from django_fsm import FSMField as DjangoFSMField @@ -147,7 +147,7 @@ class RichFSMField(DjangoFSMField): f"'{value}' is deprecated and cannot be used for new entries" ) - def get_rich_choice(self, value: str) -> Optional[RichChoice]: + def get_rich_choice(self, value: str) -> RichChoice | None: """Return the RichChoice object for a given state value.""" return registry.get_choice(self.choice_group, value, self.domain) diff --git a/backend/apps/core/state_machine/guards.py b/backend/apps/core/state_machine/guards.py index 2553db34..82d0b189 100644 --- a/backend/apps/core/state_machine/guards.py +++ b/backend/apps/core/state_machine/guards.py @@ -18,7 +18,8 @@ Example usage: OwnershipGuard() ], operator='OR') """ -from typing import Callable, Dict, List, Optional, Any, Tuple, Union +from collections.abc import Callable +from typing import Any, Optional # Valid user roles in order of increasing privilege VALID_ROLES = ["USER", "MODERATOR", "ADMIN", "SUPERUSER"] @@ -62,9 +63,9 @@ class PermissionGuard: requires_moderator: bool = False, requires_admin: bool = False, requires_superuser: bool = False, - required_roles: Optional[List[str]] = None, - custom_check: Optional[Callable] = None, - error_message: Optional[str] = None, + required_roles: list[str] | None = None, + custom_check: Callable | None = None, + error_message: str | None = None, ): """ Initialize permission guard. @@ -83,10 +84,10 @@ class PermissionGuard: self.required_roles = required_roles self.custom_check = custom_check self._custom_error_message = error_message - self._last_error_code: Optional[str] = None + self._last_error_code: str | None = None @property - def error_code(self) -> Optional[str]: + def error_code(self) -> str | None: """Return the error code from the last failed check.""" return self._last_error_code @@ -126,16 +127,14 @@ class PermissionGuard: return False # Check moderator (includes admin and superuser) - elif self.requires_moderator: - if not is_moderator_or_above(user): - self._last_error_code = self.ERROR_CODE_PERMISSION_DENIED_ROLE - return False + elif self.requires_moderator and not is_moderator_or_above(user): + self._last_error_code = self.ERROR_CODE_PERMISSION_DENIED_ROLE + return False # Apply custom check if provided - if self.custom_check: - if not self.custom_check(instance, user): - self._last_error_code = self.ERROR_CODE_PERMISSION_DENIED_CUSTOM - return False + if self.custom_check and not self.custom_check(instance, user): + self._last_error_code = self.ERROR_CODE_PERMISSION_DENIED_CUSTOM + return False return True @@ -162,7 +161,7 @@ class PermissionGuard: return "This transition requires special permissions" return "This transition is not allowed" - def get_required_roles(self) -> List[str]: + def get_required_roles(self) -> list[str]: """ Return list of roles that would satisfy this guard. @@ -207,10 +206,10 @@ class OwnershipGuard: def __init__( self, - owner_fields: Optional[List[str]] = None, + owner_fields: list[str] | None = None, allow_moderator_override: bool = False, allow_admin_override: bool = False, - error_message: Optional[str] = None, + error_message: str | None = None, ): """ Initialize ownership guard. @@ -225,10 +224,10 @@ class OwnershipGuard: self.allow_moderator_override = allow_moderator_override self.allow_admin_override = allow_admin_override self._custom_error_message = error_message - self._last_error_code: Optional[str] = None + self._last_error_code: str | None = None @property - def error_code(self) -> Optional[str]: + def error_code(self) -> str | None: """Return the error code from the last failed check.""" return self._last_error_code @@ -305,10 +304,10 @@ class AssignmentGuard: def __init__( self, - assignment_fields: Optional[List[str]] = None, + assignment_fields: list[str] | None = None, require_assignment: bool = False, allow_admin_override: bool = False, - error_message: Optional[str] = None, + error_message: str | None = None, ): """ Initialize assignment guard. @@ -323,10 +322,10 @@ class AssignmentGuard: self.require_assignment = require_assignment self.allow_admin_override = allow_admin_override self._custom_error_message = error_message - self._last_error_code: Optional[str] = None + self._last_error_code: str | None = None @property - def error_code(self) -> Optional[str]: + def error_code(self) -> str | None: """Return the error code from the last failed check.""" return self._last_error_code @@ -399,10 +398,10 @@ class StateGuard: def __init__( self, - allowed_states: Optional[List[str]] = None, - blocked_states: Optional[List[str]] = None, + allowed_states: list[str] | None = None, + blocked_states: list[str] | None = None, state_field: str = "status", - error_message: Optional[str] = None, + error_message: str | None = None, ): """ Initialize state guard. @@ -417,11 +416,11 @@ class StateGuard: self.blocked_states = blocked_states or [] self.state_field = state_field self._custom_error_message = error_message - self._last_error_code: Optional[str] = None - self._current_state: Optional[str] = None + self._last_error_code: str | None = None + self._current_state: str | None = None @property - def error_code(self) -> Optional[str]: + def error_code(self) -> str | None: """Return the error code from the last failed check.""" return self._last_error_code @@ -445,10 +444,9 @@ class StateGuard: return False # Check allowed states if specified - if self.allowed_states is not None: - if self._current_state not in self.allowed_states: - self._last_error_code = self.ERROR_CODE_INVALID_STATE - return False + if self.allowed_states is not None and self._current_state not in self.allowed_states: + self._last_error_code = self.ERROR_CODE_INVALID_STATE + return False return True @@ -485,9 +483,9 @@ class MetadataGuard: def __init__( self, - required_fields: Optional[List[str]] = None, + required_fields: list[str] | None = None, check_not_empty: bool = True, - error_message: Optional[str] = None, + error_message: str | None = None, ): """ Initialize metadata guard. @@ -500,11 +498,11 @@ class MetadataGuard: self.required_fields = required_fields or [] self.check_not_empty = check_not_empty self._custom_error_message = error_message - self._last_error_code: Optional[str] = None - self._failed_field: Optional[str] = None + self._last_error_code: str | None = None + self._failed_field: str | None = None @property - def error_code(self) -> Optional[str]: + def error_code(self) -> str | None: """Return the error code from the last failed check.""" return self._last_error_code @@ -582,9 +580,9 @@ class CompositeGuard: def __init__( self, - guards: List[Callable], + guards: list[Callable], operator: str = "AND", - error_message: Optional[str] = None, + error_message: str | None = None, ): """ Initialize composite guard. @@ -597,11 +595,11 @@ class CompositeGuard: self.guards = guards self.operator = operator.upper() self._custom_error_message = error_message - self._last_error_code: Optional[str] = None - self._failed_guards: List[Callable] = [] + self._last_error_code: str | None = None + self._failed_guards: list[Callable] = [] @property - def error_code(self) -> Optional[str]: + def error_code(self) -> str | None: """Return the error code from the last failed check.""" return self._last_error_code @@ -658,7 +656,7 @@ class CompositeGuard: def create_ownership_guard( - owner_fields: Optional[List[str]] = None, + owner_fields: list[str] | None = None, allow_moderator_override: bool = False, allow_admin_override: bool = False, ) -> OwnershipGuard: @@ -681,7 +679,7 @@ def create_ownership_guard( def create_assignment_guard( - assignment_fields: Optional[List[str]] = None, + assignment_fields: list[str] | None = None, require_assignment: bool = False, allow_admin_override: bool = False, ) -> AssignmentGuard: @@ -704,7 +702,7 @@ def create_assignment_guard( def create_composite_guard( - guards: List[Callable], + guards: list[Callable], operator: str = "AND", ) -> CompositeGuard: """ @@ -728,7 +726,7 @@ ESCALATION_LEVEL_ROLES = { } -def extract_guards_from_metadata(metadata: Dict[str, Any]) -> List[Callable]: +def extract_guards_from_metadata(metadata: dict[str, Any]) -> list[Callable]: """ Convert RichChoice metadata to guard functions. @@ -823,7 +821,7 @@ def extract_guards_from_metadata(metadata: Dict[str, Any]) -> List[Callable]: return guards -def create_permission_guard(metadata: Dict[str, Any]) -> PermissionGuard: +def create_permission_guard(metadata: dict[str, Any]) -> PermissionGuard: """ Create a permission guard from RichChoice metadata. @@ -874,7 +872,7 @@ def create_permission_guard(metadata: Dict[str, Any]) -> PermissionGuard: ) -def validate_guard_metadata(metadata: Dict[str, Any]) -> Tuple[bool, List[str]]: +def validate_guard_metadata(metadata: dict[str, Any]) -> tuple[bool, list[str]]: """ Validate that metadata contains valid guard configuration. @@ -892,12 +890,11 @@ def validate_guard_metadata(metadata: Dict[str, Any]) -> Tuple[bool, List[str]]: # Validate escalation_level escalation_level = metadata.get("escalation_level") - if escalation_level: - if escalation_level.lower() not in ESCALATION_LEVEL_ROLES: - errors.append( - f"Invalid escalation_level: {escalation_level}. " - f"Must be one of: {', '.join(ESCALATION_LEVEL_ROLES.keys())}" - ) + if escalation_level and escalation_level.lower() not in ESCALATION_LEVEL_ROLES: + errors.append( + f"Invalid escalation_level: {escalation_level}. " + f"Must be one of: {', '.join(ESCALATION_LEVEL_ROLES.keys())}" + ) # Validate required_permissions is a list required_permissions = metadata.get("required_permissions") @@ -927,7 +924,7 @@ class GuardRegistry: """Registry for storing and retrieving guard functions.""" _instance: Optional["GuardRegistry"] = None - _guards: Dict[str, Callable] + _guards: dict[str, Callable] def __new__(cls): """Implement singleton pattern.""" @@ -946,7 +943,7 @@ class GuardRegistry: """ self._guards[name] = guard - def get_guard(self, name: str) -> Optional[Callable]: + def get_guard(self, name: str) -> Callable | None: """ Retrieve a guard by name. @@ -961,9 +958,9 @@ class GuardRegistry: def apply_guards( self, instance: Any, - guards: List[Callable], - user: Optional[Any] = None, - ) -> Tuple[bool, Optional[str]]: + guards: list[Callable], + user: Any | None = None, + ) -> tuple[bool, str | None]: """ Apply multiple guards. @@ -993,8 +990,8 @@ class GuardRegistry: def create_condition_from_metadata( - metadata: Dict[str, Any], -) -> Optional[Callable]: + metadata: dict[str, Any], +) -> Callable | None: """ Create FSM condition from metadata. @@ -1011,10 +1008,7 @@ def create_condition_from_metadata( def combined_condition(instance, user=None): """Combined condition from all guards.""" - for guard in guards: - if not guard(instance, user): - return False - return True + return all(guard(instance, user) for guard in guards) return combined_condition @@ -1022,7 +1016,7 @@ def create_condition_from_metadata( # Helper functions for permission checks -def get_user_role(user: Any) -> Optional[str]: +def get_user_role(user: Any) -> str | None: """ Get the user's role from the role field. @@ -1043,7 +1037,7 @@ def get_user_role(user: Any) -> Optional[str]: return None -def has_role(user: Any, required_roles: List[str]) -> bool: +def has_role(user: Any, required_roles: list[str]) -> bool: """ Check if user has one of the required roles. @@ -1083,9 +1077,8 @@ def has_role(user: Any, required_roles: List[str]) -> bool: return True # Check for staff status (treat as moderator) - if hasattr(user, "is_staff") and user.is_staff: - if "MODERATOR" in required_roles: - return True + if hasattr(user, "is_staff") and user.is_staff and "MODERATOR" in required_roles: + return True return False @@ -1191,7 +1184,7 @@ def has_permission(user: Any, permission: str) -> bool: def create_guard_from_drf_permission( permission_class: type, - error_message: Optional[str] = None, + error_message: str | None = None, ) -> Callable: """ Create an FSM guard from a DRF permission class. @@ -1225,13 +1218,13 @@ def create_guard_from_drf_permission( class DRFPermissionGuard: """Guard that wraps a DRF permission class.""" - def __init__(self, perm_class: type, err_msg: Optional[str] = None): + def __init__(self, perm_class: type, err_msg: str | None = None): self.permission_class = perm_class self._custom_error_message = err_msg - self._last_error_code: Optional[str] = None + self._last_error_code: str | None = None @property - def error_code(self) -> Optional[str]: + def error_code(self) -> str | None: return self._last_error_code def __call__(self, instance: Any, user: Any = None) -> bool: diff --git a/backend/apps/core/state_machine/integration.py b/backend/apps/core/state_machine/integration.py index 1bc16709..e9fddf38 100644 --- a/backend/apps/core/state_machine/integration.py +++ b/backend/apps/core/state_machine/integration.py @@ -1,5 +1,6 @@ """Model integration utilities for applying state machines to Django models.""" -from typing import Type, Optional, Dict, Any, List, Callable +from collections.abc import Callable +from typing import Any from django.db import models from django_fsm import can_proceed @@ -8,23 +9,21 @@ from apps.core.state_machine.builder import ( StateTransitionBuilder, determine_method_name_for_transition, ) +from apps.core.state_machine.decorators import TransitionMethodFactory +from apps.core.state_machine.guards import ( + CompositeGuard, + create_guard_from_drf_permission, + extract_guards_from_metadata, +) from apps.core.state_machine.registry import ( TransitionInfo, registry_instance, ) from apps.core.state_machine.validators import MetadataValidator -from apps.core.state_machine.decorators import TransitionMethodFactory -from apps.core.state_machine.guards import ( - create_permission_guard, - extract_guards_from_metadata, - create_condition_from_metadata, - create_guard_from_drf_permission, - CompositeGuard, -) def apply_state_machine( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, choice_group: str, domain: str = "core", @@ -48,7 +47,7 @@ def apply_state_machine( if not result.is_valid: error_messages = [str(e) for e in result.errors] raise ValueError( - f"Cannot apply state machine - validation failed:\n" + "Cannot apply state machine - validation failed:\n" + "\n".join(error_messages) ) @@ -62,7 +61,7 @@ def apply_state_machine( def generate_transition_methods_for_model( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, choice_group: str, domain: str = "core", @@ -100,7 +99,7 @@ def generate_transition_methods_for_model( all_guards = guards + target_guards # Create combined guard if we have multiple guards - combined_guard: Optional[Callable] = None + combined_guard: Callable | None = None if len(all_guards) == 1: combined_guard = all_guards[0] elif len(all_guards) > 1: @@ -149,7 +148,7 @@ class StateMachineModelMixin: def get_available_state_transitions( self, field_name: str = "status" - ) -> List[TransitionInfo]: + ) -> list[TransitionInfo]: """ Get available transitions from current state. @@ -176,7 +175,7 @@ class StateMachineModelMixin: self, target_state: str, field_name: str = "status", - user: Optional[Any] = None, + user: Any | None = None, ) -> bool: """ Check if transition to target state is allowed. @@ -219,7 +218,7 @@ class StateMachineModelMixin: def get_transition_method( self, target_state: str, field_name: str = "status" - ) -> Optional[Callable]: + ) -> Callable | None: """ Get the transition method for moving to target state. @@ -252,7 +251,7 @@ class StateMachineModelMixin: self, target_state: str, field_name: str = "status", - user: Optional[Any] = None, + user: Any | None = None, **kwargs: Any, ) -> bool: """ @@ -299,7 +298,7 @@ def state_machine_model( Decorator function """ - def decorator(model_class: Type[models.Model]) -> Type[models.Model]: + def decorator(model_class: type[models.Model]) -> type[models.Model]: """Apply state machine to model class.""" apply_state_machine(model_class, field_name, choice_group, domain) return model_class @@ -308,7 +307,7 @@ def state_machine_model( def validate_model_state_machine( - model_class: Type[models.Model], field_name: str + model_class: type[models.Model], field_name: str ) -> bool: """ Ensure model is properly configured with state machine. @@ -345,7 +344,7 @@ def validate_model_state_machine( if not result.is_valid: error_messages = [str(e) for e in result.errors] raise ValueError( - f"State machine validation failed:\n" + "\n".join(error_messages) + "State machine validation failed:\n" + "\n".join(error_messages) ) return True diff --git a/backend/apps/core/state_machine/mixins.py b/backend/apps/core/state_machine/mixins.py index 622c463e..7705970d 100644 --- a/backend/apps/core/state_machine/mixins.py +++ b/backend/apps/core/state_machine/mixins.py @@ -38,12 +38,12 @@ See Also: - apps.core.state_machine.fields.RichFSMField: The FSM field implementation - django_fsm.can_proceed: FSM transition checking utility """ -from typing import Any, Dict, Iterable, List, Optional +from collections.abc import Iterable +from typing import Any from django.db import models from django_fsm import can_proceed - # Default transition metadata for styling TRANSITION_METADATA = { # Approval transitions @@ -71,7 +71,7 @@ TRANSITION_METADATA = { } -def _get_transition_metadata(transition_name: str) -> Dict[str, Any]: +def _get_transition_metadata(transition_name: str) -> dict[str, Any]: """Get metadata for a transition by name.""" if transition_name in TRANSITION_METADATA: return TRANSITION_METADATA[transition_name].copy() @@ -161,12 +161,12 @@ class StateMachineMixin(models.Model): class Meta: abstract = True - def get_state_value(self, field_name: Optional[str] = None) -> Any: + def get_state_value(self, field_name: str | None = None) -> Any: """Return the raw state value for the given field (default is `state`).""" name = field_name or self.state_field_name return getattr(self, name, None) - def get_state_display_value(self, field_name: Optional[str] = None) -> str: + def get_state_display_value(self, field_name: str | None = None) -> str: """Return the display label for the current state, if available.""" name = field_name or self.state_field_name getter = getattr(self, f"get_{name}_display", None) @@ -175,7 +175,7 @@ class StateMachineMixin(models.Model): value = getattr(self, name, "") return value if value is not None else "" - def get_state_choice(self, field_name: Optional[str] = None): + def get_state_choice(self, field_name: str | None = None): """Return the RichChoice object when the field provides one.""" name = field_name or self.state_field_name getter = getattr(self, f"get_{name}_rich_choice", None) @@ -193,7 +193,7 @@ class StateMachineMixin(models.Model): return can_proceed(method) def get_available_transitions( - self, field_name: Optional[str] = None + self, field_name: str | None = None ) -> Iterable[Any]: """Return available transitions when helpers are present.""" name = field_name or self.state_field_name @@ -203,12 +203,12 @@ class StateMachineMixin(models.Model): return helper() # type: ignore[misc] return [] - def is_in_state(self, state: str, field_name: Optional[str] = None) -> bool: + def is_in_state(self, state: str, field_name: str | None = None) -> bool: """Convenience check for comparing the current state.""" current_state = self.get_state_value(field_name) return current_state == state - def get_available_user_transitions(self, user) -> List[Dict[str, Any]]: + def get_available_user_transitions(self, user) -> list[dict[str, Any]]: """ Get transitions available to the given user. diff --git a/backend/apps/core/state_machine/monitoring.py b/backend/apps/core/state_machine/monitoring.py index 89be2290..dadbcbea 100644 --- a/backend/apps/core/state_machine/monitoring.py +++ b/backend/apps/core/state_machine/monitoring.py @@ -5,20 +5,18 @@ This module provides tools for monitoring callback execution, tracking performance, and debugging transition issues. """ -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Tuple, Type -from collections import defaultdict import logging -import time import threading +import time +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Optional from django.conf import settings -from django.db import models from .callback_base import TransitionContext - logger = logging.getLogger(__name__) @@ -35,9 +33,9 @@ class CallbackExecutionRecord: timestamp: datetime duration_ms: float success: bool - error_message: Optional[str] = None - instance_id: Optional[int] = None - user_id: Optional[int] = None + error_message: str | None = None + instance_id: int | None = None + user_id: int | None = None @dataclass @@ -51,8 +49,8 @@ class CallbackStats: total_duration_ms: float = 0.0 min_duration_ms: float = float('inf') max_duration_ms: float = 0.0 - last_execution: Optional[datetime] = None - last_error: Optional[str] = None + last_execution: datetime | None = None + last_error: str | None = None @property def avg_duration_ms(self) -> float: @@ -72,7 +70,7 @@ class CallbackStats: self, duration_ms: float, success: bool, - error_message: Optional[str] = None, + error_message: str | None = None, ) -> None: """Record a callback execution.""" self.total_executions += 1 @@ -114,10 +112,10 @@ class CallbackMonitor: if self._initialized: return - self._stats: Dict[str, CallbackStats] = defaultdict( + self._stats: dict[str, CallbackStats] = defaultdict( lambda: CallbackStats(callback_name="") ) - self._recent_executions: List[CallbackExecutionRecord] = [] + self._recent_executions: list[CallbackExecutionRecord] = [] self._max_recent_records = 1000 self._enabled = self._check_enabled() self._debug_mode = self._check_debug_mode() @@ -159,7 +157,7 @@ class CallbackMonitor: stage: str, duration_ms: float, success: bool, - error_message: Optional[str] = None, + error_message: str | None = None, ) -> None: """ Record a callback execution. @@ -220,7 +218,7 @@ class CallbackMonitor: else: logger.warning(f"{log_message} - Error: {record.error_message}") - def get_stats(self, callback_name: Optional[str] = None) -> Dict[str, CallbackStats]: + def get_stats(self, callback_name: str | None = None) -> dict[str, CallbackStats]: """ Get callback statistics. @@ -239,10 +237,10 @@ class CallbackMonitor: def get_recent_executions( self, limit: int = 100, - callback_name: Optional[str] = None, - model_name: Optional[str] = None, - success_only: Optional[bool] = None, - ) -> List[CallbackExecutionRecord]: + callback_name: str | None = None, + model_name: str | None = None, + success_only: bool | None = None, + ) -> list[CallbackExecutionRecord]: """ Get recent execution records. @@ -268,12 +266,12 @@ class CallbackMonitor: # Return most recent first return list(reversed(records[-limit:])) - def get_failure_summary(self) -> Dict[str, Any]: + def get_failure_summary(self) -> dict[str, Any]: """Get a summary of callback failures.""" failures = [r for r in self._recent_executions if not r.success] # Group by callback - by_callback: Dict[str, List[CallbackExecutionRecord]] = defaultdict(list) + by_callback: dict[str, list[CallbackExecutionRecord]] = defaultdict(list) for record in failures: by_callback[record.callback_name].append(record) @@ -292,7 +290,7 @@ class CallbackMonitor: return summary - def get_performance_report(self) -> Dict[str, Any]: + def get_performance_report(self) -> dict[str, Any]: """Get a performance report for all callbacks.""" report = { 'callbacks': {}, @@ -361,7 +359,7 @@ class TimedCallbackExecution: self.stage = stage self.start_time = 0.0 self.success = True - self.error_message: Optional[str] = None + self.error_message: str | None = None def __enter__(self) -> 'TimedCallbackExecution': self.start_time = time.perf_counter() @@ -419,7 +417,7 @@ def get_callback_execution_order( model_name: str, source: str, target: str, -) -> List[Tuple[str, str, int]]: +) -> list[tuple[str, str, int]]: """ Get the order of callback execution for a transition. @@ -431,7 +429,7 @@ def get_callback_execution_order( Returns: List of (stage, callback_name, priority) tuples in execution order. """ - from .callback_base import callback_registry, CallbackStage + from .callback_base import CallbackStage order = [] diff --git a/backend/apps/core/state_machine/registry.py b/backend/apps/core/state_machine/registry.py index b1a8c08f..f069b04b 100644 --- a/backend/apps/core/state_machine/registry.py +++ b/backend/apps/core/state_machine/registry.py @@ -1,13 +1,13 @@ """TransitionRegistry - Centralized registry for managing FSM transitions.""" -from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional, Any, Tuple, Type import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Optional from django.db import models from apps.core.state_machine.builder import StateTransitionBuilder - logger = logging.getLogger(__name__) @@ -20,7 +20,7 @@ class TransitionInfo: method_name: str requires_moderator: bool = False requires_admin_approval: bool = False - metadata: Dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) def __hash__(self): """Make TransitionInfo hashable.""" @@ -31,7 +31,7 @@ class TransitionRegistry: """Centralized registry for managing and looking up FSM transitions.""" _instance: Optional["TransitionRegistry"] = None - _transitions: Dict[Tuple[str, str], Dict[Tuple[str, str], TransitionInfo]] + _transitions: dict[tuple[str, str], dict[tuple[str, str], TransitionInfo]] def __new__(cls): """Implement singleton pattern.""" @@ -40,7 +40,7 @@ class TransitionRegistry: cls._instance._transitions = {} return cls._instance - def _get_key(self, choice_group: str, domain: str) -> Tuple[str, str]: + def _get_key(self, choice_group: str, domain: str) -> tuple[str, str]: """Generate registry key from choice group and domain.""" return (domain, choice_group) @@ -51,7 +51,7 @@ class TransitionRegistry: source: str, target: str, method_name: str, - metadata: Optional[Dict[str, Any]] = None, + metadata: dict[str, Any] | None = None, ) -> TransitionInfo: """ Register a transition. @@ -88,7 +88,7 @@ class TransitionRegistry: def get_transition( self, choice_group: str, domain: str, source: str, target: str - ) -> Optional[TransitionInfo]: + ) -> TransitionInfo | None: """ Retrieve transition info. @@ -111,7 +111,7 @@ class TransitionRegistry: def get_available_transitions( self, choice_group: str, domain: str, current_state: str - ) -> List[TransitionInfo]: + ) -> list[TransitionInfo]: """ Get all valid transitions from a state. @@ -129,7 +129,7 @@ class TransitionRegistry: return [] available = [] - for (source, target), info in self._transitions[key].items(): + for (source, _target), info in self._transitions[key].items(): if source == current_state: available.append(info) @@ -137,7 +137,7 @@ class TransitionRegistry: def get_transition_method_name( self, choice_group: str, domain: str, source: str, target: str - ) -> Optional[str]: + ) -> str | None: """ Get the method name for a transition. @@ -209,8 +209,8 @@ class TransitionRegistry: def clear_registry( self, - choice_group: Optional[str] = None, - domain: Optional[str] = None, + choice_group: str | None = None, + domain: str | None = None, ) -> None: """ Clear registry entries for testing. @@ -246,7 +246,7 @@ class TransitionRegistry: return {} if format == "dict" else "" if format == "dict": - graph: Dict[str, List[str]] = {} + graph: dict[str, list[str]] = {} for (source, target), info in self._transitions[key].items(): if source not in graph: graph[source] = [] @@ -272,7 +272,7 @@ class TransitionRegistry: else: raise ValueError(f"Unsupported format: {format}") - def get_all_registered_groups(self) -> List[Tuple[str, str]]: + def get_all_registered_groups(self) -> list[tuple[str, str]]: """ Get all registered choice groups. @@ -289,7 +289,7 @@ registry_instance = TransitionRegistry() # Callback registration helpers def register_callback( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, @@ -307,7 +307,7 @@ def register_callback( callback: The callback instance. stage: When to execute ('pre', 'post', 'error'). """ - from .callback_base import callback_registry, CallbackStage + from .callback_base import CallbackStage, callback_registry callback_registry.register( model_class=model_class, @@ -320,7 +320,7 @@ def register_callback( def register_notification_callback( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, source: str, target: str, @@ -348,9 +348,9 @@ def register_notification_callback( def register_cache_invalidation( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, - cache_patterns: Optional[List[str]] = None, + cache_patterns: list[str] | None = None, source: str = '*', target: str = '*', ) -> None: @@ -371,7 +371,7 @@ def register_cache_invalidation( def register_related_update( - model_class: Type[models.Model], + model_class: type[models.Model], field_name: str, update_func: Callable, source: str = '*', @@ -393,7 +393,7 @@ def register_related_update( register_callback(model_class, field_name, source, target, callback, 'post') -def register_transition_callbacks(cls: Type[models.Model]) -> Type[models.Model]: +def register_transition_callbacks(cls: type[models.Model]) -> type[models.Model]: """ Class decorator to auto-register callbacks from model's Meta. diff --git a/backend/apps/core/state_machine/signals.py b/backend/apps/core/state_machine/signals.py index 18f0da94..45f19176 100644 --- a/backend/apps/core/state_machine/signals.py +++ b/backend/apps/core/state_machine/signals.py @@ -5,15 +5,12 @@ This module defines custom Django signals emitted during state machine transitions and provides utilities for connecting signal handlers. """ -from typing import Any, Callable, Dict, List, Optional, Type, Union import logging +from collections.abc import Callable from django.db import models from django.dispatch import Signal, receiver -from .callback_base import TransitionContext - - logger = logging.getLogger(__name__) @@ -69,11 +66,11 @@ class TransitionSignalHandler: """ def __init__(self): - self._handlers: Dict[str, List[Callable]] = {} + self._handlers: dict[str, list[Callable]] = {} def register( self, - model_class: Type[models.Model], + model_class: type[models.Model], source: str, target: str, handler: Callable, @@ -105,7 +102,7 @@ class TransitionSignalHandler: def unregister( self, - model_class: Type[models.Model], + model_class: type[models.Model], source: str, target: str, handler: Callable, @@ -121,7 +118,7 @@ class TransitionSignalHandler: def _make_key( self, - model_class: Type[models.Model], + model_class: type[models.Model], source: str, target: str, stage: str, @@ -140,7 +137,7 @@ class TransitionSignalHandler: def _connect_signal( self, signal: Signal, - model_class: Type[models.Model], + model_class: type[models.Model], source: str, target: str, handler: Callable, @@ -173,7 +170,7 @@ transition_signal_handler = TransitionSignalHandler() def register_transition_handler( - model_class: Type[models.Model], + model_class: type[models.Model], source: str, target: str, handler: Callable, @@ -233,7 +230,7 @@ class TransitionHandlerDecorator: def __init__( self, - model_class: Type[models.Model], + model_class: type[models.Model], source: str = '*', target: str = '*', stage: str = 'post', @@ -265,7 +262,7 @@ class TransitionHandlerDecorator: def on_transition( - model_class: Type[models.Model], + model_class: type[models.Model], source: str = '*', target: str = '*', stage: str = 'post', @@ -291,7 +288,7 @@ def on_transition( def on_pre_transition( - model_class: Type[models.Model], + model_class: type[models.Model], source: str = '*', target: str = '*', ) -> TransitionHandlerDecorator: @@ -300,7 +297,7 @@ def on_pre_transition( def on_post_transition( - model_class: Type[models.Model], + model_class: type[models.Model], source: str = '*', target: str = '*', ) -> TransitionHandlerDecorator: @@ -309,7 +306,7 @@ def on_post_transition( def on_transition_error( - model_class: Type[models.Model], + model_class: type[models.Model], source: str = '*', target: str = '*', ) -> TransitionHandlerDecorator: diff --git a/backend/apps/core/state_machine/tests/fixtures.py b/backend/apps/core/state_machine/tests/fixtures.py index e96a503c..8ebe6400 100644 --- a/backend/apps/core/state_machine/tests/fixtures.py +++ b/backend/apps/core/state_machine/tests/fixtures.py @@ -7,43 +7,44 @@ This module provides reusable fixtures for creating test data: - Mock objects for testing guards and callbacks """ +from typing import Any + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from typing import Optional, Any, Dict User = get_user_model() class UserFactory: """Factory for creating users with different roles.""" - + _counter = 0 - + @classmethod def _get_unique_id(cls) -> int: """Get a unique counter for creating unique usernames.""" cls._counter += 1 return cls._counter - + @classmethod def create_user( cls, role: str = 'USER', - username: Optional[str] = None, - email: Optional[str] = None, + username: str | None = None, + email: str | None = None, password: str = 'testpass123', **kwargs ) -> User: """ Create a user with specified role. - + Args: role: User role (USER, MODERATOR, ADMIN, SUPERUSER) username: Username (auto-generated if not provided) email: Email (auto-generated if not provided) password: Password for the user **kwargs: Additional user fields - + Returns: Created User instance """ @@ -52,7 +53,7 @@ class UserFactory: username = f"user_{role.lower()}_{uid}" if email is None: email = f"{role.lower()}_{uid}@example.com" - + return User.objects.create_user( username=username, email=email, @@ -60,22 +61,22 @@ class UserFactory: role=role, **kwargs ) - + @classmethod def create_regular_user(cls, **kwargs) -> User: """Create a regular user.""" return cls.create_user(role='USER', **kwargs) - + @classmethod def create_moderator(cls, **kwargs) -> User: """Create a moderator user.""" return cls.create_user(role='MODERATOR', **kwargs) - + @classmethod def create_admin(cls, **kwargs) -> User: """Create an admin user.""" return cls.create_user(role='ADMIN', **kwargs) - + @classmethod def create_superuser(cls, **kwargs) -> User: """Create a superuser.""" @@ -84,23 +85,23 @@ class UserFactory: class CompanyFactory: """Factory for creating company instances.""" - + _counter = 0 - + @classmethod def _get_unique_id(cls) -> int: cls._counter += 1 return cls._counter - + @classmethod - def create_operator(cls, name: Optional[str] = None, **kwargs) -> Any: + def create_operator(cls, name: str | None = None, **kwargs) -> Any: """Create an operator company.""" from apps.parks.models import Company - + uid = cls._get_unique_id() if name is None: name = f"Test Operator {uid}" - + defaults = { 'name': name, 'description': f'Test operator company {uid}', @@ -108,16 +109,16 @@ class CompanyFactory: } defaults.update(kwargs) return Company.objects.create(**defaults) - + @classmethod - def create_manufacturer(cls, name: Optional[str] = None, **kwargs) -> Any: + def create_manufacturer(cls, name: str | None = None, **kwargs) -> Any: """Create a manufacturer company.""" from apps.rides.models import Company - + uid = cls._get_unique_id() if name is None: name = f"Test Manufacturer {uid}" - + defaults = { 'name': name, 'description': f'Test manufacturer company {uid}', @@ -129,42 +130,42 @@ class CompanyFactory: class ParkFactory: """Factory for creating park instances.""" - + _counter = 0 - + @classmethod def _get_unique_id(cls) -> int: cls._counter += 1 return cls._counter - + @classmethod def create_park( cls, - name: Optional[str] = None, - operator: Optional[Any] = None, + name: str | None = None, + operator: Any | None = None, status: str = 'OPERATING', **kwargs ) -> Any: """ Create a park with specified status. - + Args: name: Park name (auto-generated if not provided) operator: Operator company (auto-created if not provided) status: Park status **kwargs: Additional park fields - + Returns: Created Park instance """ from apps.parks.models import Park - + uid = cls._get_unique_id() if name is None: name = f"Test Park {uid}" if operator is None: operator = CompanyFactory.create_operator() - + defaults = { 'name': name, 'slug': f'test-park-{uid}', @@ -179,38 +180,38 @@ class ParkFactory: class RideFactory: """Factory for creating ride instances.""" - + _counter = 0 - + @classmethod def _get_unique_id(cls) -> int: cls._counter += 1 return cls._counter - + @classmethod def create_ride( cls, - name: Optional[str] = None, - park: Optional[Any] = None, - manufacturer: Optional[Any] = None, + name: str | None = None, + park: Any | None = None, + manufacturer: Any | None = None, status: str = 'OPERATING', **kwargs ) -> Any: """ Create a ride with specified status. - + Args: name: Ride name (auto-generated if not provided) park: Park for the ride (auto-created if not provided) manufacturer: Manufacturer company (auto-created if not provided) status: Ride status **kwargs: Additional ride fields - + Returns: Created Ride instance """ from apps.rides.models import Ride - + uid = cls._get_unique_id() if name is None: name = f"Test Ride {uid}" @@ -218,7 +219,7 @@ class RideFactory: park = ParkFactory.create_park() if manufacturer is None: manufacturer = CompanyFactory.create_manufacturer() - + defaults = { 'name': name, 'slug': f'test-ride-{uid}', @@ -233,39 +234,39 @@ class RideFactory: class EditSubmissionFactory: """Factory for creating edit submission instances.""" - + _counter = 0 - + @classmethod def _get_unique_id(cls) -> int: cls._counter += 1 return cls._counter - + @classmethod def create_submission( cls, - user: Optional[Any] = None, - target_object: Optional[Any] = None, + user: Any | None = None, + target_object: Any | None = None, status: str = 'PENDING', - changes: Optional[Dict[str, Any]] = None, + changes: dict[str, Any] | None = None, **kwargs ) -> Any: """ Create an edit submission. - + Args: user: User who submitted (auto-created if not provided) target_object: Object being edited (auto-created if not provided) status: Submission status changes: Changes dictionary **kwargs: Additional fields - + Returns: Created EditSubmission instance """ from apps.moderation.models import EditSubmission from apps.parks.models import Company - + uid = cls._get_unique_id() if user is None: user = UserFactory.create_regular_user() @@ -276,9 +277,9 @@ class EditSubmissionFactory: ) if changes is None: changes = {'name': f'Updated Name {uid}'} - + content_type = ContentType.objects.get_for_model(target_object) - + defaults = { 'user': user, 'content_type': content_type, @@ -294,37 +295,37 @@ class EditSubmissionFactory: class ModerationReportFactory: """Factory for creating moderation report instances.""" - + _counter = 0 - + @classmethod def _get_unique_id(cls) -> int: cls._counter += 1 return cls._counter - + @classmethod def create_report( cls, - reporter: Optional[Any] = None, - target_object: Optional[Any] = None, + reporter: Any | None = None, + target_object: Any | None = None, status: str = 'PENDING', **kwargs ) -> Any: """ Create a moderation report. - + Args: reporter: User who reported (auto-created if not provided) target_object: Object being reported (auto-created if not provided) status: Report status **kwargs: Additional fields - + Returns: Created ModerationReport instance """ from apps.moderation.models import ModerationReport from apps.parks.models import Company - + uid = cls._get_unique_id() if reporter is None: reporter = UserFactory.create_regular_user() @@ -333,9 +334,9 @@ class ModerationReportFactory: name=f'Reported Company {uid}', description='Test company' ) - + content_type = ContentType.objects.get_for_model(target_object) - + defaults = { 'report_type': 'CONTENT', 'status': status, @@ -354,7 +355,7 @@ class ModerationReportFactory: class MockInstance: """ Mock instance for testing guards without database. - + Example: instance = MockInstance( status='PENDING', @@ -362,11 +363,11 @@ class MockInstance: assigned_to=moderator ) """ - + def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - + def __repr__(self): attrs = ', '.join(f'{k}={v!r}' for k, v in self.__dict__.items()) return f'MockInstance({attrs})' diff --git a/backend/apps/core/state_machine/tests/helpers.py b/backend/apps/core/state_machine/tests/helpers.py index 545ef31f..3c6d038f 100644 --- a/backend/apps/core/state_machine/tests/helpers.py +++ b/backend/apps/core/state_machine/tests/helpers.py @@ -7,34 +7,36 @@ This module provides utility functions for testing state machine functionality: - Guard testing utilities """ -from typing import Any, Optional, List, Callable +from collections.abc import Callable +from typing import Any + from django.contrib.contenttypes.models import ContentType def assert_transition_allowed( instance: Any, method_name: str, - user: Optional[Any] = None + user: Any | None = None ) -> bool: """ Assert that a transition is allowed. - + Args: instance: Model instance with FSM field method_name: Name of the transition method user: User attempting the transition - + Returns: True if transition is allowed - + Raises: AssertionError: If transition is not allowed - + Example: assert_transition_allowed(submission, 'transition_to_approved', moderator) """ from django_fsm import can_proceed - + method = getattr(instance, method_name) result = can_proceed(method) assert result, f"Transition {method_name} should be allowed but was denied" @@ -44,27 +46,27 @@ def assert_transition_allowed( def assert_transition_denied( instance: Any, method_name: str, - user: Optional[Any] = None + user: Any | None = None ) -> bool: """ Assert that a transition is denied. - + Args: instance: Model instance with FSM field method_name: Name of the transition method user: User attempting the transition - + Returns: True if transition is denied - + Raises: AssertionError: If transition is allowed - + Example: assert_transition_denied(submission, 'transition_to_approved', regular_user) """ from django_fsm import can_proceed - + method = getattr(instance, method_name) result = can_proceed(method) assert not result, f"Transition {method_name} should be denied but was allowed" @@ -74,130 +76,130 @@ def assert_transition_denied( def assert_state_log_created( instance: Any, expected_state: str, - user: Optional[Any] = None + user: Any | None = None ) -> Any: """ Assert that a StateLog entry was created for a transition. - + Args: instance: Model instance that was transitioned expected_state: The expected final state in the log user: Expected user who made the transition (optional) - + Returns: The StateLog entry - + Raises: AssertionError: If StateLog entry not found or doesn't match - + Example: log = assert_state_log_created(submission, 'APPROVED', moderator) """ from django_fsm_log.models import StateLog - + ct = ContentType.objects.get_for_model(instance) log = StateLog.objects.filter( content_type=ct, object_id=instance.id, state=expected_state ).first() - + assert log is not None, f"StateLog for state '{expected_state}' not found" - + if user is not None: assert log.by == user, f"Expected log.by={user}, got {log.by}" - + return log -def assert_state_log_count(instance: Any, expected_count: int) -> List[Any]: +def assert_state_log_count(instance: Any, expected_count: int) -> list[Any]: """ Assert the number of StateLog entries for an instance. - + Args: instance: Model instance to check logs for expected_count: Expected number of log entries - + Returns: List of StateLog entries - + Raises: AssertionError: If count doesn't match - + Example: logs = assert_state_log_count(submission, 2) """ from django_fsm_log.models import StateLog - + ct = ContentType.objects.get_for_model(instance) logs = list(StateLog.objects.filter( content_type=ct, object_id=instance.id ).order_by('timestamp')) - + actual_count = len(logs) assert actual_count == expected_count, \ f"Expected {expected_count} StateLog entries, got {actual_count}" - + return logs def assert_state_transition_sequence( instance: Any, - expected_states: List[str] -) -> List[Any]: + expected_states: list[str] +) -> list[Any]: """ Assert that state transitions occurred in a specific sequence. - + Args: instance: Model instance to check expected_states: List of expected states in order - + Returns: List of StateLog entries - + Raises: AssertionError: If sequence doesn't match - + Example: assert_state_transition_sequence(submission, ['ESCALATED', 'APPROVED']) """ from django_fsm_log.models import StateLog - + ct = ContentType.objects.get_for_model(instance) logs = list(StateLog.objects.filter( content_type=ct, object_id=instance.id ).order_by('timestamp')) - + actual_states = [log.state for log in logs] assert actual_states == expected_states, \ f"Expected state sequence {expected_states}, got {actual_states}" - + return logs def assert_guard_passes( guard: Callable, instance: Any, - user: Optional[Any] = None, + user: Any | None = None, message: str = "" ) -> bool: """ Assert that a guard function passes. - + Args: guard: Guard function or callable instance: Model instance to check user: User attempting the action message: Optional message on failure - + Returns: True if guard passes - + Raises: AssertionError: If guard fails - + Example: assert_guard_passes(permission_guard, instance, moderator) """ @@ -210,58 +212,58 @@ def assert_guard_passes( def assert_guard_fails( guard: Callable, instance: Any, - user: Optional[Any] = None, - expected_error_code: Optional[str] = None, + user: Any | None = None, + expected_error_code: str | None = None, message: str = "" ) -> bool: """ Assert that a guard function fails. - + Args: guard: Guard function or callable instance: Model instance to check user: User attempting the action expected_error_code: Expected error code from guard message: Optional message on failure - + Returns: True if guard fails as expected - + Raises: AssertionError: If guard passes or wrong error code - + Example: assert_guard_fails(permission_guard, instance, regular_user, 'PERMISSION_DENIED') """ result = guard(instance, user) fail_message = message or f"Guard should fail but returned {result}" assert result is False, fail_message - + if expected_error_code and hasattr(guard, 'error_code'): assert guard.error_code == expected_error_code, \ f"Expected error code {expected_error_code}, got {guard.error_code}" - + return True def transition_and_save( instance: Any, transition_method: str, - user: Optional[Any] = None, + user: Any | None = None, **kwargs ) -> Any: """ Execute a transition and save the instance. - + Args: instance: Model instance with FSM field transition_method: Name of the transition method user: User performing the transition **kwargs: Additional arguments for the transition - + Returns: The saved instance - + Example: submission = transition_and_save(submission, 'transition_to_approved', moderator) """ @@ -272,37 +274,36 @@ def transition_and_save( return instance -def get_available_transitions(instance: Any) -> List[str]: +def get_available_transitions(instance: Any) -> list[str]: """ Get list of available transitions for an instance. - + Args: instance: Model instance with FSM field - + Returns: List of available transition method names - + Example: transitions = get_available_transitions(submission) # ['transition_to_approved', 'transition_to_rejected', 'transition_to_escalated'] """ - from django_fsm import get_available_FIELD_transitions - + # Get the state field name from the instance state_field = getattr(instance, 'state_field_name', 'status') - + # Build the function name dynamically func_name = f'get_available_{state_field}_transitions' if hasattr(instance, func_name): get_transitions = getattr(instance, func_name) return [t.name for t in get_transitions()] - + # Fallback: look for transition methods transitions = [] for attr_name in dir(instance): if attr_name.startswith('transition_to_'): transitions.append(attr_name) - + return transitions @@ -310,22 +311,22 @@ def create_transition_context( instance: Any, from_state: str, to_state: str, - user: Optional[Any] = None, + user: Any | None = None, **extra ) -> dict: """ Create a mock transition context dictionary. - + Args: instance: Model instance being transitioned from_state: Source state to_state: Target state user: User performing the transition **extra: Additional context data - + Returns: Dictionary matching TransitionContext structure - + Example: context = create_transition_context(submission, 'PENDING', 'APPROVED', moderator) """ diff --git a/backend/apps/core/state_machine/tests/test_builder.py b/backend/apps/core/state_machine/tests/test_builder.py index 6fc19038..b71e461a 100644 --- a/backend/apps/core/state_machine/tests/test_builder.py +++ b/backend/apps/core/state_machine/tests/test_builder.py @@ -2,7 +2,7 @@ import pytest from django.core.exceptions import ImproperlyConfigured -from apps.core.choices.base import RichChoice, ChoiceCategory +from apps.core.choices.base import ChoiceCategory, RichChoice from apps.core.choices.registry import registry from apps.core.state_machine.builder import StateTransitionBuilder diff --git a/backend/apps/core/state_machine/tests/test_callbacks.py b/backend/apps/core/state_machine/tests/test_callbacks.py index d771faa1..58d5bb05 100644 --- a/backend/apps/core/state_machine/tests/test_callbacks.py +++ b/backend/apps/core/state_machine/tests/test_callbacks.py @@ -9,14 +9,15 @@ This module tests: - Callback context handling """ +from typing import Any +from unittest.mock import Mock, patch + from django.test import TestCase -from unittest.mock import Mock, patch, call -from typing import Any, Dict, List class CallbackContext: """Mock context for testing callbacks.""" - + def __init__( self, instance: Any = None, @@ -30,8 +31,8 @@ class CallbackContext: self.to_state = to_state self.user = user self.extra = extra - - def to_dict(self) -> Dict[str, Any]: + + def to_dict(self) -> dict[str, Any]: return { 'instance': self.instance, 'from_state': self.from_state, @@ -43,179 +44,179 @@ class CallbackContext: class MockCallback: """Mock callback for testing.""" - + def __init__(self, name: str = 'callback', should_raise: bool = False): self.name = name - self.calls: List[Dict] = [] + self.calls: list[dict] = [] self.should_raise = should_raise - - def __call__(self, context: Dict[str, Any]) -> None: + + def __call__(self, context: dict[str, Any]) -> None: self.calls.append(context) if self.should_raise: raise ValueError(f"Callback {self.name} failed") - + @property def call_count(self) -> int: return len(self.calls) - + def was_called(self) -> bool: return len(self.calls) > 0 - + def reset(self): self.calls = [] class PreTransitionCallbackTests(TestCase): """Tests for pre-transition callbacks.""" - + def test_pre_callback_executes_before_state_change(self): """Test that pre-transition callback executes before state changes.""" callback = MockCallback('pre_callback') context = CallbackContext(from_state='PENDING', to_state='APPROVED') - + # Simulate pre-transition execution callback(context.to_dict()) - + self.assertTrue(callback.was_called()) self.assertEqual(callback.calls[0]['from_state'], 'PENDING') self.assertEqual(callback.calls[0]['to_state'], 'APPROVED') - + def test_pre_callback_receives_instance(self): """Test that pre-callback receives the model instance.""" mock_instance = Mock() mock_instance.id = 123 mock_instance.status = 'PENDING' - + callback = MockCallback() context = CallbackContext(instance=mock_instance) - + callback(context.to_dict()) - + self.assertEqual(callback.calls[0]['instance'], mock_instance) - + def test_pre_callback_receives_user(self): """Test that pre-callback receives the user performing transition.""" mock_user = Mock() mock_user.username = 'moderator' - + callback = MockCallback() context = CallbackContext(user=mock_user) - + callback(context.to_dict()) - + self.assertEqual(callback.calls[0]['user'], mock_user) - + def test_pre_callback_can_prevent_transition(self): """Test that pre-callback can prevent transition by raising exception.""" callback = MockCallback(should_raise=True) context = CallbackContext() - + with self.assertRaises(ValueError): callback(context.to_dict()) - + def test_multiple_pre_callbacks_execute_in_order(self): """Test that multiple pre-callbacks execute in registration order.""" execution_order = [] - + def callback_1(ctx): execution_order.append('first') - + def callback_2(ctx): execution_order.append('second') - + def callback_3(ctx): execution_order.append('third') - + context = CallbackContext().to_dict() - + # Execute in order callback_1(context) callback_2(context) callback_3(context) - + self.assertEqual(execution_order, ['first', 'second', 'third']) class PostTransitionCallbackTests(TestCase): """Tests for post-transition callbacks.""" - + def test_post_callback_executes_after_state_change(self): """Test that post-transition callback executes after state changes.""" callback = MockCallback('post_callback') - + # Simulate instance after transition mock_instance = Mock() mock_instance.status = 'APPROVED' # Already changed - + context = CallbackContext( instance=mock_instance, from_state='PENDING', to_state='APPROVED' ) - + callback(context.to_dict()) - + self.assertTrue(callback.was_called()) self.assertEqual(callback.calls[0]['instance'].status, 'APPROVED') - + def test_post_callback_receives_updated_instance(self): """Test that post-callback receives instance with new state.""" mock_instance = Mock() mock_instance.status = 'APPROVED' mock_instance.approved_at = '2025-01-15' mock_instance.handled_by_id = 456 - + callback = MockCallback() context = CallbackContext(instance=mock_instance) - + callback(context.to_dict()) - + instance = callback.calls[0]['instance'] self.assertEqual(instance.status, 'APPROVED') self.assertEqual(instance.approved_at, '2025-01-15') - + def test_post_callback_failure_does_not_rollback(self): """Test that post-callback failures don't rollback the transition.""" # In a real scenario, the transition would already be committed callback = MockCallback(should_raise=True) context = CallbackContext() - + # Post-callback failure should not affect already-committed transition with self.assertRaises(ValueError): callback(context.to_dict()) - + # The transition would still be committed in real usage self.assertTrue(callback.was_called()) - + def test_multiple_post_callbacks_execute_in_order(self): """Test that multiple post-callbacks execute in order.""" execution_order = [] - + def notification_callback(ctx): execution_order.append('notification') - + def cache_callback(ctx): execution_order.append('cache') - + def analytics_callback(ctx): execution_order.append('analytics') - + context = CallbackContext().to_dict() - + notification_callback(context) cache_callback(context) analytics_callback(context) - + self.assertEqual(execution_order, ['notification', 'cache', 'analytics']) class ErrorCallbackTests(TestCase): """Tests for error callbacks.""" - + def test_error_callback_receives_exception(self): """Test that error callback receives exception information.""" error_callback = MockCallback() - + try: raise ValueError("Transition failed") except ValueError as e: @@ -227,33 +228,33 @@ class ErrorCallbackTests(TestCase): 'exception_type': type(e).__name__ } error_callback(error_context) - + self.assertTrue(error_callback.was_called()) self.assertIn('exception', error_callback.calls[0]) self.assertEqual(error_callback.calls[0]['exception_type'], 'ValueError') - + def test_error_callback_for_cleanup(self): """Test that error callbacks can perform cleanup.""" cleanup_performed = [] - + def cleanup_callback(ctx): cleanup_performed.append(True) # In real usage, might release locks, revert partial changes, etc. - + try: raise ValueError("Transition failed") except ValueError: cleanup_callback({'exception': 'test'}) - + self.assertTrue(cleanup_performed) - + def test_error_callback_receives_context(self): """Test that error callback receives full transition context.""" mock_instance = Mock() mock_user = Mock() - + error_callback = MockCallback() - + error_context = { 'instance': mock_instance, 'from_state': 'PENDING', @@ -261,152 +262,152 @@ class ErrorCallbackTests(TestCase): 'user': mock_user, 'exception': ValueError("Test error") } - + error_callback(error_context) - + self.assertEqual(error_callback.calls[0]['instance'], mock_instance) self.assertEqual(error_callback.calls[0]['user'], mock_user) class ConditionalCallbackTests(TestCase): """Tests for conditional callback execution.""" - + def test_callback_with_state_filter(self): """Test callback that only executes for specific states.""" execution_log = [] - + def approval_only_callback(ctx): if ctx.get('to_state') == 'APPROVED': execution_log.append('approved') - + # Transition to APPROVED - should execute approval_only_callback({'to_state': 'APPROVED'}) self.assertEqual(len(execution_log), 1) - + # Transition to REJECTED - should not execute approval_only_callback({'to_state': 'REJECTED'}) self.assertEqual(len(execution_log), 1) # Still 1 - + def test_callback_with_transition_filter(self): """Test callback that only executes for specific transitions.""" execution_log = [] - + def escalation_callback(ctx): if ctx.get('to_state') == 'ESCALATED': execution_log.append('escalated') - + # Escalation - should execute escalation_callback({'to_state': 'ESCALATED'}) self.assertEqual(len(execution_log), 1) - + # Other transitions - should not execute escalation_callback({'to_state': 'APPROVED'}) self.assertEqual(len(execution_log), 1) - + def test_callback_with_user_role_filter(self): """Test callback that checks user role.""" admin_notifications = [] - + def admin_only_notification(ctx): user = ctx.get('user') if user and getattr(user, 'role', None) == 'ADMIN': admin_notifications.append(ctx) - + admin_user = Mock(role='ADMIN') moderator_user = Mock(role='MODERATOR') - + admin_only_notification({'user': admin_user}) self.assertEqual(len(admin_notifications), 1) - + admin_only_notification({'user': moderator_user}) self.assertEqual(len(admin_notifications), 1) # Still 1 class CallbackChainTests(TestCase): """Tests for callback chains and pipelines.""" - + def test_callback_chain_continues_on_success(self): """Test that callback chain continues when callbacks succeed.""" results = [] - + callbacks = [ lambda ctx: results.append('a'), lambda ctx: results.append('b'), lambda ctx: results.append('c'), ] - + context = {} for cb in callbacks: cb(context) - + self.assertEqual(results, ['a', 'b', 'c']) - + def test_callback_chain_stops_on_failure(self): """Test that callback chain stops when a callback fails.""" results = [] - + def callback_a(ctx): results.append('a') - + def callback_b(ctx): raise ValueError("B failed") - + def callback_c(ctx): results.append('c') - + callbacks = [callback_a, callback_b, callback_c] - + context = {} for cb in callbacks: try: cb(context) except ValueError: break - + self.assertEqual(results, ['a']) # c never executed - + def test_callback_chain_with_continue_on_error(self): """Test callback chain that continues despite errors.""" results = [] errors = [] - + def callback_a(ctx): results.append('a') - + def callback_b(ctx): raise ValueError("B failed") - + def callback_c(ctx): results.append('c') - + callbacks = [callback_a, callback_b, callback_c] - + context = {} for cb in callbacks: try: cb(context) except Exception as e: errors.append(str(e)) - + self.assertEqual(results, ['a', 'c']) self.assertEqual(len(errors), 1) class CallbackContextEnrichmentTests(TestCase): """Tests for callback context enrichment.""" - + def test_context_includes_model_class(self): """Test that context includes the model class.""" mock_instance = Mock() mock_instance.__class__.__name__ = 'EditSubmission' - + context = { 'instance': mock_instance, 'model_class': type(mock_instance) } - + self.assertIn('model_class', context) - + def test_context_includes_transition_name(self): """Test that context includes the transition method name.""" context = { @@ -415,9 +416,9 @@ class CallbackContextEnrichmentTests(TestCase): 'to_state': 'APPROVED', 'transition_name': 'transition_to_approved' } - + self.assertEqual(context['transition_name'], 'transition_to_approved') - + def test_context_includes_timestamp(self): """Test that context includes transition timestamp.""" from django.utils import timezone @@ -452,9 +453,10 @@ class NotificationCallbackTests(TestCase): instance=None, ): """Helper to create a TransitionContext.""" - from ..callback_base import TransitionContext from django.utils import timezone + from ..callback_base import TransitionContext + if instance is None: instance = Mock() instance.pk = 123 @@ -603,9 +605,10 @@ class CacheCallbackTests(TestCase): target_state: str = 'CLOSED_TEMP', ): """Helper to create a TransitionContext.""" - from ..callback_base import TransitionContext from django.utils import timezone + from ..callback_base import TransitionContext + instance = Mock() instance.pk = instance_id instance.__class__.__name__ = model_name @@ -703,7 +706,7 @@ class CacheCallbackTests(TestCase): context = self._create_transition_context() # Should not raise overall - result = callback.execute(context) + callback.execute(context) # All patterns should have been attempted self.assertGreater(call_count, 1) @@ -716,9 +719,10 @@ class ModelCacheInvalidationTests(TestCase): model_name: str = 'Ride', instance_id: int = 789, ): - from ..callback_base import TransitionContext from django.utils import timezone + from ..callback_base import TransitionContext + instance = Mock() instance.pk = instance_id instance.__class__.__name__ = model_name @@ -771,7 +775,7 @@ class RelatedUpdateCallbackTests(TestCase): def setUp(self): """Set up test fixtures.""" from django.contrib.auth import get_user_model - User = get_user_model() + get_user_model() self.user = Mock() self.user.pk = 1 @@ -783,9 +787,10 @@ class RelatedUpdateCallbackTests(TestCase): instance=None, target_state: str = 'OPERATING', ): - from ..callback_base import TransitionContext from django.utils import timezone + from ..callback_base import TransitionContext + if instance is None: instance = Mock() instance.pk = 123 @@ -920,9 +925,10 @@ class CallbackErrorHandlingTests(TestCase): """Tests for callback error handling paths.""" def _create_transition_context(self): - from ..callback_base import TransitionContext from django.utils import timezone + from ..callback_base import TransitionContext + instance = Mock() instance.pk = 1 instance.__class__.__name__ = 'EditSubmission' @@ -939,9 +945,10 @@ class CallbackErrorHandlingTests(TestCase): @patch('apps.core.state_machine.callbacks.notifications.NotificationService') def test_notification_callback_logs_error_on_failure(self, mock_service_class): """Test NotificationCallback logs errors when service fails.""" - from ..callbacks.notifications import NotificationCallback import logging + from ..callbacks.notifications import NotificationCallback + mock_service = Mock() mock_service.send_notification = Mock(side_effect=Exception("Network error")) mock_service_class.return_value = mock_service @@ -949,7 +956,7 @@ class CallbackErrorHandlingTests(TestCase): callback = NotificationCallback() context = self._create_transition_context() - with self.assertLogs(level=logging.WARNING) as log_output: + with self.assertLogs(level=logging.WARNING): try: callback.execute(context) except Exception: @@ -979,9 +986,10 @@ class CallbackErrorHandlingTests(TestCase): def test_callback_with_none_user(self): """Test callbacks handle None user gracefully.""" + from django.utils import timezone + from ..callback_base import TransitionContext from ..callbacks.notifications import NotificationCallback - from django.utils import timezone instance = Mock() instance.pk = 1 diff --git a/backend/apps/core/state_machine/tests/test_decorators.py b/backend/apps/core/state_machine/tests/test_decorators.py index 29366361..fc4f7c6d 100644 --- a/backend/apps/core/state_machine/tests/test_decorators.py +++ b/backend/apps/core/state_machine/tests/test_decorators.py @@ -1,11 +1,10 @@ """Tests for transition decorator generation.""" -import pytest from unittest.mock import Mock from apps.core.state_machine.decorators import ( - generate_transition_decorator, - create_transition_method, TransitionMethodFactory, + create_transition_method, + generate_transition_decorator, with_transition_logging, ) diff --git a/backend/apps/core/state_machine/tests/test_guards.py b/backend/apps/core/state_machine/tests/test_guards.py index cb6535a5..8fd75565 100644 --- a/backend/apps/core/state_machine/tests/test_guards.py +++ b/backend/apps/core/state_machine/tests/test_guards.py @@ -10,33 +10,30 @@ This module contains tests for: - CompositeGuard (combining guards with AND/OR logic) """ -from django.test import TestCase from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser +from django.test import TestCase from apps.core.state_machine.guards import ( - PermissionGuard, - OwnershipGuard, + ADMIN_ROLES, + MODERATOR_ROLES, AssignmentGuard, - StateGuard, - MetadataGuard, CompositeGuard, - extract_guards_from_metadata, - create_permission_guard, - create_ownership_guard, + MetadataGuard, + OwnershipGuard, + PermissionGuard, + StateGuard, create_assignment_guard, create_composite_guard, - validate_guard_metadata, + create_ownership_guard, + create_permission_guard, + extract_guards_from_metadata, get_user_role, has_role, - is_moderator_or_above, is_admin_or_above, + is_moderator_or_above, is_superuser_role, - has_permission, - VALID_ROLES, - MODERATOR_ROLES, - ADMIN_ROLES, - SUPERUSER_ROLES, + validate_guard_metadata, ) User = get_user_model() @@ -44,7 +41,7 @@ User = get_user_model() class MockInstance: """Mock instance for testing guards.""" - + def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) @@ -89,90 +86,90 @@ class PermissionGuardTests(TestCase): def test_no_user_fails(self): """Test that guard fails when no user is provided.""" guard = PermissionGuard(requires_moderator=True) - + result = guard(self.instance, user=None) - + self.assertFalse(result) self.assertEqual(guard.error_code, PermissionGuard.ERROR_CODE_NO_USER) def test_requires_moderator_allows_moderator(self): """Test that requires_moderator allows moderator role.""" guard = PermissionGuard(requires_moderator=True) - + result = guard(self.instance, user=self.moderator) - + self.assertTrue(result) def test_requires_moderator_allows_admin(self): """Test that requires_moderator allows admin role.""" guard = PermissionGuard(requires_moderator=True) - + result = guard(self.instance, user=self.admin) - + self.assertTrue(result) def test_requires_moderator_allows_superuser(self): """Test that requires_moderator allows superuser role.""" guard = PermissionGuard(requires_moderator=True) - + result = guard(self.instance, user=self.superuser) - + self.assertTrue(result) def test_requires_moderator_denies_regular_user(self): """Test that requires_moderator denies regular user.""" guard = PermissionGuard(requires_moderator=True) - + result = guard(self.instance, user=self.regular_user) - + self.assertFalse(result) self.assertEqual(guard.error_code, PermissionGuard.ERROR_CODE_PERMISSION_DENIED_ROLE) def test_requires_admin_allows_admin(self): """Test that requires_admin allows admin role.""" guard = PermissionGuard(requires_admin=True) - + result = guard(self.instance, user=self.admin) - + self.assertTrue(result) def test_requires_admin_allows_superuser(self): """Test that requires_admin allows superuser role.""" guard = PermissionGuard(requires_admin=True) - + result = guard(self.instance, user=self.superuser) - + self.assertTrue(result) def test_requires_admin_denies_moderator(self): """Test that requires_admin denies moderator role.""" guard = PermissionGuard(requires_admin=True) - + result = guard(self.instance, user=self.moderator) - + self.assertFalse(result) self.assertEqual(guard.error_code, PermissionGuard.ERROR_CODE_PERMISSION_DENIED_ROLE) def test_requires_superuser_allows_superuser(self): """Test that requires_superuser allows superuser role.""" guard = PermissionGuard(requires_superuser=True) - + result = guard(self.instance, user=self.superuser) - + self.assertTrue(result) def test_requires_superuser_denies_admin(self): """Test that requires_superuser denies admin role.""" guard = PermissionGuard(requires_superuser=True) - + result = guard(self.instance, user=self.admin) - + self.assertFalse(result) def test_required_roles_explicit_list(self): """Test using explicit required_roles list.""" guard = PermissionGuard(required_roles=['ADMIN', 'SUPERUSER']) - + self.assertTrue(guard(self.instance, user=self.admin)) self.assertTrue(guard(self.instance, user=self.superuser)) self.assertFalse(guard(self.instance, user=self.moderator)) @@ -182,24 +179,24 @@ class PermissionGuardTests(TestCase): """Test custom check function that passes.""" def custom_check(instance, user): return hasattr(instance, 'allow_access') and instance.allow_access - + guard = PermissionGuard(custom_check=custom_check) instance = MockInstance(allow_access=True) - + result = guard(instance, user=self.regular_user) - + self.assertTrue(result) def test_custom_check_fails(self): """Test custom check function that fails.""" def custom_check(instance, user): return hasattr(instance, 'allow_access') and instance.allow_access - + guard = PermissionGuard(custom_check=custom_check) instance = MockInstance(allow_access=False) - + result = guard(instance, user=self.regular_user) - + self.assertFalse(result) self.assertEqual(guard.error_code, PermissionGuard.ERROR_CODE_PERMISSION_DENIED_CUSTOM) @@ -207,25 +204,25 @@ class PermissionGuardTests(TestCase): """Test custom error message.""" custom_message = "You need special access for this" guard = PermissionGuard(requires_moderator=True, error_message=custom_message) - + guard(self.instance, user=self.regular_user) - + self.assertEqual(guard.get_error_message(), custom_message) def test_get_required_roles_moderator(self): """Test get_required_roles for moderator requirement.""" guard = PermissionGuard(requires_moderator=True) - + roles = guard.get_required_roles() - + self.assertEqual(set(roles), set(MODERATOR_ROLES)) def test_get_required_roles_admin(self): """Test get_required_roles for admin requirement.""" guard = PermissionGuard(requires_admin=True) - + roles = guard.get_required_roles() - + self.assertEqual(set(roles), set(ADMIN_ROLES)) @@ -268,9 +265,9 @@ class OwnershipGuardTests(TestCase): """Test that guard fails when no user is provided.""" instance = MockInstance(created_by=self.owner) guard = OwnershipGuard() - + result = guard(instance, user=None) - + self.assertFalse(result) self.assertEqual(guard.error_code, OwnershipGuard.ERROR_CODE_NO_USER) @@ -278,36 +275,36 @@ class OwnershipGuardTests(TestCase): """Test that owner passes via created_by field.""" instance = MockInstance(created_by=self.owner) guard = OwnershipGuard() - + result = guard(instance, user=self.owner) - + self.assertTrue(result) def test_owner_passes_user_field(self): """Test that owner passes via user field.""" instance = MockInstance(user=self.owner) guard = OwnershipGuard() - + result = guard(instance, user=self.owner) - + self.assertTrue(result) def test_owner_passes_submitted_by(self): """Test that owner passes via submitted_by field.""" instance = MockInstance(submitted_by=self.owner) guard = OwnershipGuard() - + result = guard(instance, user=self.owner) - + self.assertTrue(result) def test_non_owner_fails(self): """Test that non-owner fails.""" instance = MockInstance(created_by=self.owner) guard = OwnershipGuard() - + result = guard(instance, user=self.other_user) - + self.assertFalse(result) self.assertEqual(guard.error_code, OwnershipGuard.ERROR_CODE_NOT_OWNER) @@ -315,27 +312,27 @@ class OwnershipGuardTests(TestCase): """Test that moderator can bypass ownership check.""" instance = MockInstance(created_by=self.owner) guard = OwnershipGuard(allow_moderator_override=True) - + result = guard(instance, user=self.moderator) - + self.assertTrue(result) def test_admin_override(self): """Test that admin can bypass ownership check.""" instance = MockInstance(created_by=self.owner) guard = OwnershipGuard(allow_admin_override=True) - + result = guard(instance, user=self.admin) - + self.assertTrue(result) def test_custom_owner_fields(self): """Test custom owner field names.""" instance = MockInstance(author=self.owner) guard = OwnershipGuard(owner_fields=['author']) - + result = guard(instance, user=self.owner) - + self.assertTrue(result) def test_anonymous_user_fails(self): @@ -343,9 +340,9 @@ class OwnershipGuardTests(TestCase): instance = MockInstance(created_by=self.owner) guard = OwnershipGuard() anonymous = AnonymousUser() - + result = guard(instance, user=anonymous) - + self.assertFalse(result) @@ -382,9 +379,9 @@ class AssignmentGuardTests(TestCase): """Test that guard fails when no user is provided.""" instance = MockInstance(assigned_to=self.assigned_user) guard = AssignmentGuard() - + result = guard(instance, user=None) - + self.assertFalse(result) self.assertEqual(guard.error_code, AssignmentGuard.ERROR_CODE_NO_USER) @@ -392,18 +389,18 @@ class AssignmentGuardTests(TestCase): """Test that assigned user passes.""" instance = MockInstance(assigned_to=self.assigned_user) guard = AssignmentGuard() - + result = guard(instance, user=self.assigned_user) - + self.assertTrue(result) def test_unassigned_user_fails(self): """Test that unassigned user fails.""" instance = MockInstance(assigned_to=self.assigned_user) guard = AssignmentGuard() - + result = guard(instance, user=self.other_user) - + self.assertFalse(result) self.assertEqual(guard.error_code, AssignmentGuard.ERROR_CODE_NOT_ASSIGNED) @@ -411,18 +408,18 @@ class AssignmentGuardTests(TestCase): """Test that admin can bypass assignment check.""" instance = MockInstance(assigned_to=self.assigned_user) guard = AssignmentGuard(allow_admin_override=True) - + result = guard(instance, user=self.admin) - + self.assertTrue(result) def test_require_assignment_with_no_assignment(self): """Test require_assignment fails when no one is assigned.""" instance = MockInstance(assigned_to=None) guard = AssignmentGuard(require_assignment=True) - + result = guard(instance, user=self.assigned_user) - + self.assertFalse(result) self.assertEqual(guard.error_code, AssignmentGuard.ERROR_CODE_NO_ASSIGNMENT) @@ -430,18 +427,18 @@ class AssignmentGuardTests(TestCase): """Test custom assignment field names.""" instance = MockInstance(reviewer=self.assigned_user) guard = AssignmentGuard(assignment_fields=['reviewer']) - + result = guard(instance, user=self.assigned_user) - + self.assertTrue(result) def test_error_message_for_no_assignment(self): """Test error message when no assignment exists.""" instance = MockInstance(assigned_to=None) guard = AssignmentGuard(require_assignment=True) - + guard(instance, user=self.assigned_user) - + self.assertIn('assigned', guard.get_error_message().lower()) @@ -466,18 +463,18 @@ class StateGuardTests(TestCase): """Test that guard passes when in allowed state.""" instance = MockInstance(status='PENDING') guard = StateGuard(allowed_states=['PENDING', 'UNDER_REVIEW']) - + result = guard(instance, user=self.user) - + self.assertTrue(result) def test_allowed_states_fails(self): """Test that guard fails when not in allowed state.""" instance = MockInstance(status='COMPLETED') guard = StateGuard(allowed_states=['PENDING', 'UNDER_REVIEW']) - + result = guard(instance, user=self.user) - + self.assertFalse(result) self.assertEqual(guard.error_code, StateGuard.ERROR_CODE_INVALID_STATE) @@ -485,18 +482,18 @@ class StateGuardTests(TestCase): """Test that guard passes when not in blocked state.""" instance = MockInstance(status='PENDING') guard = StateGuard(blocked_states=['COMPLETED', 'CANCELLED']) - + result = guard(instance, user=self.user) - + self.assertTrue(result) def test_blocked_states_fails(self): """Test that guard fails when in blocked state.""" instance = MockInstance(status='COMPLETED') guard = StateGuard(blocked_states=['COMPLETED', 'CANCELLED']) - + result = guard(instance, user=self.user) - + self.assertFalse(result) self.assertEqual(guard.error_code, StateGuard.ERROR_CODE_BLOCKED_STATE) @@ -504,18 +501,18 @@ class StateGuardTests(TestCase): """Test using custom state field name.""" instance = MockInstance(workflow_status='ACTIVE') guard = StateGuard(allowed_states=['ACTIVE'], state_field='workflow_status') - + result = guard(instance, user=self.user) - + self.assertTrue(result) def test_error_message_includes_states(self): """Test that error message includes allowed states.""" instance = MockInstance(status='COMPLETED') guard = StateGuard(allowed_states=['PENDING', 'UNDER_REVIEW']) - + guard(instance, user=self.user) - + message = guard.get_error_message() self.assertIn('PENDING', message) self.assertIn('UNDER_REVIEW', message) @@ -542,18 +539,18 @@ class MetadataGuardTests(TestCase): """Test that guard passes when required fields are present.""" instance = MockInstance(resolution_notes='Fixed', assigned_to='user') guard = MetadataGuard(required_fields=['resolution_notes', 'assigned_to']) - + result = guard(instance, user=self.user) - + self.assertTrue(result) def test_required_field_missing(self): """Test that guard fails when required field is missing.""" instance = MockInstance(resolution_notes='Fixed') guard = MetadataGuard(required_fields=['resolution_notes', 'assigned_to']) - + result = guard(instance, user=self.user) - + self.assertFalse(result) self.assertEqual(guard.error_code, MetadataGuard.ERROR_CODE_MISSING_FIELD) @@ -561,9 +558,9 @@ class MetadataGuardTests(TestCase): """Test that guard fails when required field is None.""" instance = MockInstance(resolution_notes=None) guard = MetadataGuard(required_fields=['resolution_notes']) - + result = guard(instance, user=self.user) - + self.assertFalse(result) self.assertEqual(guard.error_code, MetadataGuard.ERROR_CODE_MISSING_FIELD) @@ -571,9 +568,9 @@ class MetadataGuardTests(TestCase): """Test that empty string fails when check_not_empty is True.""" instance = MockInstance(resolution_notes=' ') guard = MetadataGuard(required_fields=['resolution_notes'], check_not_empty=True) - + result = guard(instance, user=self.user) - + self.assertFalse(result) self.assertEqual(guard.error_code, MetadataGuard.ERROR_CODE_EMPTY_FIELD) @@ -581,9 +578,9 @@ class MetadataGuardTests(TestCase): """Test that empty list fails when check_not_empty is True.""" instance = MockInstance(tags=[]) guard = MetadataGuard(required_fields=['tags'], check_not_empty=True) - + result = guard(instance, user=self.user) - + self.assertFalse(result) self.assertEqual(guard.error_code, MetadataGuard.ERROR_CODE_EMPTY_FIELD) @@ -591,9 +588,9 @@ class MetadataGuardTests(TestCase): """Test that empty dict fails when check_not_empty is True.""" instance = MockInstance(metadata={}) guard = MetadataGuard(required_fields=['metadata'], check_not_empty=True) - + result = guard(instance, user=self.user) - + self.assertFalse(result) self.assertEqual(guard.error_code, MetadataGuard.ERROR_CODE_EMPTY_FIELD) @@ -601,9 +598,9 @@ class MetadataGuardTests(TestCase): """Test that error message includes the field name.""" instance = MockInstance(resolution_notes=None) guard = MetadataGuard(required_fields=['resolution_notes']) - + guard(instance, user=self.user) - + message = guard.get_error_message() self.assertIn('Resolution Notes', message) @@ -645,9 +642,9 @@ class CompositeGuardTests(TestCase): OwnershipGuard() ] composite = CompositeGuard(guards, operator='AND') - + result = composite(instance, user=self.moderator) - + self.assertTrue(result) def test_and_operator_one_fails(self): @@ -658,9 +655,9 @@ class CompositeGuardTests(TestCase): OwnershipGuard() # Will fail - moderator is not owner ] composite = CompositeGuard(guards, operator='AND') - + result = composite(instance, user=self.non_owner_moderator) - + self.assertFalse(result) self.assertEqual(composite.error_code, CompositeGuard.ERROR_CODE_SOME_FAILED) @@ -672,9 +669,9 @@ class CompositeGuardTests(TestCase): OwnershipGuard() # Will pass - user is owner ] composite = CompositeGuard(guards, operator='OR') - + result = composite(instance, user=self.owner) - + self.assertTrue(result) def test_or_operator_all_fail(self): @@ -685,30 +682,30 @@ class CompositeGuardTests(TestCase): OwnershipGuard() # Not the owner fails ] composite = CompositeGuard(guards, operator='OR') - + result = composite(instance, user=self.owner) - + self.assertFalse(result) self.assertEqual(composite.error_code, CompositeGuard.ERROR_CODE_ALL_FAILED) def test_nested_composite_guards(self): """Test nested composite guards.""" instance = MockInstance(created_by=self.moderator, status='PENDING') - + # Inner composite: moderator OR owner inner = CompositeGuard([ PermissionGuard(requires_moderator=True), OwnershipGuard() ], operator='OR') - + # Outer composite: (moderator OR owner) AND valid state outer = CompositeGuard([ inner, StateGuard(allowed_states=['PENDING']) ], operator='AND') - + result = outer(instance, user=self.moderator) - + self.assertTrue(result) def test_error_message_from_failed_guard(self): @@ -717,9 +714,9 @@ class CompositeGuardTests(TestCase): perm_guard = PermissionGuard(requires_admin=True) guards = [perm_guard] composite = CompositeGuard(guards, operator='AND') - + composite(instance, user=self.owner) - + message = composite.get_error_message() self.assertIn('admin', message.lower()) @@ -746,42 +743,42 @@ class GuardFactoryTests(TestCase): metadata = {'requires_moderator': True} guard = create_permission_guard(metadata) instance = MockInstance() - + result = guard(instance, user=self.moderator) - + self.assertTrue(result) def test_create_permission_guard_admin(self): """Test create_permission_guard with admin requirement.""" metadata = {'requires_admin_approval': True} guard = create_permission_guard(metadata) - + self.assertTrue(guard.requires_admin) def test_create_permission_guard_escalation_level(self): """Test create_permission_guard with escalation level.""" metadata = {'escalation_level': 'admin'} guard = create_permission_guard(metadata) - + self.assertTrue(guard.requires_admin) def test_create_ownership_guard(self): """Test create_ownership_guard factory.""" guard = create_ownership_guard(allow_moderator_override=True) - + self.assertTrue(guard.allow_moderator_override) def test_create_assignment_guard(self): """Test create_assignment_guard factory.""" guard = create_assignment_guard(require_assignment=True) - + self.assertTrue(guard.require_assignment) def test_create_composite_guard(self): """Test create_composite_guard factory.""" guards = [PermissionGuard(), OwnershipGuard()] composite = create_composite_guard(guards, operator='OR') - + self.assertEqual(composite.operator, 'OR') self.assertEqual(len(composite.guards), 2) @@ -798,7 +795,7 @@ class MetadataExtractionTests(TestCase): """Test extracting guard for moderator requirement.""" metadata = {'requires_moderator': True} guards = extract_guards_from_metadata(metadata) - + self.assertEqual(len(guards), 1) self.assertIsInstance(guards[0], PermissionGuard) @@ -806,7 +803,7 @@ class MetadataExtractionTests(TestCase): """Test extracting guard for admin requirement.""" metadata = {'requires_admin_approval': True} guards = extract_guards_from_metadata(metadata) - + self.assertEqual(len(guards), 1) self.assertTrue(guards[0].requires_admin) @@ -814,7 +811,7 @@ class MetadataExtractionTests(TestCase): """Test extracting assignment guard.""" metadata = {'requires_assignment': True} guards = extract_guards_from_metadata(metadata) - + self.assertEqual(len(guards), 1) self.assertIsInstance(guards[0], AssignmentGuard) @@ -825,21 +822,21 @@ class MetadataExtractionTests(TestCase): 'requires_assignment': True } guards = extract_guards_from_metadata(metadata) - + self.assertEqual(len(guards), 2) def test_extract_zero_tolerance_guard(self): """Test extracting guard for zero tolerance (superuser required).""" metadata = {'zero_tolerance': True} guards = extract_guards_from_metadata(metadata) - + self.assertEqual(len(guards), 1) self.assertTrue(guards[0].requires_superuser) def test_invalid_escalation_level_raises(self): """Test that invalid escalation level raises ValueError.""" metadata = {'escalation_level': 'invalid'} - + with self.assertRaises(ValueError): extract_guards_from_metadata(metadata) @@ -859,36 +856,36 @@ class MetadataValidationTests(TestCase): 'escalation_level': 'admin', 'requires_assignment': False } - + is_valid, errors = validate_guard_metadata(metadata) - + self.assertTrue(is_valid) self.assertEqual(len(errors), 0) def test_invalid_escalation_level(self): """Test that invalid escalation level fails validation.""" metadata = {'escalation_level': 'invalid_level'} - + is_valid, errors = validate_guard_metadata(metadata) - + self.assertFalse(is_valid) self.assertTrue(any('escalation_level' in e for e in errors)) def test_invalid_boolean_field(self): """Test that non-boolean value for boolean field fails validation.""" metadata = {'requires_moderator': 'yes'} - + is_valid, errors = validate_guard_metadata(metadata) - + self.assertFalse(is_valid) self.assertTrue(any('requires_moderator' in e for e in errors)) def test_required_permissions_not_list(self): """Test that non-list required_permissions fails validation.""" metadata = {'required_permissions': 'app.permission'} - + is_valid, errors = validate_guard_metadata(metadata) - + self.assertFalse(is_valid) self.assertTrue(any('required_permissions' in e for e in errors)) @@ -965,7 +962,7 @@ class RoleHelperTests(TestCase): def test_anonymous_user_has_no_role(self): """Test that anonymous user has no role.""" anonymous = AnonymousUser() - + self.assertFalse(has_role(anonymous, ['USER'])) self.assertFalse(is_moderator_or_above(anonymous)) self.assertFalse(is_admin_or_above(anonymous)) diff --git a/backend/apps/core/state_machine/tests/test_integration.py b/backend/apps/core/state_machine/tests/test_integration.py index f8d58ba6..14031fa1 100644 --- a/backend/apps/core/state_machine/tests/test_integration.py +++ b/backend/apps/core/state_machine/tests/test_integration.py @@ -1,14 +1,14 @@ """Integration tests for state machine model integration.""" -import pytest from unittest.mock import Mock, patch -from django.core.exceptions import ImproperlyConfigured + +import pytest from apps.core.choices.base import RichChoice from apps.core.choices.registry import registry from apps.core.state_machine.integration import ( + StateMachineModelMixin, apply_state_machine, generate_transition_methods_for_model, - StateMachineModelMixin, state_machine_model, validate_model_state_machine, ) diff --git a/backend/apps/core/state_machine/tests/test_registry.py b/backend/apps/core/state_machine/tests/test_registry.py index 5e9d63a6..b622b54a 100644 --- a/backend/apps/core/state_machine/tests/test_registry.py +++ b/backend/apps/core/state_machine/tests/test_registry.py @@ -4,8 +4,8 @@ import pytest from apps.core.choices.base import RichChoice from apps.core.choices.registry import registry from apps.core.state_machine.registry import ( - TransitionRegistry, TransitionInfo, + TransitionRegistry, registry_instance, ) diff --git a/backend/apps/core/state_machine/tests/test_validators.py b/backend/apps/core/state_machine/tests/test_validators.py index c24f3e8f..8efc3a95 100644 --- a/backend/apps/core/state_machine/tests/test_validators.py +++ b/backend/apps/core/state_machine/tests/test_validators.py @@ -5,8 +5,8 @@ from apps.core.choices.base import RichChoice from apps.core.choices.registry import registry from apps.core.state_machine.validators import ( MetadataValidator, - ValidationResult, ValidationError, + ValidationResult, ValidationWarning, validate_on_registration, ) diff --git a/backend/apps/core/state_machine/validators.py b/backend/apps/core/state_machine/validators.py index 1c87528b..705c8e00 100644 --- a/backend/apps/core/state_machine/validators.py +++ b/backend/apps/core/state_machine/validators.py @@ -1,9 +1,8 @@ """Metadata validators for ensuring RichChoice metadata meets FSM requirements.""" from dataclasses import dataclass, field -from typing import List, Dict, Set, Optional, Any +from typing import Any from apps.core.state_machine.builder import StateTransitionBuilder -from apps.core.choices.registry import registry @dataclass @@ -12,8 +11,8 @@ class ValidationError: code: str message: str - state: Optional[str] = None - metadata: Dict[str, Any] = field(default_factory=dict) + state: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) def __str__(self): """String representation of the error.""" @@ -28,7 +27,7 @@ class ValidationWarning: code: str message: str - state: Optional[str] = None + state: str | None = None def __str__(self): """String representation of the warning.""" @@ -42,15 +41,15 @@ class ValidationResult: """Result of metadata validation.""" is_valid: bool - errors: List[ValidationError] = field(default_factory=list) - warnings: List[ValidationWarning] = field(default_factory=list) + errors: list[ValidationError] = field(default_factory=list) + warnings: list[ValidationWarning] = field(default_factory=list) - def add_error(self, code: str, message: str, state: Optional[str] = None): + def add_error(self, code: str, message: str, state: str | None = None): """Add a validation error.""" self.errors.append(ValidationError(code, message, state)) self.is_valid = False - def add_warning(self, code: str, message: str, state: Optional[str] = None): + def add_warning(self, code: str, message: str, state: str | None = None): """Add a validation warning.""" self.warnings.append(ValidationWarning(code, message, state)) @@ -91,7 +90,7 @@ class MetadataValidator: return result - def validate_transitions(self) -> List[ValidationError]: + def validate_transitions(self) -> list[ValidationError]: """ Check all can_transition_to references exist. @@ -148,7 +147,7 @@ class MetadataValidator: return errors - def validate_terminal_states(self) -> List[ValidationError]: + def validate_terminal_states(self) -> list[ValidationError]: """ Ensure terminal states have no outgoing transitions. @@ -175,7 +174,7 @@ class MetadataValidator: return errors - def validate_permission_consistency(self) -> List[ValidationError]: + def validate_permission_consistency(self) -> list[ValidationError]: """ Check permission requirements are consistent. @@ -206,7 +205,7 @@ class MetadataValidator: return errors - def validate_no_cycles(self) -> List[ValidationError]: + def validate_no_cycles(self) -> list[ValidationError]: """ Detect invalid state cycles (excluding self-loops). @@ -224,10 +223,10 @@ class MetadataValidator: pass # Detect cycles using DFS - visited: Set[str] = set() - rec_stack: Set[str] = set() + visited: set[str] = set() + rec_stack: set[str] = set() - def has_cycle(node: str, path: List[str]) -> Optional[List[str]]: + def has_cycle(node: str, path: list[str]) -> list[str] | None: visited.add(node) rec_stack.add(node) path.append(node) @@ -262,7 +261,7 @@ class MetadataValidator: return errors - def validate_reachability(self) -> List[ValidationError]: + def validate_reachability(self) -> list[ValidationError]: """ Ensure all states are reachable from initial states. @@ -274,7 +273,7 @@ class MetadataValidator: all_states = set(self.builder.get_all_states()) # Find states with no incoming transitions (potential initial states) - incoming: Dict[str, List[str]] = {state: [] for state in all_states} + incoming: dict[str, list[str]] = {state: [] for state in all_states} for source, targets in graph.items(): for target in targets: incoming[target].append(source) @@ -293,7 +292,7 @@ class MetadataValidator: return errors # BFS from initial states to find reachable states - reachable: Set[str] = set(initial_states) + reachable: set[str] = set(initial_states) queue = list(initial_states) while queue: diff --git a/backend/apps/core/tasks/trending.py b/backend/apps/core/tasks/trending.py index 7870cb7d..4354f033 100644 --- a/backend/apps/core/tasks/trending.py +++ b/backend/apps/core/tasks/trending.py @@ -7,12 +7,13 @@ All tasks run asynchronously to avoid blocking the main application. import logging from datetime import datetime, timedelta -from typing import Dict, List, Any +from typing import Any + from celery import shared_task -from django.utils import timezone -from django.core.cache import cache from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.db.models import Q +from django.utils import timezone from apps.core.analytics import PageView from apps.parks.models import Park @@ -24,7 +25,7 @@ logger = logging.getLogger(__name__) @shared_task(bind=True, max_retries=3, default_retry_delay=60) def calculate_trending_content( self, content_type: str = "all", limit: int = 50 -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Calculate trending content using real analytics data. @@ -100,7 +101,7 @@ def calculate_trending_content( @shared_task(bind=True, max_retries=3, default_retry_delay=30) def calculate_new_content( self, content_type: str = "all", days_back: int = 30, limit: int = 50 -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Calculate new content based on opening dates and creation dates. @@ -157,7 +158,7 @@ def calculate_new_content( @shared_task(bind=True) -def warm_trending_cache(self) -> Dict[str, Any]: +def warm_trending_cache(self) -> dict[str, Any]: """ Warm the trending cache by pre-calculating common queries. @@ -208,7 +209,7 @@ def warm_trending_cache(self) -> Dict[str, Any]: def _calculate_trending_parks( current_period_hours: int, previous_period_hours: int, limit: int -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Calculate trending scores for parks using real data.""" parks = Park.objects.filter(status="OPERATING").select_related( "location", "operator" @@ -247,7 +248,7 @@ def _calculate_trending_parks( def _calculate_trending_rides( current_period_hours: int, previous_period_hours: int, limit: int -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Calculate trending scores for rides using real data.""" rides = Ride.objects.filter(status="OPERATING").select_related( "park", "park__location" @@ -453,7 +454,7 @@ def _calculate_popularity_score( return 0.0 -def _get_new_parks(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: +def _get_new_parks(cutoff_date: datetime, limit: int) -> list[dict[str, Any]]: """Get recently added parks using real data.""" new_parks = ( Park.objects.filter( @@ -467,9 +468,8 @@ def _get_new_parks(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: results = [] for park in new_parks: date_added = park.opening_date or park.created_at - if date_added: - if isinstance(date_added, datetime): - date_added = date_added.date() + if date_added and isinstance(date_added, datetime): + date_added = date_added.date() opening_date = getattr(park, "opening_date", None) if opening_date and isinstance(opening_date, datetime): @@ -492,7 +492,7 @@ def _get_new_parks(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: return results -def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: +def _get_new_rides(cutoff_date: datetime, limit: int) -> list[dict[str, Any]]: """Get recently added rides using real data.""" new_rides = ( Ride.objects.filter( @@ -508,9 +508,8 @@ def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: date_added = getattr(ride, "opening_date", None) or getattr( ride, "created_at", None ) - if date_added: - if isinstance(date_added, datetime): - date_added = date_added.date() + if date_added and isinstance(date_added, datetime): + date_added = date_added.date() opening_date = getattr(ride, "opening_date", None) if opening_date and isinstance(opening_date, datetime): @@ -534,10 +533,10 @@ def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: def _format_trending_results( - trending_items: List[Dict[str, Any]], + trending_items: list[dict[str, Any]], current_period_hours: int, previous_period_hours: int, -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Format trending results for frontend consumption.""" formatted_results = [] @@ -581,8 +580,8 @@ def _format_trending_results( def _format_new_content_results( - new_items: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: + new_items: list[dict[str, Any]], +) -> list[dict[str, Any]]: """Format new content results for frontend consumption.""" formatted_results = [] diff --git a/backend/apps/core/templatetags/common_filters.py b/backend/apps/core/templatetags/common_filters.py index 5453513d..14c2f0a4 100644 --- a/backend/apps/core/templatetags/common_filters.py +++ b/backend/apps/core/templatetags/common_filters.py @@ -14,10 +14,10 @@ Usage: """ from datetime import timedelta + from django import template from django.template.defaultfilters import stringfilter from django.utils import timezone -from django.utils.html import format_html register = template.Library() diff --git a/backend/apps/core/templatetags/fsm_tags.py b/backend/apps/core/templatetags/fsm_tags.py index 7456c417..9ceac39e 100644 --- a/backend/apps/core/templatetags/fsm_tags.py +++ b/backend/apps/core/templatetags/fsm_tags.py @@ -23,13 +23,13 @@ Usage: {# Render a transition button #} {% transition_button submission 'approve' request.user %} """ -from typing import Any, Dict, List, Optional +from typing import Any from django import template -from django.urls import reverse, NoReverseMatch +from django.urls import NoReverseMatch, reverse from django_fsm import can_proceed -from apps.core.views.views import get_transition_metadata, TRANSITION_METADATA +from apps.core.views.views import get_transition_metadata register = template.Library() @@ -40,7 +40,7 @@ register = template.Library() @register.filter -def get_state_value(obj) -> Optional[str]: +def get_state_value(obj) -> str | None: """ Get the current state value of an FSM-enabled object. @@ -171,7 +171,7 @@ def default_target_id(obj) -> str: @register.simple_tag -def get_available_transitions(obj, user) -> List[Dict[str, Any]]: +def get_available_transitions(obj, user) -> list[dict[str, Any]]: """ Get all available transitions for an object that the user can execute. diff --git a/backend/apps/core/templatetags/safe_html.py b/backend/apps/core/templatetags/safe_html.py index 6575ce9c..f21786a7 100644 --- a/backend/apps/core/templatetags/safe_html.py +++ b/backend/apps/core/templatetags/safe_html.py @@ -28,19 +28,26 @@ Usage: {% 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, +) +from apps.core.utils.html_sanitizer import ( sanitize_attribute_value, + sanitize_for_json, + sanitize_html, + sanitize_svg, +) +from apps.core.utils.html_sanitizer import ( + sanitize_minimal as _sanitize_minimal, +) +from apps.core.utils.html_sanitizer import ( + sanitize_url as _sanitize_url, +) +from apps.core.utils.html_sanitizer import ( + strip_html as _strip_html, ) register = template.Library() diff --git a/backend/apps/core/tests/test_admin.py b/backend/apps/core/tests/test_admin.py index cec0cece..ef0560fc 100644 --- a/backend/apps/core/tests/test_admin.py +++ b/backend/apps/core/tests/test_admin.py @@ -5,7 +5,6 @@ These tests verify the functionality of the base admin classes and mixins that provide standardized behavior across all admin interfaces. """ -import pytest from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase diff --git a/backend/apps/core/tests/test_history.py b/backend/apps/core/tests/test_history.py index f0f7fd7e..79a71f7e 100644 --- a/backend/apps/core/tests/test_history.py +++ b/backend/apps/core/tests/test_history.py @@ -1,8 +1,9 @@ +import pghistory import pytest from django.contrib.auth import get_user_model -from apps.parks.models import Park, Company -import pghistory + +from apps.parks.models import Company, Park User = get_user_model() @@ -16,7 +17,7 @@ class TestTrackedModel: """Test that creating a model instance creates a history event.""" user = User.objects.create_user(username="testuser", password="password") company = Company.objects.create(name="Test Operator", roles=["OPERATOR"]) - + with pghistory.context(user=user.id): park = Park.objects.create( name="History Test Park", @@ -24,13 +25,13 @@ class TestTrackedModel: operating_season="Summer", operator=company ) - + # Verify history using the helper method from TrackedModel events = park.get_history() assert events.count() == 1 event = events.first() assert event.pgh_obj_id == park.pk - + # Verify context was captured # The middleware isn't running here, so we used pghistory.context explicitly # But pghistory.context stores data in pgh_context field if configured? @@ -40,15 +41,15 @@ class TestTrackedModel: def test_update_tracking(self): company = Company.objects.create(name="Test Operator 2", roles=["OPERATOR"]) park = Park.objects.create(name="Original", operator=company) - + # Initial create event assert park.get_history().count() == 1 - + # Update park.name = "Updated" park.save() - + assert park.get_history().count() == 2 latest = park.get_history().first() # Ordered by -pgh_created_at assert latest.name == "Updated" - + diff --git a/backend/apps/core/urls/__init__.py b/backend/apps/core/urls/__init__.py index 6bbfff11..f70fe166 100644 --- a/backend/apps/core/urls/__init__.py +++ b/backend/apps/core/urls/__init__.py @@ -2,7 +2,8 @@ Core app URL configuration. """ -from django.urls import path, include +from django.urls import include, path + from ..views.entity_search import ( EntityFuzzySearchView, EntityNotFoundView, diff --git a/backend/apps/core/urls/map_urls.py b/backend/apps/core/urls/map_urls.py index b9c34fc0..08b1d65a 100644 --- a/backend/apps/core/urls/map_urls.py +++ b/backend/apps/core/urls/map_urls.py @@ -3,13 +3,14 @@ URL patterns for the unified map service API. """ from django.urls import path + from ..views.map_views import ( - MapLocationsView, - MapLocationDetailView, - MapSearchView, MapBoundsView, - MapStatsView, MapCacheView, + MapLocationDetailView, + MapLocationsView, + MapSearchView, + MapStatsView, ) app_name = "map_api" diff --git a/backend/apps/core/urls/maps.py b/backend/apps/core/urls/maps.py index 71f980b1..748f7c2b 100644 --- a/backend/apps/core/urls/maps.py +++ b/backend/apps/core/urls/maps.py @@ -4,15 +4,16 @@ Includes both HTML views and HTMX endpoints. """ from django.urls import path + from ..views.maps import ( - UniversalMapView, - ParkMapView, - NearbyLocationsView, + LocationDetailModalView, LocationFilterView, + LocationListView, LocationSearchView, MapBoundsUpdateView, - LocationDetailModalView, - LocationListView, + NearbyLocationsView, + ParkMapView, + UniversalMapView, ) app_name = "maps" diff --git a/backend/apps/core/urls/search.py b/backend/apps/core/urls/search.py index 643ff830..6a329a09 100644 --- a/backend/apps/core/urls/search.py +++ b/backend/apps/core/urls/search.py @@ -1,4 +1,5 @@ from django.urls import path + from apps.core.views.search import ( AdaptiveSearchView, FilterFormView, diff --git a/backend/apps/core/utils/breadcrumbs.py b/backend/apps/core/utils/breadcrumbs.py index cfc8276f..b07e78ec 100644 --- a/backend/apps/core/utils/breadcrumbs.py +++ b/backend/apps/core/utils/breadcrumbs.py @@ -29,7 +29,8 @@ Usage Examples: from __future__ import annotations -from dataclasses import dataclass, field +import contextlib +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from urllib.parse import urljoin @@ -351,19 +352,15 @@ def get_model_breadcrumb( parent_list_url = f"{parent_model_name}s:list" parent_list_label = f"{parent.__class__.__name__}s" - try: + with contextlib.suppress(Exception): builder.add_from_url(parent_list_url, parent_list_label) - except Exception: - pass builder.add_model(parent) # Add list page breadcrumb if list_url_name and list_label: - try: + with contextlib.suppress(Exception): builder.add_from_url(list_url_name, list_label) - except Exception: - pass # Add current model instance builder.add_model_current(instance) diff --git a/backend/apps/core/utils/cloudflare.py b/backend/apps/core/utils/cloudflare.py index c6a81051..54136ddc 100644 --- a/backend/apps/core/utils/cloudflare.py +++ b/backend/apps/core/utils/cloudflare.py @@ -1,53 +1,54 @@ +import logging + import requests from django.conf import settings from django.core.exceptions import ImproperlyConfigured -import logging logger = logging.getLogger(__name__) def get_direct_upload_url(user_id=None): """ Generates a direct upload URL for Cloudflare Images. - + Args: user_id (str, optional): The user ID to associate with the upload. - + Returns: dict: A dictionary containing 'id' and 'uploadURL'. - + Raises: ImproperlyConfigured: If Cloudflare settings are missing. requests.RequestException: If the Cloudflare API request fails. """ account_id = getattr(settings, 'CLOUDFLARE_IMAGES_ACCOUNT_ID', None) api_token = getattr(settings, 'CLOUDFLARE_IMAGES_API_TOKEN', None) - + if not account_id or not api_token: raise ImproperlyConfigured( "CLOUDFLARE_IMAGES_ACCOUNT_ID and CLOUDFLARE_IMAGES_API_TOKEN must be set." ) url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2/direct_upload" - + headers = { "Authorization": f"Bearer {api_token}", } - + data = { "requireSignedURLs": "false", } - + if user_id: data["metadata"] = f'{{"user_id": "{user_id}"}}' response = requests.post(url, headers=headers, data=data) response.raise_for_status() - + result = response.json() - + if not result.get("success"): error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message") logger.error(f"Cloudflare Direct Upload Error: {error_msg}") raise requests.RequestException(f"Cloudflare Error: {error_msg}") - + return result.get("result", {}) diff --git a/backend/apps/core/utils/error_handling.py b/backend/apps/core/utils/error_handling.py index 1213acd5..d58a7a41 100644 --- a/backend/apps/core/utils/error_handling.py +++ b/backend/apps/core/utils/error_handling.py @@ -6,7 +6,7 @@ ensuring consistent logging, user messages, and API responses. """ import logging -from typing import Any, Dict, Optional +from typing import Any from django.contrib import messages from django.http import HttpRequest @@ -26,7 +26,7 @@ class ErrorHandler: request: HttpRequest, error: Exception, user_message: str = "An error occurred", - log_message: Optional[str] = None, + log_message: str | None = None, level: str = "error", ) -> None: """ @@ -68,7 +68,7 @@ class ErrorHandler: def handle_api_error( error: Exception, user_message: str = "An error occurred", - log_message: Optional[str] = None, + log_message: str | None = None, status_code: int = status.HTTP_400_BAD_REQUEST, ) -> Response: """ @@ -99,7 +99,7 @@ class ErrorHandler: logger.error(log_msg, exc_info=True) # Build error response - error_data: Dict[str, Any] = { + error_data: dict[str, Any] = { "error": user_message, "detail": str(error), } @@ -150,7 +150,7 @@ class ErrorHandler: Returns: DRF Response with success data in standard format """ - response_data: Dict[str, Any] = { + response_data: dict[str, Any] = { "status": "success", "message": message, } diff --git a/backend/apps/core/utils/file_scanner.py b/backend/apps/core/utils/file_scanner.py index 24f70f90..0954a651 100644 --- a/backend/apps/core/utils/file_scanner.py +++ b/backend/apps/core/utils/file_scanner.py @@ -30,7 +30,6 @@ import os import re import uuid from io import BytesIO -from typing import Optional, Set, Tuple from django.core.exceptions import ValidationError from django.core.files.uploadedfile import UploadedFile @@ -72,7 +71,7 @@ IMAGE_SIGNATURES = { } # All allowed MIME types -ALLOWED_IMAGE_MIME_TYPES: Set[str] = frozenset({ +ALLOWED_IMAGE_MIME_TYPES: set[str] = frozenset({ 'image/jpeg', 'image/png', 'image/gif', @@ -80,7 +79,7 @@ ALLOWED_IMAGE_MIME_TYPES: Set[str] = frozenset({ }) # Allowed file extensions -ALLOWED_IMAGE_EXTENSIONS: Set[str] = frozenset({ +ALLOWED_IMAGE_EXTENSIONS: set[str] = frozenset({ '.jpg', '.jpeg', '.png', '.gif', '.webp', }) @@ -98,8 +97,8 @@ MIN_FILE_SIZE = 100 # 100 bytes def validate_image_upload( file: UploadedFile, max_size: int = MAX_FILE_SIZE, - allowed_types: Optional[Set[str]] = None, - allowed_extensions: Optional[Set[str]] = None, + allowed_types: set[str] | None = None, + allowed_extensions: set[str] | None = None, ) -> bool: """ Validate an uploaded image file for security. @@ -191,15 +190,14 @@ def _validate_magic_number(file: UploadedFile) -> bool: # Check against known signatures for format_name, signatures in IMAGE_SIGNATURES.items(): - for magic, offset, description in signatures: - if len(header) >= offset + len(magic): - if header[offset:offset + len(magic)] == magic: - # Special handling for WebP (must also have WEBP marker) - if format_name == 'webp': - if len(header) >= 12 and header[8:12] == b'WEBP': - return True - else: + for magic, offset, _description in signatures: + if len(header) >= offset + len(magic) and header[offset:offset + len(magic)] == magic: + # Special handling for WebP (must also have WEBP marker) + if format_name == 'webp': + if len(header) >= 12 and header[8:12] == b'WEBP': return True + else: + return True return False @@ -340,7 +338,7 @@ UPLOAD_RATE_LIMITS = { } -def check_upload_rate_limit(user_id: int, cache_backend=None) -> Tuple[bool, str]: +def check_upload_rate_limit(user_id: int, cache_backend=None) -> tuple[bool, str]: """ Check if user has exceeded upload rate limits. @@ -414,7 +412,7 @@ def increment_upload_count(user_id: int, cache_backend=None) -> None: # Antivirus Integration Point # ============================================================================= -def scan_file_for_malware(file: UploadedFile) -> Tuple[bool, str]: +def scan_file_for_malware(file: UploadedFile) -> tuple[bool, str]: """ Placeholder for antivirus/malware scanning integration. diff --git a/backend/apps/core/utils/messages.py b/backend/apps/core/utils/messages.py index 7e2bd14f..70575624 100644 --- a/backend/apps/core/utils/messages.py +++ b/backend/apps/core/utils/messages.py @@ -16,8 +16,6 @@ Usage Examples: from __future__ import annotations -from typing import Any - def success_created( model_name: str, diff --git a/backend/apps/core/utils/query_optimization.py b/backend/apps/core/utils/query_optimization.py index 5e180e1f..c021e194 100644 --- a/backend/apps/core/utils/query_optimization.py +++ b/backend/apps/core/utils/query_optimization.py @@ -2,14 +2,15 @@ Database query optimization utilities and helpers. """ -import time import logging +import time from contextlib import contextmanager -from typing import Optional, Dict, Any, List, Type -from django.db import connection, models -from django.db.models import QuerySet, Prefetch, Count, Avg, Max +from typing import Any + from django.conf import settings from django.core.cache import cache +from django.db import connection, models +from django.db.models import Avg, Count, Max, Prefetch, QuerySet logger = logging.getLogger("query_optimization") @@ -136,7 +137,7 @@ class QueryOptimizer: ) @staticmethod - def create_bulk_queryset(model: Type[models.Model], ids: List[int]) -> QuerySet: + def create_bulk_queryset(model: type[models.Model], ids: list[int]) -> QuerySet: """ Create an optimized queryset for bulk operations """ @@ -186,7 +187,7 @@ class QueryCache: return result @staticmethod - def invalidate_model_cache(model_name: str, instance_id: Optional[int] = None): + def invalidate_model_cache(model_name: str, instance_id: int | None = None): """ Invalidate cache keys related to a specific model @@ -195,10 +196,7 @@ class QueryCache: instance_id: Specific instance ID, if applicable """ # Pattern-based cache invalidation (works with Redis) - if instance_id: - pattern = f"*{model_name}_{instance_id}*" - else: - pattern = f"*{model_name}*" + pattern = f"*{model_name}_{instance_id}*" if instance_id else f"*{model_name}*" try: # For Redis cache backends that support pattern deletion @@ -219,7 +217,7 @@ class IndexAnalyzer: """Analyze and suggest database indexes""" @staticmethod - def analyze_slow_queries(min_time: float = 0.1) -> List[Dict[str, Any]]: + def analyze_slow_queries(min_time: float = 0.1) -> list[dict[str, Any]]: """ Analyze slow queries from the current request @@ -244,7 +242,7 @@ class IndexAnalyzer: return slow_queries @staticmethod - def _analyze_query_sql(sql: str) -> Dict[str, Any]: + def _analyze_query_sql(sql: str) -> dict[str, Any]: """ Analyze SQL to suggest potential optimizations """ @@ -285,7 +283,7 @@ class IndexAnalyzer: return analysis @staticmethod - def suggest_model_indexes(model: Type[models.Model]) -> List[str]: + def suggest_model_indexes(model: type[models.Model]) -> list[str]: """ Suggest database indexes for a Django model based on its fields """ @@ -343,7 +341,7 @@ def log_query_performance(): def optimize_queryset_for_serialization( - queryset: QuerySet, fields: List[str] + queryset: QuerySet, fields: list[str] ) -> QuerySet: """ Optimize a queryset for API serialization by only selecting needed fields diff --git a/backend/apps/core/utils/turnstile.py b/backend/apps/core/utils/turnstile.py index b9ecdbf4..5e7b071b 100644 --- a/backend/apps/core/utils/turnstile.py +++ b/backend/apps/core/utils/turnstile.py @@ -11,25 +11,25 @@ from django.conf import settings def validate_turnstile_token(token: str, ip: str = None) -> dict: """ Validate a Cloudflare Turnstile token. - + Args: token: The Turnstile response token from the client ip: Optional client IP address for additional verification - + Returns: dict with 'success' boolean and optional 'error' message """ # Skip validation if configured (dev mode) if getattr(settings, 'TURNSTILE_SKIP_VALIDATION', False): return {'success': True} - + secret = getattr(settings, 'TURNSTILE_SECRET', '') if not secret: return {'success': True} # Skip if no secret configured - + if not token: return {'success': False, 'error': 'Captcha verification required'} - + try: response = requests.post( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', @@ -41,17 +41,17 @@ def validate_turnstile_token(token: str, ip: str = None) -> dict: timeout=10 ) result = response.json() - + if result.get('success'): return {'success': True} else: error_codes = result.get('error-codes', []) return { - 'success': False, + 'success': False, 'error': 'Captcha verification failed', 'error_codes': error_codes } - except requests.RequestException as e: + except requests.RequestException: # Log error but don't block user on network issues return {'success': True} # Fail open to avoid blocking legitimate users diff --git a/backend/apps/core/views/base.py b/backend/apps/core/views/base.py index d268e141..4e4cb4bc 100644 --- a/backend/apps/core/views/base.py +++ b/backend/apps/core/views/base.py @@ -5,7 +5,6 @@ This module provides base view classes that implement common patterns such as automatic query optimization with select_related and prefetch_related. """ -from typing import List from django.db.models import QuerySet from django.views.generic import DetailView, ListView @@ -29,8 +28,8 @@ class OptimizedListView(ListView): prefetch_related_fields = ['photos'] """ - select_related_fields: List[str] = [] - prefetch_related_fields: List[str] = [] + select_related_fields: list[str] = [] + prefetch_related_fields: list[str] = [] def get_queryset(self) -> QuerySet: """Get queryset with optimizations applied.""" @@ -63,8 +62,8 @@ class OptimizedDetailView(DetailView): prefetch_related_fields = ['photos', 'coaster_stats'] """ - select_related_fields: List[str] = [] - prefetch_related_fields: List[str] = [] + select_related_fields: list[str] = [] + prefetch_related_fields: list[str] = [] def get_queryset(self) -> QuerySet: """Get queryset with optimizations applied.""" diff --git a/backend/apps/core/views/entity_search.py b/backend/apps/core/views/entity_search.py index b54e14d8..bed79b66 100644 --- a/backend/apps/core/views/entity_search.py +++ b/backend/apps/core/views/entity_search.py @@ -2,15 +2,17 @@ Entity search views with fuzzy matching and authentication prompts. """ -from rest_framework.views import APIView -from rest_framework.response import Response + +import contextlib + from rest_framework import status from rest_framework.permissions import AllowAny -from typing import Optional, List +from rest_framework.response import Response +from rest_framework.views import APIView from ..services.entity_fuzzy_matching import ( - entity_fuzzy_matcher, EntityType, + entity_fuzzy_matcher, ) @@ -177,10 +179,8 @@ class EntityNotFoundView(APIView): # Determine entity types to search based on context entity_types = [] if entity_type_hint: - try: + with contextlib.suppress(ValueError): entity_types = [EntityType(entity_type_hint)] - except ValueError: - pass # If we have park context, prioritize ride searches if context.get("park_slug") and not entity_types: @@ -314,7 +314,7 @@ class QuickEntitySuggestionView(APIView): # Utility function for other views to use def get_entity_suggestions( - query: str, entity_types: Optional[List[str]] = None, user=None + query: str, entity_types: list[str] | None = None, user=None ): """ Utility function for other Django views to get entity suggestions. diff --git a/backend/apps/core/views/map_views.py b/backend/apps/core/views/map_views.py index 4837d063..4318f98b 100644 --- a/backend/apps/core/views/map_views.py +++ b/backend/apps/core/views/map_views.py @@ -5,18 +5,19 @@ Enhanced with proper error handling, pagination, and performance optimizations. import json import logging -from typing import Dict, Any, Optional -from django.http import JsonResponse, HttpRequest -from django.views.decorators.cache import cache_page -from django.views.decorators.gzip import gzip_page +import time +from typing import Any + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.http import HttpRequest, JsonResponse from django.utils.decorators import method_decorator from django.views import View -from django.core.exceptions import ValidationError -from django.conf import settings -import time +from django.views.decorators.cache import cache_page +from django.views.decorators.gzip import gzip_page +from ..services.data_structures import GeoBounds, LocationType, MapFilters from ..services.map_service import unified_map_service -from ..services.data_structures import GeoBounds, MapFilters, LocationType logger = logging.getLogger(__name__) @@ -60,7 +61,7 @@ class MapAPIView(View): """Handle preflight CORS requests.""" return JsonResponse({}, status=200) - def _parse_bounds(self, request: HttpRequest) -> Optional[GeoBounds]: + def _parse_bounds(self, request: HttpRequest) -> GeoBounds | None: """Parse geographic bounds from request parameters.""" try: north = request.GET.get("north") @@ -87,7 +88,7 @@ class MapAPIView(View): except (ValueError, TypeError) as e: raise ValidationError(f"Invalid bounds parameters: {e}") - def _parse_pagination(self, request: HttpRequest) -> Dict[str, int]: + def _parse_pagination(self, request: HttpRequest) -> dict[str, int]: """Parse pagination parameters from request.""" try: page = max(1, int(request.GET.get("page", 1))) @@ -114,7 +115,7 @@ class MapAPIView(View): "limit": self.DEFAULT_PAGE_SIZE, } - def _parse_filters(self, request: HttpRequest) -> Optional[MapFilters]: + def _parse_filters(self, request: HttpRequest) -> MapFilters | None: """Parse filtering parameters from request.""" try: filters = MapFilters() @@ -213,9 +214,9 @@ class MapAPIView(View): self, data: list, total_count: int, - pagination: Dict[str, int], + pagination: dict[str, int], request: HttpRequest, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Create paginated response with metadata.""" total_pages = (total_count + pagination["page_size"] - 1) // pagination[ "page_size" @@ -255,7 +256,7 @@ class MapAPIView(View): message: str, status: int = 400, error_code: str = None, - details: Dict[str, Any] = None, + details: dict[str, Any] = None, ) -> JsonResponse: """Return standardized error response with enhanced information.""" response_data = { @@ -278,7 +279,7 @@ class MapAPIView(View): return JsonResponse(response_data, status=status) def _success_response( - self, data: Any, message: str = None, metadata: Dict[str, Any] = None + self, data: Any, message: str = None, metadata: dict[str, Any] = None ) -> JsonResponse: """Return standardized success response.""" response_data = { diff --git a/backend/apps/core/views/maps.py b/backend/apps/core/views/maps.py index d3f7781d..bd30125a 100644 --- a/backend/apps/core/views/maps.py +++ b/backend/apps/core/views/maps.py @@ -3,21 +3,23 @@ HTML views for the unified map service. Provides web interfaces for map functionality with HTMX integration. """ +import contextlib import json -from typing import Dict, Any, Optional, Set -from django.shortcuts import render -from django.http import JsonResponse, HttpRequest, HttpResponse -from django.views.generic import TemplateView, View -from django.core.paginator import Paginator +from typing import Any +from django.core.paginator import Paginator +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import render +from django.views.generic import TemplateView, View + +from ..services.data_structures import GeoBounds, LocationType, MapFilters from ..services.map_service import unified_map_service -from ..services.data_structures import GeoBounds, MapFilters, LocationType class MapViewMixin: """Mixin providing common functionality for map views.""" - def get_map_context(self, request: HttpRequest) -> Dict[str, Any]: + def get_map_context(self, request: HttpRequest) -> dict[str, Any]: """Get common context data for map views.""" return { "map_api_urls": { @@ -32,7 +34,7 @@ class MapViewMixin: "enable_search": True, } - def parse_location_types(self, request: HttpRequest) -> Optional[Set[LocationType]]: + def parse_location_types(self, request: HttpRequest) -> set[LocationType] | None: """Parse location types from request parameters.""" types_param = request.GET.get("types") if types_param: @@ -75,15 +77,13 @@ class UniversalMapView(MapViewMixin, TemplateView): if all( param in self.request.GET for param in ["north", "south", "east", "west"] ): - try: + with contextlib.suppress(ValueError, TypeError): context["initial_bounds"] = { "north": float(self.request.GET["north"]), "south": float(self.request.GET["south"]), "east": float(self.request.GET["east"]), "west": float(self.request.GET["west"]), } - except (ValueError, TypeError): - pass return context diff --git a/backend/apps/core/views/performance_dashboard.py b/backend/apps/core/views/performance_dashboard.py index df4799be..f629b29c 100644 --- a/backend/apps/core/views/performance_dashboard.py +++ b/backend/apps/core/views/performance_dashboard.py @@ -12,17 +12,18 @@ Access: Staff/Admin only URL: /admin/performance/ (configured in urls.py) """ -import time import logging -from typing import Any, Dict +import time +from typing import Any + +from django.conf import settings +from django.contrib.admin.views.decorators import staff_member_required +from django.core.cache import caches +from django.db import connection +from django.http import JsonResponse +from django.utils.decorators import method_decorator from django.views import View from django.views.generic import TemplateView -from django.http import JsonResponse -from django.contrib.admin.views.decorators import staff_member_required -from django.utils.decorators import method_decorator -from django.db import connection -from django.core.cache import caches -from django.conf import settings from apps.core.services.enhanced_cache_service import CacheMonitor @@ -39,7 +40,7 @@ class PerformanceDashboardView(TemplateView): template_name = "core/performance_dashboard.html" - def get_context_data(self, **kwargs) -> Dict[str, Any]: + def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) # Get cache statistics @@ -56,7 +57,7 @@ class PerformanceDashboardView(TemplateView): return context - def _get_cache_stats(self) -> Dict[str, Any]: + def _get_cache_stats(self) -> dict[str, Any]: """Get cache statistics from all configured caches.""" stats = {} @@ -67,7 +68,7 @@ class PerformanceDashboardView(TemplateView): stats["default"] = {"error": str(e)} # Try to get stats for each configured cache - for cache_name in settings.CACHES.keys(): + for cache_name in settings.CACHES: try: cache = caches[cache_name] cache_backend = cache.__class__.__name__ @@ -107,7 +108,7 @@ class PerformanceDashboardView(TemplateView): return stats - def _get_database_stats(self) -> Dict[str, Any]: + def _get_database_stats(self) -> dict[str, Any]: """Get database connection and query statistics.""" stats = {} @@ -138,7 +139,7 @@ class PerformanceDashboardView(TemplateView): return stats - def _get_middleware_config(self) -> Dict[str, Any]: + def _get_middleware_config(self) -> dict[str, Any]: """Get middleware configuration summary.""" middleware = settings.MIDDLEWARE return { @@ -150,7 +151,7 @@ class PerformanceDashboardView(TemplateView): "middleware_list": middleware, } - def _get_cache_config(self) -> Dict[str, Any]: + def _get_cache_config(self) -> dict[str, Any]: """Get cache configuration summary.""" cache_config = {} @@ -231,7 +232,7 @@ class CacheStatsAPIView(View): def get(self, request) -> JsonResponse: stats = {} - for cache_name in settings.CACHES.keys(): + for cache_name in settings.CACHES: try: cache = caches[cache_name] cache_backend = cache.__class__.__name__ diff --git a/backend/apps/core/views/search.py b/backend/apps/core/views/search.py index 6ea0efef..ad59e123 100644 --- a/backend/apps/core/views/search.py +++ b/backend/apps/core/views/search.py @@ -1,13 +1,14 @@ -from django.views.generic import TemplateView -from django.http import JsonResponse from django.contrib.gis.geos import Point -from apps.parks.models import Park -from apps.parks.filters import ParkFilter -from apps.core.services.location_search import ( - location_search_service, - LocationSearchFilters, -) +from django.http import JsonResponse +from django.views.generic import TemplateView + from apps.core.forms.search import LocationSearchForm +from apps.core.services.location_search import ( + LocationSearchFilters, + location_search_service, +) +from apps.parks.filters import ParkFilter +from apps.parks.models import Park class AdaptiveSearchView(TemplateView): diff --git a/backend/apps/core/views/views.py b/backend/apps/core/views/views.py index 651d334e..8b204933 100644 --- a/backend/apps/core/views/views.py +++ b/backend/apps/core/views/views.py @@ -4,24 +4,24 @@ Core views for the application. import json import logging -from typing import Any, Dict, Optional, Type +from typing import Any from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.models import Model from django.http import HttpRequest, HttpResponse, JsonResponse -from django.shortcuts import redirect, render, get_object_or_404 +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_protect from django.views.generic import DetailView, TemplateView -from django_fsm import can_proceed, TransitionNotAllowed +from django_fsm import TransitionNotAllowed, can_proceed from apps.core.state_machine.exceptions import ( + TransitionNotAvailable, TransitionPermissionDenied, TransitionValidationError, - TransitionNotAvailable, format_transition_error, ) @@ -34,9 +34,9 @@ class SlugRedirectMixin(View): Requires the model to inherit from SluggedModel and view to inherit from DetailView. """ - model: Optional[Type[Model]] = None + model: type[Model] | None = None slug_url_kwarg: str = "slug" - object: Optional[Model] = None + object: Model | None = None def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: # Only apply slug redirect logic to DetailViews @@ -71,7 +71,7 @@ class SlugRedirectMixin(View): "Subclasses must implement get_redirect_url_pattern()" ) - def get_redirect_url_kwargs(self) -> Dict[str, Any]: + def get_redirect_url_kwargs(self) -> dict[str, Any]: """ Get the kwargs for reverse() when redirecting. Should be overridden by subclasses if they need custom kwargs. @@ -188,7 +188,7 @@ TRANSITION_METADATA = { } -def get_transition_metadata(transition_name: str) -> Dict[str, Any]: +def get_transition_metadata(transition_name: str) -> dict[str, Any]: """Get metadata for a transition by name.""" # Check for exact match first if transition_name in TRANSITION_METADATA: @@ -244,7 +244,7 @@ class FSMTransitionView(View): # Override these in subclasses or pass via URL kwargs partial_template = None # Template to render after successful transition - def get_model_class(self, app_label: str, model_name: str) -> Optional[Type[Model]]: + def get_model_class(self, app_label: str, model_name: str) -> type[Model] | None: """ Get the model class from app_label and model_name. @@ -264,7 +264,7 @@ class FSMTransitionView(View): return None def get_object( - self, model_class: Type[Model], pk: Any, slug: Optional[str] = None + self, model_class: type[Model], pk: Any, slug: str | None = None ) -> Model: """ Get the model instance. @@ -299,7 +299,7 @@ class FSMTransitionView(View): def validate_transition( self, obj: Model, transition_name: str, user - ) -> tuple[bool, Optional[str]]: + ) -> tuple[bool, str | None]: """ Validate that the transition can proceed. @@ -367,7 +367,7 @@ class FSMTransitionView(View): return error.user_message return str(error) or "An error occurred during the transition." - def get_partial_template(self, obj: Model, request: HttpRequest) -> Optional[str]: + def get_partial_template(self, obj: Model, request: HttpRequest) -> str | None: """ Get the template to render after a successful transition. @@ -395,8 +395,8 @@ class FSMTransitionView(View): ] # Use template loader to check if template exists - from django.template.loader import select_template from django.template import TemplateDoesNotExist + from django.template.loader import select_template try: template = select_template(possible_templates) diff --git a/backend/apps/lists/admin.py b/backend/apps/lists/admin.py index ceaff2ea..28964510 100644 --- a/backend/apps/lists/admin.py +++ b/backend/apps/lists/admin.py @@ -1,13 +1,15 @@ from django.contrib import admin from django.db.models import Count from django.utils.html import format_html + from apps.core.admin import ( BaseModelAdmin, ExportActionMixin, QueryOptimizationMixin, TimestampFieldsMixin, ) -from .models import UserList, ListItem + +from .models import ListItem, UserList class ListItemInline(admin.TabularInline): diff --git a/backend/apps/lists/apps.py b/backend/apps/lists/apps.py index c86f2bae..cf52b851 100644 --- a/backend/apps/lists/apps.py +++ b/backend/apps/lists/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class ListsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.lists" diff --git a/backend/apps/lists/migrations/0001_initial.py b/backend/apps/lists/migrations/0001_initial.py index d9715433..d32cf907 100644 --- a/backend/apps/lists/migrations/0001_initial.py +++ b/backend/apps/lists/migrations/0001_initial.py @@ -1,12 +1,13 @@ # Generated by Django 5.1.6 on 2025-12-26 14:13 -import apps.core.choices.fields import django.db.models.deletion import pgtrigger.compiler import pgtrigger.migrations from django.conf import settings from django.db import migrations, models +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/lists/models.py b/backend/apps/lists/models.py index 0ff42152..fc6587eb 100644 --- a/backend/apps/lists/models.py +++ b/backend/apps/lists/models.py @@ -1,10 +1,12 @@ -from django.db import models +import pghistory from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from apps.core.history import TrackedModel +from django.db import models + from apps.core.choices import RichChoiceField -import pghistory +from apps.core.history import TrackedModel + @pghistory.track() class UserList(TrackedModel): diff --git a/backend/apps/lists/serializers.py b/backend/apps/lists/serializers.py index 400c168b..78b65f8c 100644 --- a/backend/apps/lists/serializers.py +++ b/backend/apps/lists/serializers.py @@ -1,7 +1,10 @@ from rest_framework import serializers -from .models import UserList, ListItem + from apps.accounts.serializers import UserSerializer +from .models import ListItem, UserList + + class ListItemSerializer(serializers.ModelSerializer): class Meta: model = ListItem diff --git a/backend/apps/lists/urls.py b/backend/apps/lists/urls.py index 9a0f036c..6e0b4f4c 100644 --- a/backend/apps/lists/urls.py +++ b/backend/apps/lists/urls.py @@ -1,6 +1,7 @@ -from django.urls import path, include +from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import UserListViewSet, ListItemViewSet + +from .views import ListItemViewSet, UserListViewSet router = DefaultRouter() router.register(r"lists", UserListViewSet, basename="list") diff --git a/backend/apps/lists/views.py b/backend/apps/lists/views.py index 6584f58a..01a9a883 100644 --- a/backend/apps/lists/views.py +++ b/backend/apps/lists/views.py @@ -1,9 +1,12 @@ from django.db.models import Q -from rest_framework import viewsets, permissions -from .models import UserList, ListItem -from .serializers import UserListSerializer, ListItemSerializer +from rest_framework import permissions, viewsets + from apps.core.permissions import IsOwnerOrReadOnly +from .models import ListItem, UserList +from .serializers import ListItemSerializer, UserListSerializer + + class UserListViewSet(viewsets.ModelViewSet): serializer_class = UserListSerializer permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] diff --git a/backend/apps/media/apps.py b/backend/apps/media/apps.py index fa572bec..77218bf3 100644 --- a/backend/apps/media/apps.py +++ b/backend/apps/media/apps.py @@ -6,6 +6,7 @@ def create_photo_permissions(sender, **kwargs): """Create custom permissions for domain-specific photo models""" from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType + from apps.parks.models import ParkPhoto from apps.rides.models import RidePhoto diff --git a/backend/apps/media/commands/download_photos.py b/backend/apps/media/commands/download_photos.py index e1c4e8f1..09d9df86 100644 --- a/backend/apps/media/commands/download_photos.py +++ b/backend/apps/media/commands/download_photos.py @@ -1,9 +1,11 @@ +import json + import requests +from django.core.files.base import ContentFile from django.core.management.base import BaseCommand + from apps.parks.models import Park, ParkPhoto from apps.rides.models import Ride, RidePhoto -import json -from django.core.files.base import ContentFile class Command(BaseCommand): @@ -13,7 +15,7 @@ class Command(BaseCommand): self.stdout.write("Downloading photos from seed data...") # Read seed data - with open("parks/management/commands/seed_data.json", "r") as f: + with open("parks/management/commands/seed_data.json") as f: seed_data = json.load(f) # Process parks and their photos diff --git a/backend/apps/media/commands/fix_photo_paths.py b/backend/apps/media/commands/fix_photo_paths.py index 5043d0bf..138d0042 100644 --- a/backend/apps/media/commands/fix_photo_paths.py +++ b/backend/apps/media/commands/fix_photo_paths.py @@ -1,8 +1,10 @@ import os + from django.core.management.base import BaseCommand +from django.db import transaction + from apps.parks.models import ParkPhoto from apps.rides.models import RidePhoto -from django.db import transaction class Command(BaseCommand): diff --git a/backend/apps/media/commands/move_photos.py b/backend/apps/media/commands/move_photos.py index 627c273a..f99edf17 100644 --- a/backend/apps/media/commands/move_photos.py +++ b/backend/apps/media/commands/move_photos.py @@ -1,9 +1,11 @@ import os +import shutil + +from django.conf import settings from django.core.management.base import BaseCommand + from apps.parks.models import ParkPhoto from apps.rides.models import RidePhoto -from django.conf import settings -import shutil class Command(BaseCommand): @@ -182,7 +184,7 @@ class Command(BaseCommand): for content_type in ["park", "ride"]: base_dir = os.path.join(settings.MEDIA_ROOT, content_type) if os.path.exists(base_dir): - for root, dirs, files in os.walk(base_dir): + for root, _dirs, files in os.walk(base_dir): for file in files: file_path = os.path.join(root, file) if file_path not in processed_files: diff --git a/backend/apps/media/models.py b/backend/apps/media/models.py index 7ae749b4..8ebb387a 100644 --- a/backend/apps/media/models.py +++ b/backend/apps/media/models.py @@ -1,14 +1,17 @@ -from django.db import models +import pghistory from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from apps.core.history import TrackedModel -import pghistory +from django.db import models + # Using string reference for CloudflareImage to avoid circular imports if possible, # or direct import if safe. django-cloudflare-images-toolkit usually provides a field or model. # Checking installed apps... it's "django_cloudflareimages_toolkit". from django_cloudflareimages_toolkit.models import CloudflareImage +from apps.core.history import TrackedModel + + @pghistory.track() class Photo(TrackedModel): user = models.ForeignKey( @@ -17,7 +20,7 @@ class Photo(TrackedModel): related_name="photos", help_text="User who uploaded this photo", ) - + # The actual image image = models.ForeignKey( CloudflareImage, @@ -38,10 +41,10 @@ class Photo(TrackedModel): # Metadata caption = models.CharField(max_length=255, blank=True, help_text="Photo caption") is_public = models.BooleanField( - default=True, + default=True, help_text="Whether this photo is visible to others" ) - + # We might want credit/source info if not taken by user source = models.CharField(max_length=100, blank=True, help_text="Source/Credit if applicable") diff --git a/backend/apps/media/serializers.py b/backend/apps/media/serializers.py index 40d02ede..e0bfdf9c 100644 --- a/backend/apps/media/serializers.py +++ b/backend/apps/media/serializers.py @@ -1,12 +1,15 @@ -from rest_framework import serializers -from .models import Photo -from apps.accounts.serializers import UserSerializer from django_cloudflareimages_toolkit.models import CloudflareImage +from rest_framework import serializers + +from apps.accounts.serializers import UserSerializer + +from .models import Photo + # We need a serializer for the CloudflareImage model too if we want to show variants class CloudflareImageSerializer(serializers.ModelSerializer): variants = serializers.JSONField(read_only=True) - + class Meta: model = CloudflareImage fields = ["id", "cloudflare_id", "variants"] @@ -15,7 +18,7 @@ class PhotoSerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) image = CloudflareImageSerializer(read_only=True) cloudflare_image_id = serializers.CharField(write_only=True) - + # Helper for frontend to get URLs easily url = serializers.SerializerMethodField() thumbnail = serializers.SerializerMethodField() @@ -46,7 +49,7 @@ class PhotoSerializer(serializers.ModelSerializer): # We assume it exists on CF side. We just need the DB record. image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id) validated_data["image"] = image - + return super().create(validated_data) def get_url(self, obj): diff --git a/backend/apps/media/urls.py b/backend/apps/media/urls.py index 1fb20721..812fdc33 100644 --- a/backend/apps/media/urls.py +++ b/backend/apps/media/urls.py @@ -1,5 +1,6 @@ -from django.urls import path, include +from django.urls import include, path from rest_framework.routers import DefaultRouter + from .views import PhotoViewSet router = DefaultRouter() diff --git a/backend/apps/media/views.py b/backend/apps/media/views.py index baf54e49..f9b90265 100644 --- a/backend/apps/media/views.py +++ b/backend/apps/media/views.py @@ -1,8 +1,11 @@ -from rest_framework import viewsets, permissions, filters from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, permissions, viewsets + +from apps.core.permissions import IsOwnerOrReadOnly + from .models import Photo from .serializers import PhotoSerializer -from apps.core.permissions import IsOwnerOrReadOnly + class PhotoViewSet(viewsets.ModelViewSet): queryset = Photo.objects.filter(is_public=True) diff --git a/backend/apps/moderation/admin.py b/backend/apps/moderation/admin.py index 835410d2..1ca3ba2a 100644 --- a/backend/apps/moderation/admin.py +++ b/backend/apps/moderation/admin.py @@ -13,9 +13,7 @@ Performance targets: from django.contrib import admin, messages from django.contrib.admin import AdminSite -from django.db.models import Count from django.urls import reverse -from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe from django_fsm_log.models import StateLog diff --git a/backend/apps/moderation/apps.py b/backend/apps/moderation/apps.py index 6f734d71..b4317f14 100644 --- a/backend/apps/moderation/apps.py +++ b/backend/apps/moderation/apps.py @@ -2,7 +2,6 @@ import logging from django.apps import AppConfig - logger = logging.getLogger(__name__) @@ -20,11 +19,12 @@ class ModerationConfig(AppConfig): def _apply_state_machines(self): """Apply FSM to all moderation models.""" from apps.core.state_machine import apply_state_machine + from .models import ( - EditSubmission, - ModerationReport, - ModerationQueue, BulkOperation, + EditSubmission, + ModerationQueue, + ModerationReport, PhotoSubmission, ) @@ -62,21 +62,22 @@ class ModerationConfig(AppConfig): def _register_callbacks(self): """Register FSM transition callbacks for moderation models.""" - from apps.core.state_machine.registry import register_callback - from apps.core.state_machine.callbacks.notifications import ( - SubmissionApprovedNotification, - SubmissionRejectedNotification, - SubmissionEscalatedNotification, - ModerationNotificationCallback, - ) from apps.core.state_machine.callbacks.cache import ( ModerationCacheInvalidation, ) + from apps.core.state_machine.callbacks.notifications import ( + ModerationNotificationCallback, + SubmissionApprovedNotification, + SubmissionEscalatedNotification, + SubmissionRejectedNotification, + ) + from apps.core.state_machine.registry import register_callback + from .models import ( - EditSubmission, - ModerationReport, - ModerationQueue, BulkOperation, + EditSubmission, + ModerationQueue, + ModerationReport, PhotoSubmission, ) diff --git a/backend/apps/moderation/choices.py b/backend/apps/moderation/choices.py index 074ee32d..289215b6 100644 --- a/backend/apps/moderation/choices.py +++ b/backend/apps/moderation/choices.py @@ -5,7 +5,7 @@ This module defines all choice options for the moderation system using the Rich All choices include rich metadata for UI styling, business logic, and enhanced functionality. """ -from apps.core.choices.base import RichChoice, ChoiceCategory +from apps.core.choices.base import ChoiceCategory, RichChoice from apps.core.choices.registry import register_choices # ============================================================================ diff --git a/backend/apps/moderation/filters.py b/backend/apps/moderation/filters.py index 1b717f80..cb297038 100644 --- a/backend/apps/moderation/filters.py +++ b/backend/apps/moderation/filters.py @@ -5,19 +5,21 @@ This module contains Django filter classes for the moderation system, providing comprehensive filtering capabilities for all moderation models. """ +from datetime import timedelta + import django_filters from django.contrib.auth import get_user_model from django.db.models import Q from django.utils import timezone -from datetime import timedelta + +from apps.core.choices.registry import get_choices from .models import ( - ModerationReport, - ModerationQueue, - ModerationAction, BulkOperation, + ModerationAction, + ModerationQueue, + ModerationReport, ) -from apps.core.choices.registry import get_choices User = get_user_model() diff --git a/backend/apps/moderation/management/commands/analyze_transitions.py b/backend/apps/moderation/management/commands/analyze_transitions.py index e2cab2a4..53115741 100644 --- a/backend/apps/moderation/management/commands/analyze_transitions.py +++ b/backend/apps/moderation/management/commands/analyze_transitions.py @@ -5,13 +5,14 @@ This command provides insights into transition usage, patterns, and statistics across all models using django-fsm-log. """ -from django.core.management.base import BaseCommand -from django.db.models import Count, Avg, F -from django.db.models.functions import TruncDate, ExtractHour -from django.utils import timezone from datetime import timedelta -from django_fsm_log.models import StateLog + from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand +from django.db.models import Count +from django.db.models.functions import ExtractHour, TruncDate +from django.utils import timezone +from django_fsm_log.models import StateLog class Command(BaseCommand): @@ -143,7 +144,7 @@ class Command(BaseCommand): # System vs User transitions system_count = queryset.filter(by__isnull=True).count() user_count = queryset.exclude(by__isnull=True).count() - + self.stdout.write(self.style.SUCCESS('\n--- Transition Attribution ---')) self.stdout.write(f" User-initiated: {user_count} ({(user_count/total_transitions)*100:.1f}%)") self.stdout.write(f" System-initiated: {system_count} ({(system_count/total_transitions)*100:.1f}%)") @@ -181,7 +182,7 @@ class Command(BaseCommand): # Transition patterns (common sequences) self.stdout.write(self.style.SUCCESS('\n--- Common Transition Patterns ---')) self.stdout.write(' Analyzing transition sequences...') - + # Get recent objects and their transition sequences recent_objects = ( queryset.values('content_type', 'object_id') @@ -198,7 +199,7 @@ class Command(BaseCommand): .order_by('timestamp') .values_list('transition', flat=True) ) - + # Create pattern from consecutive transitions if len(transitions) >= 2: pattern = ' → '.join([t or 'N/A' for t in transitions[:3]]) @@ -215,7 +216,7 @@ class Command(BaseCommand): self.stdout.write(f" {pattern}: {count} occurrences") self.stdout.write( - self.style.SUCCESS(f'\n=== Analysis Complete ===\n') + self.style.SUCCESS('\n=== Analysis Complete ===\n') ) # Export options @@ -228,7 +229,7 @@ class Command(BaseCommand): """Export analysis results as JSON.""" import json from datetime import datetime - + data = { 'analysis_date': datetime.now().isoformat(), 'period_days': days, @@ -240,11 +241,11 @@ class Command(BaseCommand): ) ) } - + filename = f'transition_analysis_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json' with open(filename, 'w') as f: json.dump(data, f, indent=2, default=str) - + self.stdout.write( self.style.SUCCESS(f'Exported to {filename}') ) @@ -253,16 +254,16 @@ class Command(BaseCommand): """Export analysis results as CSV.""" import csv from datetime import datetime - + filename = f'transition_analysis_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' - + with open(filename, 'w', newline='') as f: writer = csv.writer(f) writer.writerow([ 'ID', 'Timestamp', 'Model', 'Object ID', 'State', 'Transition', 'User' ]) - + for log in queryset.select_related('content_type', 'by'): writer.writerow([ log.id, @@ -273,7 +274,7 @@ class Command(BaseCommand): log.transition or 'N/A', log.by.username if log.by else 'System' ]) - + self.stdout.write( self.style.SUCCESS(f'Exported to {filename}') ) diff --git a/backend/apps/moderation/management/commands/seed_submissions.py b/backend/apps/moderation/management/commands/seed_submissions.py index da5614f8..bb1b11db 100644 --- a/backend/apps/moderation/management/commands/seed_submissions.py +++ b/backend/apps/moderation/management/commands/seed_submissions.py @@ -1,11 +1,13 @@ -from django.core.management.base import BaseCommand +from datetime import date + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management.base import BaseCommand + from apps.moderation.models import EditSubmission, PhotoSubmission from apps.parks.models import Park from apps.rides.models import Ride -from datetime import date User = get_user_model() diff --git a/backend/apps/moderation/management/commands/validate_state_machines.py b/backend/apps/moderation/management/commands/validate_state_machines.py index 79d35f52..17c0dee2 100644 --- a/backend/apps/moderation/management/commands/validate_state_machines.py +++ b/backend/apps/moderation/management/commands/validate_state_machines.py @@ -1,13 +1,13 @@ """Management command to validate state machine configurations for moderation models.""" -from django.core.management.base import BaseCommand from django.core.management import CommandError +from django.core.management.base import BaseCommand from apps.core.state_machine import MetadataValidator from apps.moderation.models import ( - EditSubmission, - ModerationReport, - ModerationQueue, BulkOperation, + EditSubmission, + ModerationQueue, + ModerationReport, PhotoSubmission, ) diff --git a/backend/apps/moderation/migrations/0006_alter_bulkoperation_operation_type_and_more.py b/backend/apps/moderation/migrations/0006_alter_bulkoperation_operation_type_and_more.py index 4a59b40f..e1e88bb5 100644 --- a/backend/apps/moderation/migrations/0006_alter_bulkoperation_operation_type_and_more.py +++ b/backend/apps/moderation/migrations/0006_alter_bulkoperation_operation_type_and_more.py @@ -1,8 +1,9 @@ # Generated by Django 5.2.5 on 2025-09-15 17:35 -import apps.core.choices.fields from django.db import migrations +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/moderation/migrations/0007_convert_status_to_richfsmfield.py b/backend/apps/moderation/migrations/0007_convert_status_to_richfsmfield.py index 9b0774d4..40e9a661 100644 --- a/backend/apps/moderation/migrations/0007_convert_status_to_richfsmfield.py +++ b/backend/apps/moderation/migrations/0007_convert_status_to_richfsmfield.py @@ -2,9 +2,10 @@ # This migration converts status fields from RichChoiceField to RichFSMField # across all moderation models to enable FSM state management. -import apps.core.state_machine.fields from django.db import migrations +import apps.core.state_machine.fields + class Migration(migrations.Migration): diff --git a/backend/apps/moderation/migrations/0008_alter_bulkoperation_options_and_more.py b/backend/apps/moderation/migrations/0008_alter_bulkoperation_options_and_more.py index e7a8a35f..e0893669 100644 --- a/backend/apps/moderation/migrations/0008_alter_bulkoperation_options_and_more.py +++ b/backend/apps/moderation/migrations/0008_alter_bulkoperation_options_and_more.py @@ -1,10 +1,11 @@ # Generated by Django 5.1.6 on 2025-12-26 14:10 -import apps.core.state_machine.fields import django.db.models.deletion from django.conf import settings from django.db import migrations, models +import apps.core.state_machine.fields + class Migration(migrations.Migration): diff --git a/backend/apps/moderation/migrations/0009_add_claim_fields.py b/backend/apps/moderation/migrations/0009_add_claim_fields.py index 1f4a6865..bba415bd 100644 --- a/backend/apps/moderation/migrations/0009_add_claim_fields.py +++ b/backend/apps/moderation/migrations/0009_add_claim_fields.py @@ -1,12 +1,13 @@ # Generated by Django 5.1.6 on 2025-12-26 20:01 -import apps.core.state_machine.fields import django.db.models.deletion import pgtrigger.compiler import pgtrigger.migrations from django.conf import settings from django.db import migrations, models +import apps.core.state_machine.fields + class Migration(migrations.Migration): diff --git a/backend/apps/moderation/mixins.py b/backend/apps/moderation/mixins.py index 6f304219..c9544149 100644 --- a/backend/apps/moderation/mixins.py +++ b/backend/apps/moderation/mixins.py @@ -1,16 +1,18 @@ -from typing import Any, Dict, Optional, Type, cast +import json +from typing import Any, cast + +from django.contrib.auth import get_user_model from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.contenttypes.models import ContentType +from django.db import models from django.http import ( - JsonResponse, - HttpResponseForbidden, HttpRequest, HttpResponse, + HttpResponseForbidden, + JsonResponse, ) from django.views.generic import DetailView -from django.db import models -from django.contrib.auth import get_user_model -import json + from .models import EditSubmission, PhotoSubmission, UserType User = get_user_model() @@ -21,12 +23,12 @@ class EditSubmissionMixin(DetailView): Mixin for handling edit submissions with proper moderation. """ - model: Optional[Type[models.Model]] = None + model: type[models.Model] | None = None def handle_edit_submission( self, request: HttpRequest, - changes: Dict[str, Any], + changes: dict[str, Any], reason: str = "", source: str = "", submission_type: str = "EDIT", @@ -148,7 +150,7 @@ class PhotoSubmissionMixin(DetailView): Mixin for handling photo submissions with proper moderation. """ - model: Optional[Type[models.Model]] = None + model: type[models.Model] | None = None def handle_photo_submission(self, request: HttpRequest) -> JsonResponse: """Handle a photo submission based on user's role""" @@ -214,7 +216,7 @@ class PhotoSubmissionMixin(DetailView): class ModeratorRequiredMixin(UserPassesTestMixin): """Require moderator or higher role for access""" - request: Optional[HttpRequest] = None + request: HttpRequest | None = None def test_func(self) -> bool: if not self.request: @@ -235,7 +237,7 @@ class ModeratorRequiredMixin(UserPassesTestMixin): class AdminRequiredMixin(UserPassesTestMixin): """Require admin or superuser role for access""" - request: Optional[HttpRequest] = None + request: HttpRequest | None = None def test_func(self) -> bool: if not self.request: @@ -255,9 +257,9 @@ class AdminRequiredMixin(UserPassesTestMixin): class InlineEditMixin: """Add inline editing context to views""" - request: Optional[HttpRequest] = None + request: HttpRequest | None = None - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) # type: ignore if self.request and self.request.user.is_authenticated: context["can_edit"] = True @@ -285,7 +287,7 @@ class InlineEditMixin: class HistoryMixin: """Add edit history context to views""" - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) # type: ignore # Only add history context for DetailViews diff --git a/backend/apps/moderation/models.py b/backend/apps/moderation/models.py index 1c2092bf..ac01631d 100644 --- a/backend/apps/moderation/models.py +++ b/backend/apps/moderation/models.py @@ -16,19 +16,21 @@ Callbacks for notifications, cache invalidation, and related updates are registered via the callback configuration defined in each model's Meta class. """ -from typing import Any, Dict, Optional, Union -from django.db import models -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType +from datetime import timedelta +from typing import Any, Union + +import pghistory from django.conf import settings -from django.utils import timezone -from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import AnonymousUser -from datetime import timedelta -import pghistory -from apps.core.history import TrackedModel +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist +from django.db import models +from django.utils import timezone + from apps.core.choices.fields import RichChoiceField +from apps.core.history import TrackedModel from apps.core.state_machine import RichFSMField, StateMachineMixin UserType = Union[AbstractBaseUser, AnonymousUser] @@ -38,10 +40,10 @@ UserType = Union[AbstractBaseUser, AnonymousUser] def _get_notification_callbacks(): """Lazy import of notification callbacks.""" from apps.core.state_machine.callbacks.notifications import ( - SubmissionApprovedNotification, - SubmissionRejectedNotification, - SubmissionEscalatedNotification, ModerationNotificationCallback, + SubmissionApprovedNotification, + SubmissionEscalatedNotification, + SubmissionRejectedNotification, ) return { 'approved': SubmissionApprovedNotification, @@ -70,7 +72,7 @@ def _get_cache_callbacks(): @pghistory.track() # Track all changes by default class EditSubmission(StateMachineMixin, TrackedModel): """Edit submission model with FSM-managed status transitions.""" - + state_field_name = "status" # Who submitted the edit @@ -173,7 +175,7 @@ class EditSubmission(StateMachineMixin, TrackedModel): target = "Unknown" return f"{action} by {self.user.username} on {target}" - def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]: + def _resolve_foreign_keys(self, data: dict[str, Any]) -> dict[str, Any]: """Convert foreign key IDs to model instances""" if not (model_class := self.content_type.model_class()): raise ValueError("Could not resolve model class") @@ -197,7 +199,7 @@ class EditSubmission(StateMachineMixin, TrackedModel): return resolved_data - def _get_final_changes(self) -> Dict[str, Any]: + def _get_final_changes(self) -> dict[str, Any]: """Get the final changes to apply (moderator changes if available, otherwise original changes)""" return self.moderator_changes or self.changes @@ -213,12 +215,12 @@ class EditSubmission(StateMachineMixin, TrackedModel): ValidationError: If submission is not in PENDING state """ from django.core.exceptions import ValidationError - + if self.status != "PENDING": raise ValidationError( f"Cannot claim submission: current status is {self.status}, expected PENDING" ) - + self.transition_to_claimed(user=user) self.claimed_by = user self.claimed_at = timezone.now() @@ -236,12 +238,12 @@ class EditSubmission(StateMachineMixin, TrackedModel): ValidationError: If submission is not in CLAIMED state """ from django.core.exceptions import ValidationError - + if self.status != "CLAIMED": raise ValidationError( f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED" ) - + # Set status directly (not via FSM transition to avoid cycle) # This is intentional - the unclaim action is a special "rollback" operation self.status = "PENDING" @@ -249,7 +251,7 @@ class EditSubmission(StateMachineMixin, TrackedModel): self.claimed_at = None self.save() - def approve(self, moderator: UserType, user=None) -> Optional[models.Model]: + def approve(self, moderator: UserType, user=None) -> models.Model | None: """ Approve this submission and apply the changes. Wrapper method that preserves business logic while using FSM. @@ -266,16 +268,16 @@ class EditSubmission(StateMachineMixin, TrackedModel): ValidationError: If the data is invalid """ from django.core.exceptions import ValidationError - + # Use user parameter if provided (FSM convention) approver = user or moderator - + # Validate state - must be CLAIMED before approval if self.status != "CLAIMED": raise ValidationError( f"Cannot approve submission: must be CLAIMED first (current status: {self.status})" ) - + model_class = self.content_type.model_class() if not model_class: raise ValueError("Could not resolve model class") @@ -333,16 +335,16 @@ class EditSubmission(StateMachineMixin, TrackedModel): user: Alternative parameter for FSM compatibility """ from django.core.exceptions import ValidationError - + # Use user parameter if provided (FSM convention) rejecter = user or moderator - + # Validate state - must be CLAIMED before rejection if self.status != "CLAIMED": raise ValidationError( f"Cannot reject submission: must be CLAIMED first (current status: {self.status})" ) - + # Use FSM transition to update status self.transition_to_rejected(user=rejecter) self.handled_by = rejecter @@ -361,16 +363,16 @@ class EditSubmission(StateMachineMixin, TrackedModel): user: Alternative parameter for FSM compatibility """ from django.core.exceptions import ValidationError - + # Use user parameter if provided (FSM convention) escalator = user or moderator - + # Validate state - must be CLAIMED before escalation if self.status != "CLAIMED": raise ValidationError( f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})" ) - + # Use FSM transition to update status self.transition_to_escalated(user=escalator) self.handled_by = escalator @@ -401,7 +403,7 @@ class ModerationReport(StateMachineMixin, TrackedModel): This handles the initial reporting phase where users flag content or behavior that needs moderator attention. """ - + state_field_name = "status" # Report details @@ -493,7 +495,7 @@ class ModerationQueue(StateMachineMixin, TrackedModel): This represents items in the moderation queue that need attention, separate from the initial reports. """ - + state_field_name = "status" # Queue item details @@ -678,7 +680,7 @@ class BulkOperation(StateMachineMixin, TrackedModel): This handles large-scale operations like bulk updates, imports, exports, or mass moderation actions. """ - + state_field_name = "status" # Operation details @@ -773,7 +775,7 @@ class BulkOperation(StateMachineMixin, TrackedModel): @pghistory.track() # Track all changes by default class PhotoSubmission(StateMachineMixin, TrackedModel): """Photo submission model with FSM-managed status transitions.""" - + state_field_name = "status" # Who submitted the photo @@ -869,12 +871,12 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): ValidationError: If submission is not in PENDING state """ from django.core.exceptions import ValidationError - + if self.status != "PENDING": raise ValidationError( f"Cannot claim submission: current status is {self.status}, expected PENDING" ) - + self.transition_to_claimed(user=user) self.claimed_by = user self.claimed_at = timezone.now() @@ -892,12 +894,12 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): ValidationError: If submission is not in CLAIMED state """ from django.core.exceptions import ValidationError - + if self.status != "CLAIMED": raise ValidationError( f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED" ) - + # Set status directly (not via FSM transition to avoid cycle) # This is intentional - the unclaim action is a special "rollback" operation self.status = "PENDING" @@ -909,25 +911,26 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): """ Approve the photo submission. Wrapper method that preserves business logic while using FSM. - + Args: moderator: The user approving the submission notes: Optional approval notes user: Alternative parameter for FSM compatibility """ + from django.core.exceptions import ValidationError + from apps.parks.models.media import ParkPhoto from apps.rides.models.media import RidePhoto - from django.core.exceptions import ValidationError # Use user parameter if provided (FSM convention) approver = user or moderator - + # Validate state - must be CLAIMED before approval if self.status != "CLAIMED": raise ValidationError( f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})" ) - + # Determine the correct photo model based on the content type model_class = self.content_type.model_class() if model_class.__name__ == "Park": @@ -945,7 +948,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): caption=self.caption, is_approved=True, ) - + # Use FSM transition to update status self.transition_to_approved(user=approver) self.handled_by = approver # type: ignore @@ -957,23 +960,23 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): """ Reject the photo submission. Wrapper method that preserves business logic while using FSM. - + Args: moderator: The user rejecting the submission notes: Rejection reason user: Alternative parameter for FSM compatibility """ from django.core.exceptions import ValidationError - + # Use user parameter if provided (FSM convention) rejecter = user or moderator - + # Validate state - must be CLAIMED before rejection if self.status != "CLAIMED": raise ValidationError( f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})" ) - + # Use FSM transition to update status self.transition_to_rejected(user=rejecter) self.handled_by = rejecter # type: ignore @@ -994,23 +997,23 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): """ Escalate the photo submission to admin. Wrapper method that preserves business logic while using FSM. - + Args: moderator: The user escalating the submission notes: Escalation reason user: Alternative parameter for FSM compatibility """ from django.core.exceptions import ValidationError - + # Use user parameter if provided (FSM convention) escalator = user or moderator - + # Validate state - must be CLAIMED before escalation if self.status != "CLAIMED": raise ValidationError( f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})" ) - + # Use FSM transition to update status self.transition_to_escalated(user=escalator) self.handled_by = escalator # type: ignore diff --git a/backend/apps/moderation/permissions.py b/backend/apps/moderation/permissions.py index a99944ff..0f83a6fa 100644 --- a/backend/apps/moderation/permissions.py +++ b/backend/apps/moderation/permissions.py @@ -9,9 +9,11 @@ the permission to an FSM guard function, enabling alignment between API permissions and FSM transition checks. """ -from typing import Callable, Any, Optional -from rest_framework import permissions +from collections.abc import Callable +from typing import Any + from django.contrib.auth import get_user_model +from rest_framework import permissions User = get_user_model() @@ -35,7 +37,7 @@ class PermissionGuardAdapter: def __init__( self, permission_class: type, - error_message: Optional[str] = None, + error_message: str | None = None, ): """ Initialize the guard adapter. @@ -46,10 +48,10 @@ class PermissionGuardAdapter: """ self.permission_class = permission_class self._custom_error_message = error_message - self._last_error_code: Optional[str] = None + self._last_error_code: str | None = None @property - def error_code(self) -> Optional[str]: + def error_code(self) -> str | None: """Return the error code from the last failed check.""" return self._last_error_code @@ -118,7 +120,7 @@ class GuardMixin: """ @classmethod - def as_guard(cls, error_message: Optional[str] = None) -> Callable: + def as_guard(cls, error_message: str | None = None) -> Callable: """ Convert this permission class to an FSM guard function. @@ -443,9 +445,7 @@ class CanManageUserRestrictions(GuardMixin, permissions.BasePermission): # Admins can manage most restrictions if user_role == "ADMIN": # Admins cannot create permanent bans - if action_type == "USER_BAN" and request.data.get("duration_hours") is None: - return False - return True + return not (action_type == "USER_BAN" and request.data.get("duration_hours") is None) # Moderators can only manage basic restrictions if user_role == "MODERATOR": diff --git a/backend/apps/moderation/selectors.py b/backend/apps/moderation/selectors.py index f30787b2..18cb08e3 100644 --- a/backend/apps/moderation/selectors.py +++ b/backend/apps/moderation/selectors.py @@ -3,18 +3,19 @@ Selectors for moderation-related data retrieval. Following Django styleguide pattern for separating data access from business logic. """ -from typing import Optional, Dict, Any -from django.db.models import QuerySet, Count, F, ExpressionWrapper, FloatField +from datetime import timedelta +from typing import Any + +from django.contrib.auth.models import User +from django.db.models import Count, ExpressionWrapper, F, FloatField, QuerySet from django.db.models.functions import Extract from django.utils import timezone -from datetime import timedelta -from django.contrib.auth.models import User from .models import EditSubmission def pending_submissions_for_review( - *, content_type: Optional[str] = None, limit: int = 50 + *, content_type: str | None = None, limit: int = 50 ) -> QuerySet[EditSubmission]: """ Get pending submissions that need moderation review. @@ -39,7 +40,7 @@ def pending_submissions_for_review( def submissions_by_user( - *, user_id: int, status: Optional[str] = None + *, user_id: int, status: str | None = None ) -> QuerySet[EditSubmission]: """ Get submissions created by a specific user. @@ -105,7 +106,7 @@ def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]: def submissions_by_content_type( - *, content_type: str, status: Optional[str] = None + *, content_type: str, status: str | None = None ) -> QuerySet[EditSubmission]: """ Get submissions for a specific content type. @@ -127,7 +128,7 @@ def submissions_by_content_type( return queryset.order_by("-created_at") -def moderation_queue_summary() -> Dict[str, Any]: +def moderation_queue_summary() -> dict[str, Any]: """ Get summary statistics for the moderation queue. @@ -159,8 +160,8 @@ def moderation_queue_summary() -> Dict[str, Any]: def moderation_statistics_summary( - *, days: int = 30, moderator: Optional[User] = None -) -> Dict[str, Any]: + *, days: int = 30, moderator: User | None = None +) -> dict[str, Any]: """ Get comprehensive moderation statistics for a time period. @@ -175,10 +176,7 @@ def moderation_statistics_summary( base_queryset = EditSubmission.objects.filter(created_at__gte=cutoff_date) - if moderator: - handled_queryset = base_queryset.filter(handled_by=moderator) - else: - handled_queryset = base_queryset + handled_queryset = base_queryset.filter(handled_by=moderator) if moderator else base_queryset total_submissions = base_queryset.count() pending_submissions = base_queryset.filter(status="PENDING").count() @@ -258,7 +256,7 @@ def top_contributors(*, days: int = 30, limit: int = 10) -> QuerySet[User]: ) -def moderator_workload_summary(*, days: int = 30) -> Dict[str, Any]: +def moderator_workload_summary(*, days: int = 30) -> dict[str, Any]: """ Get workload distribution among moderators. diff --git a/backend/apps/moderation/serializers.py b/backend/apps/moderation/serializers.py index b27c636e..8f97a425 100644 --- a/backend/apps/moderation/serializers.py +++ b/backend/apps/moderation/serializers.py @@ -10,18 +10,19 @@ This module contains DRF serializers for the moderation system, including: All serializers include comprehensive validation and nested relationships. """ -from rest_framework import serializers +from datetime import timedelta + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils import timezone -from datetime import timedelta +from rest_framework import serializers from .models import ( - ModerationReport, - ModerationQueue, - ModerationAction, BulkOperation, EditSubmission, + ModerationAction, + ModerationQueue, + ModerationReport, PhotoSubmission, ) @@ -274,7 +275,7 @@ class ModerationReportSerializer(serializers.ModelSerializer): # Define SLA hours by priority sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72} - + if obj.priority in sla_hours: threshold = sla_hours[obj.priority] else: @@ -375,11 +376,10 @@ class UpdateModerationReportSerializer(serializers.ModelSerializer): def validate_status(self, value): """Validate status transitions.""" - if self.instance and self.instance.status == "RESOLVED": - if value != "RESOLVED": - raise serializers.ValidationError( - "Cannot change status of resolved report" - ) + if self.instance and self.instance.status == "RESOLVED" and value != "RESOLVED": + raise serializers.ValidationError( + "Cannot change status of resolved report" + ) return value def update(self, instance, validated_data): @@ -935,7 +935,7 @@ class PhotoSubmissionSerializer(serializers.ModelSerializer): source="content_type.model", read_only=True ) photo_url = serializers.SerializerMethodField() - + # UI Metadata status_display = serializers.CharField(source="get_status_display", read_only=True) status_color = serializers.SerializerMethodField() diff --git a/backend/apps/moderation/services.py b/backend/apps/moderation/services.py index aaea170e..46778b0f 100644 --- a/backend/apps/moderation/services.py +++ b/backend/apps/moderation/services.py @@ -3,14 +3,16 @@ Services for moderation functionality. Following Django styleguide pattern for business logic encapsulation. """ -from typing import Optional, Dict, Any, Union +from typing import Any + from django.db import transaction -from django.utils import timezone from django.db.models import QuerySet +from django.utils import timezone from django_fsm import TransitionNotAllowed from apps.accounts.models import User -from .models import EditSubmission, PhotoSubmission, ModerationQueue + +from .models import EditSubmission, ModerationQueue, PhotoSubmission class ModerationService: @@ -18,8 +20,8 @@ class ModerationService: @staticmethod def approve_submission( - *, submission_id: int, moderator: User, notes: Optional[str] = None - ) -> Union[object, None]: + *, submission_id: int, moderator: User, notes: str | None = None + ) -> object | None: """ Approve a content submission and apply changes. @@ -115,10 +117,10 @@ class ModerationService: def create_edit_submission( *, content_object: object, - changes: Dict[str, Any], + changes: dict[str, Any], submitter: User, submission_type: str = "UPDATE", - notes: Optional[str] = None, + notes: str | None = None, ) -> EditSubmission: """ Create a new edit submission for moderation. @@ -154,7 +156,7 @@ class ModerationService: def update_submission_changes( *, submission_id: int, - moderator_changes: Dict[str, Any], + moderator_changes: dict[str, Any], moderator: User, ) -> EditSubmission: """ @@ -199,8 +201,8 @@ class ModerationService: def get_pending_submissions_for_moderator( *, moderator: User, - content_type: Optional[str] = None, - limit: Optional[int] = None, + content_type: str | None = None, + limit: int | None = None, ) -> QuerySet: """ Get pending submissions for a moderator to review. @@ -219,8 +221,8 @@ class ModerationService: @staticmethod def get_submission_statistics( - *, days: int = 30, moderator: Optional[User] = None - ) -> Dict[str, Any]: + *, days: int = 30, moderator: User | None = None + ) -> dict[str, Any]: """ Get moderation statistics for a time period. @@ -251,13 +253,13 @@ class ModerationService: @staticmethod def create_edit_submission_with_queue( *, - content_object: Optional[object], - changes: Dict[str, Any], + content_object: object | None, + changes: dict[str, Any], submitter: User, submission_type: str = "EDIT", - reason: Optional[str] = None, - source: Optional[str] = None, - ) -> Dict[str, Any]: + reason: str | None = None, + source: str | None = None, + ) -> dict[str, Any]: """ Create an edit submission with automatic queue routing. @@ -332,7 +334,7 @@ class ModerationService: caption: str = "", date_taken=None, submitter: User, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Create a photo submission with automatic queue routing. @@ -508,8 +510,8 @@ class ModerationService: @staticmethod def process_queue_item( - *, queue_item_id: int, moderator: User, action: str, notes: Optional[str] = None - ) -> Dict[str, Any]: + *, queue_item_id: int, moderator: User, action: str, notes: str | None = None + ) -> dict[str, Any]: """ Process a moderation queue item (approve, reject, etc.). @@ -675,6 +677,6 @@ class ModerationService: queue_item.full_clean() queue_item.save() - + result['queue_item'] = queue_item return result diff --git a/backend/apps/moderation/signals.py b/backend/apps/moderation/signals.py index 662cbc0a..91dddb22 100644 --- a/backend/apps/moderation/signals.py +++ b/backend/apps/moderation/signals.py @@ -13,14 +13,7 @@ Includes: import logging -from django.conf import settings -from django.dispatch import receiver, Signal - -from apps.core.state_machine.signals import ( - post_state_transition, - state_transition_failed, -) - +from django.dispatch import Signal logger = logging.getLogger(__name__) @@ -269,6 +262,7 @@ def _update_related_queue_items(instance, status): """Update queue items related to a moderation object.""" try: from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import ModerationQueue content_type = ContentType.objects.get_for_model(type(instance)) @@ -328,10 +322,10 @@ def _finalize_bulk_operation(instance, success): def _broadcast_submission_status_change(instance, source, target, user): """ Broadcast submission status change for real-time UI updates. - + Emits the submission_status_changed signal with a structured payload that can be consumed by notification systems (Novu, SSE, WebSocket, etc.). - + Payload format: { "submission_id": 123, @@ -344,11 +338,11 @@ def _broadcast_submission_status_change(instance, source, target, user): } """ try: - from .models import EditSubmission, PhotoSubmission - + from .models import EditSubmission + # Determine submission type submission_type = "edit" if isinstance(instance, EditSubmission) else "photo" - + # Build the broadcast payload payload = { "submission_id": instance.pk, @@ -359,13 +353,13 @@ def _broadcast_submission_status_change(instance, source, target, user): "locked_at": None, "changed_by": user.username if user else None, } - + # Add claim information if available if hasattr(instance, 'claimed_by') and instance.claimed_by: payload["locked_by"] = instance.claimed_by.username if hasattr(instance, 'claimed_at') and instance.claimed_at: payload["locked_at"] = instance.claimed_at.isoformat() - + # Emit the signal for downstream notification handlers submission_status_changed.send( sender=type(instance), @@ -376,7 +370,7 @@ def _broadcast_submission_status_change(instance, source, target, user): locked_by=payload["locked_by"], payload=payload, ) - + logger.debug( f"Broadcast status change: {submission_type}#{instance.pk} " f"{source} -> {target}" @@ -397,11 +391,11 @@ def register_moderation_signal_handlers(): try: from apps.moderation.models import ( - EditSubmission, - PhotoSubmission, - ModerationReport, - ModerationQueue, BulkOperation, + EditSubmission, + ModerationQueue, + ModerationReport, + PhotoSubmission, ) # EditSubmission handlers diff --git a/backend/apps/moderation/sse.py b/backend/apps/moderation/sse.py index b5f74535..17567a8a 100644 --- a/backend/apps/moderation/sse.py +++ b/backend/apps/moderation/sse.py @@ -8,14 +8,11 @@ import json import logging import queue import threading -import time -from typing import Generator +from collections.abc import Generator -from django.http import StreamingHttpResponse, JsonResponse -from django.views import View -from django.contrib.auth.mixins import LoginRequiredMixin -from rest_framework.views import APIView +from django.http import JsonResponse, StreamingHttpResponse from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView from apps.moderation.permissions import CanViewModerationData from apps.moderation.signals import submission_status_changed @@ -27,15 +24,15 @@ logger = logging.getLogger(__name__) class SSEBroadcaster: """ Manages SSE connections and broadcasts events to all clients. - + Uses a simple subscriber pattern where each connected client gets its own queue of events to consume. """ - + def __init__(self): self._subscribers: list[queue.Queue] = [] self._lock = threading.Lock() - + def subscribe(self) -> queue.Queue: """Create a new subscriber queue and register it.""" client_queue = queue.Queue() @@ -43,14 +40,14 @@ class SSEBroadcaster: self._subscribers.append(client_queue) logger.debug(f"SSE client subscribed. Total clients: {len(self._subscribers)}") return client_queue - + def unsubscribe(self, client_queue: queue.Queue): """Remove a subscriber queue.""" with self._lock: if client_queue in self._subscribers: self._subscribers.remove(client_queue) logger.debug(f"SSE client unsubscribed. Total clients: {len(self._subscribers)}") - + def broadcast(self, event_data: dict): """Send an event to all connected clients.""" with self._lock: @@ -68,7 +65,7 @@ sse_broadcaster = SSEBroadcaster() def handle_submission_status_changed(sender, payload, **kwargs): """ Signal handler that broadcasts submission status changes to SSE clients. - + Connected to the submission_status_changed signal from signals.py. """ sse_broadcaster.broadcast(payload) @@ -82,14 +79,14 @@ submission_status_changed.connect(handle_submission_status_changed) class ModerationSSEView(APIView): """ Server-Sent Events endpoint for real-time moderation updates. - + Provides a streaming response that sends submission status changes as they occur. Clients should connect to this endpoint and keep the connection open to receive real-time updates. - + Response format (SSE): data: {"submission_id": 1, "new_status": "CLAIMED", ...} - + Usage: const eventSource = new EventSource('/api/moderation/sse/') eventSource.onmessage = (event) => { @@ -97,22 +94,22 @@ class ModerationSSEView(APIView): // Handle update } """ - + permission_classes = [IsAuthenticated, CanViewModerationData] - + def get(self, request): """ Establish SSE connection and stream events. - + Sends a heartbeat every 30 seconds to keep the connection alive. """ - def event_stream() -> Generator[str, None, None]: + def event_stream() -> Generator[str]: client_queue = sse_broadcaster.subscribe() - + try: # Send initial connection event yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established'})}\n\n" - + while True: try: # Wait for event with timeout for heartbeat @@ -120,13 +117,13 @@ class ModerationSSEView(APIView): yield f"data: {json.dumps(event)}\n\n" except queue.Empty: # Send heartbeat to keep connection alive - yield f": heartbeat\n\n" + yield ": heartbeat\n\n" except GeneratorExit: # Client disconnected sse_broadcaster.unsubscribe(client_queue) finally: sse_broadcaster.unsubscribe(client_queue) - + response = StreamingHttpResponse( event_stream(), content_type='text/event-stream' @@ -134,17 +131,17 @@ class ModerationSSEView(APIView): response['Cache-Control'] = 'no-cache' response['X-Accel-Buffering'] = 'no' # Disable nginx buffering response['Connection'] = 'keep-alive' - + return response class ModerationSSETestView(APIView): """ Test endpoint to manually trigger an SSE event. - + This is useful for testing the SSE connection without making actual state transitions. - + POST /api/moderation/sse/test/ { "submission_id": 1, @@ -153,9 +150,9 @@ class ModerationSSETestView(APIView): "previous_status": "PENDING" } """ - + permission_classes = [IsAuthenticated, CanViewModerationData] - + def post(self, request): """Broadcast a test event.""" test_payload = { @@ -168,9 +165,9 @@ class ModerationSSETestView(APIView): "changed_by": request.user.username, "test": True, } - + sse_broadcaster.broadcast(test_payload) - + return JsonResponse({ "status": "ok", "message": f"Test event broadcast to {len(sse_broadcaster._subscribers)} clients", diff --git a/backend/apps/moderation/templatetags/moderation_tags.py b/backend/apps/moderation/templatetags/moderation_tags.py index 765cd8fd..f7f39017 100644 --- a/backend/apps/moderation/templatetags/moderation_tags.py +++ b/backend/apps/moderation/templatetags/moderation_tags.py @@ -1,12 +1,13 @@ +from typing import Any + from django import template from django.contrib.contenttypes.models import ContentType -from typing import Optional, Dict, Any, List, Union register = template.Library() @register.filter -def get_object_name(value: Optional[int], model_path: str) -> Optional[str]: +def get_object_name(value: int | None, model_path: str) -> str | None: """Get object name from ID and model path.""" if not value or not model_path or "." not in model_path: return None @@ -27,7 +28,7 @@ def get_object_name(value: Optional[int], model_path: str) -> Optional[str]: @register.filter -def get_category_display(value: Optional[str]) -> Optional[str]: +def get_category_display(value: str | None) -> str | None: """Get display value for ride category.""" if not value: return None @@ -44,7 +45,7 @@ def get_category_display(value: Optional[str]) -> Optional[str]: @register.filter -def get_park_area_name(value: Optional[int], park_id: Optional[int]) -> Optional[str]: +def get_park_area_name(value: int | None, park_id: int | None) -> str | None: """Get park area name from ID and park ID.""" if not value or not park_id: return None @@ -60,8 +61,8 @@ def get_park_area_name(value: Optional[int], park_id: Optional[int]) -> Optional @register.filter def get_item( - dictionary: Optional[Dict[str, Any]], key: Optional[Union[str, int]] -) -> List[Any]: + dictionary: dict[str, Any] | None, key: str | int | None +) -> list[Any]: """Get item from dictionary by key.""" if not dictionary or not isinstance(dictionary, dict) or not key: return [] diff --git a/backend/apps/moderation/tests.py b/backend/apps/moderation/tests.py index 7cacf622..d9870b03 100644 --- a/backend/apps/moderation/tests.py +++ b/backend/apps/moderation/tests.py @@ -11,34 +11,36 @@ This module contains tests for: - Mixin functionality tests """ -from django.test import TestCase, Client +import json + from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile -from django.http import JsonResponse, HttpRequest +from django.http import HttpRequest, JsonResponse +from django.test import Client, RequestFactory, TestCase from django.utils import timezone -from django_fsm import TransitionNotAllowed -from .models import ( - EditSubmission, - PhotoSubmission, - ModerationReport, - ModerationQueue, - BulkOperation, - ModerationAction, -) -from .mixins import ( - EditSubmissionMixin, - PhotoSubmissionMixin, - ModeratorRequiredMixin, - AdminRequiredMixin, - InlineEditMixin, - HistoryMixin, -) -from apps.parks.models import Company as Operator from django.views.generic import DetailView -from django.test import RequestFactory -import json +from django_fsm import TransitionNotAllowed + +from apps.parks.models import Company as Operator + +from .mixins import ( + AdminRequiredMixin, + EditSubmissionMixin, + HistoryMixin, + InlineEditMixin, + ModeratorRequiredMixin, + PhotoSubmissionMixin, +) +from .models import ( + BulkOperation, + EditSubmission, + ModerationAction, + ModerationQueue, + ModerationReport, + PhotoSubmission, +) User = get_user_model() @@ -421,12 +423,12 @@ class EditSubmissionTransitionTests(TestCase): """Test transition from PENDING to APPROVED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') - + submission.transition_to_approved(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.moderator) @@ -436,13 +438,13 @@ class EditSubmissionTransitionTests(TestCase): """Test transition from PENDING to REJECTED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') - + submission.transition_to_rejected(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.notes = 'Rejected: Insufficient evidence' submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') self.assertEqual(submission.handled_by, self.moderator) @@ -452,25 +454,25 @@ class EditSubmissionTransitionTests(TestCase): """Test transition from PENDING to ESCALATED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') - + submission.transition_to_escalated(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.notes = 'Escalated: Needs admin review' submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'ESCALATED') def test_escalated_to_approved_transition(self): """Test transition from ESCALATED to APPROVED.""" submission = self._create_submission(status='ESCALATED') - + submission.transition_to_approved(user=self.admin) submission.handled_by = self.admin submission.handled_at = timezone.now() submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.admin) @@ -478,20 +480,20 @@ class EditSubmissionTransitionTests(TestCase): def test_escalated_to_rejected_transition(self): """Test transition from ESCALATED to REJECTED.""" submission = self._create_submission(status='ESCALATED') - + submission.transition_to_rejected(user=self.admin) submission.handled_by = self.admin submission.handled_at = timezone.now() submission.notes = 'Rejected by admin' submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') def test_invalid_transition_from_approved(self): """Test that transitions from APPROVED state fail.""" submission = self._create_submission(status='APPROVED') - + # Attempting to transition from APPROVED should raise TransitionNotAllowed with self.assertRaises(TransitionNotAllowed): submission.transition_to_rejected(user=self.moderator) @@ -499,7 +501,7 @@ class EditSubmissionTransitionTests(TestCase): def test_invalid_transition_from_rejected(self): """Test that transitions from REJECTED state fail.""" submission = self._create_submission(status='REJECTED') - + # Attempting to transition from REJECTED should raise TransitionNotAllowed with self.assertRaises(TransitionNotAllowed): submission.transition_to_approved(user=self.moderator) @@ -507,9 +509,9 @@ class EditSubmissionTransitionTests(TestCase): def test_approve_wrapper_method(self): """Test the approve() wrapper method.""" submission = self._create_submission() - - result = submission.approve(self.moderator) - + + submission.approve(self.moderator) + submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.moderator) @@ -518,9 +520,9 @@ class EditSubmissionTransitionTests(TestCase): def test_reject_wrapper_method(self): """Test the reject() wrapper method.""" submission = self._create_submission() - + submission.reject(self.moderator, reason='Not enough evidence') - + submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') self.assertIn('Not enough evidence', submission.notes) @@ -528,9 +530,9 @@ class EditSubmissionTransitionTests(TestCase): def test_escalate_wrapper_method(self): """Test the escalate() wrapper method.""" submission = self._create_submission() - + submission.escalate(self.moderator, reason='Needs admin approval') - + submission.refresh_from_db() self.assertEqual(submission.status, 'ESCALATED') self.assertIn('Needs admin approval', submission.notes) @@ -582,11 +584,11 @@ class ModerationReportTransitionTests(TestCase): """Test transition from PENDING to UNDER_REVIEW.""" report = self._create_report() self.assertEqual(report.status, 'PENDING') - + report.transition_to_under_review(user=self.moderator) report.assigned_moderator = self.moderator report.save() - + report.refresh_from_db() self.assertEqual(report.status, 'UNDER_REVIEW') self.assertEqual(report.assigned_moderator, self.moderator) @@ -596,13 +598,13 @@ class ModerationReportTransitionTests(TestCase): report = self._create_report(status='UNDER_REVIEW') report.assigned_moderator = self.moderator report.save() - + report.transition_to_resolved(user=self.moderator) report.resolution_action = 'Content updated' report.resolution_notes = 'Fixed the incorrect information' report.resolved_at = timezone.now() report.save() - + report.refresh_from_db() self.assertEqual(report.status, 'RESOLVED') self.assertIsNotNone(report.resolved_at) @@ -612,26 +614,26 @@ class ModerationReportTransitionTests(TestCase): report = self._create_report(status='UNDER_REVIEW') report.assigned_moderator = self.moderator report.save() - + report.transition_to_dismissed(user=self.moderator) report.resolution_notes = 'Report is not valid' report.resolved_at = timezone.now() report.save() - + report.refresh_from_db() self.assertEqual(report.status, 'DISMISSED') def test_invalid_transition_from_resolved(self): """Test that transitions from RESOLVED state fail.""" report = self._create_report(status='RESOLVED') - + with self.assertRaises(TransitionNotAllowed): report.transition_to_dismissed(user=self.moderator) def test_invalid_transition_from_dismissed(self): """Test that transitions from DISMISSED state fail.""" report = self._create_report(status='DISMISSED') - + with self.assertRaises(TransitionNotAllowed): report.transition_to_resolved(user=self.moderator) @@ -668,12 +670,12 @@ class ModerationQueueTransitionTests(TestCase): """Test transition from PENDING to IN_PROGRESS.""" item = self._create_queue_item() self.assertEqual(item.status, 'PENDING') - + item.transition_to_in_progress(user=self.moderator) item.assigned_to = self.moderator item.assigned_at = timezone.now() item.save() - + item.refresh_from_db() self.assertEqual(item.status, 'IN_PROGRESS') self.assertEqual(item.assigned_to, self.moderator) @@ -683,10 +685,10 @@ class ModerationQueueTransitionTests(TestCase): item = self._create_queue_item(status='IN_PROGRESS') item.assigned_to = self.moderator item.save() - + item.transition_to_completed(user=self.moderator) item.save() - + item.refresh_from_db() self.assertEqual(item.status, 'COMPLETED') @@ -695,27 +697,27 @@ class ModerationQueueTransitionTests(TestCase): item = self._create_queue_item(status='IN_PROGRESS') item.assigned_to = self.moderator item.save() - + item.transition_to_cancelled(user=self.moderator) item.save() - + item.refresh_from_db() self.assertEqual(item.status, 'CANCELLED') def test_pending_to_cancelled_transition(self): """Test transition from PENDING to CANCELLED.""" item = self._create_queue_item() - + item.transition_to_cancelled(user=self.moderator) item.save() - + item.refresh_from_db() self.assertEqual(item.status, 'CANCELLED') def test_invalid_transition_from_completed(self): """Test that transitions from COMPLETED state fail.""" item = self._create_queue_item(status='COMPLETED') - + with self.assertRaises(TransitionNotAllowed): item.transition_to_in_progress(user=self.moderator) @@ -753,11 +755,11 @@ class BulkOperationTransitionTests(TestCase): """Test transition from PENDING to RUNNING.""" operation = self._create_bulk_operation() self.assertEqual(operation.status, 'PENDING') - + operation.transition_to_running(user=self.admin) operation.started_at = timezone.now() operation.save() - + operation.refresh_from_db() self.assertEqual(operation.status, 'RUNNING') self.assertIsNotNone(operation.started_at) @@ -767,12 +769,12 @@ class BulkOperationTransitionTests(TestCase): operation = self._create_bulk_operation(status='RUNNING') operation.started_at = timezone.now() operation.save() - + operation.transition_to_completed(user=self.admin) operation.completed_at = timezone.now() operation.processed_items = 100 operation.save() - + operation.refresh_from_db() self.assertEqual(operation.status, 'COMPLETED') self.assertIsNotNone(operation.completed_at) @@ -783,13 +785,13 @@ class BulkOperationTransitionTests(TestCase): operation = self._create_bulk_operation(status='RUNNING') operation.started_at = timezone.now() operation.save() - + operation.transition_to_failed(user=self.admin) operation.completed_at = timezone.now() operation.results = {'error': 'Database connection failed'} operation.failed_items = 50 operation.save() - + operation.refresh_from_db() self.assertEqual(operation.status, 'FAILED') self.assertEqual(operation.failed_items, 50) @@ -797,10 +799,10 @@ class BulkOperationTransitionTests(TestCase): def test_pending_to_cancelled_transition(self): """Test transition from PENDING to CANCELLED.""" operation = self._create_bulk_operation() - + operation.transition_to_cancelled(user=self.admin) operation.save() - + operation.refresh_from_db() self.assertEqual(operation.status, 'CANCELLED') @@ -809,24 +811,24 @@ class BulkOperationTransitionTests(TestCase): operation = self._create_bulk_operation(status='RUNNING') operation.can_cancel = True operation.save() - + operation.transition_to_cancelled(user=self.admin) operation.save() - + operation.refresh_from_db() self.assertEqual(operation.status, 'CANCELLED') def test_invalid_transition_from_completed(self): """Test that transitions from COMPLETED state fail.""" operation = self._create_bulk_operation(status='COMPLETED') - + with self.assertRaises(TransitionNotAllowed): operation.transition_to_running(user=self.admin) def test_invalid_transition_from_failed(self): """Test that transitions from FAILED state fail.""" operation = self._create_bulk_operation(status='FAILED') - + with self.assertRaises(TransitionNotAllowed): operation.transition_to_completed(user=self.admin) @@ -835,12 +837,12 @@ class BulkOperationTransitionTests(TestCase): operation = self._create_bulk_operation() operation.total_items = 100 operation.processed_items = 50 - + self.assertEqual(operation.progress_percentage, 50.0) - + operation.processed_items = 0 self.assertEqual(operation.progress_percentage, 0.0) - + operation.total_items = 0 self.assertEqual(operation.progress_percentage, 0.0) @@ -876,7 +878,7 @@ class TransitionLoggingTestCase(TestCase): def test_transition_creates_log(self): """Test that transitions create StateLog entries.""" from django_fsm_log.models import StateLog - + # Create a submission submission = EditSubmission.objects.create( user=self.user, @@ -887,18 +889,18 @@ class TransitionLoggingTestCase(TestCase): status='PENDING', reason='Test reason' ) - + # Perform transition submission.transition_to_approved(user=self.moderator) submission.save() - + # Check log was created submission_ct = ContentType.objects.get_for_model(submission) log = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).first() - + self.assertIsNotNone(log, "StateLog entry should be created") self.assertEqual(log.state, 'APPROVED') self.assertEqual(log.by, self.moderator) @@ -907,7 +909,7 @@ class TransitionLoggingTestCase(TestCase): def test_multiple_transitions_logged(self): """Test that multiple transitions are all logged.""" from django_fsm_log.models import StateLog - + submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, @@ -917,23 +919,23 @@ class TransitionLoggingTestCase(TestCase): status='PENDING', reason='Test reason' ) - + submission_ct = ContentType.objects.get_for_model(submission) - + # First transition submission.transition_to_escalated(user=self.moderator) submission.save() - + # Second transition submission.transition_to_approved(user=self.moderator) submission.save() - + # Check multiple logs created logs = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).order_by('timestamp') - + self.assertEqual(logs.count(), 2, "Should have 2 log entries") self.assertEqual(logs[0].state, 'ESCALATED') self.assertEqual(logs[1].state, 'APPROVED') @@ -941,10 +943,10 @@ class TransitionLoggingTestCase(TestCase): def test_history_endpoint_returns_logs(self): """Test history API endpoint returns transition logs.""" from rest_framework.test import APIClient - + api_client = APIClient() api_client.force_authenticate(user=self.moderator) - + submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, @@ -954,21 +956,21 @@ class TransitionLoggingTestCase(TestCase): status='PENDING', reason='Test reason' ) - + # Perform transition to create log submission.transition_to_approved(user=self.moderator) submission.save() - + # Note: This assumes EditSubmission has a history endpoint # Adjust URL pattern based on actual implementation response = api_client.get('/api/moderation/reports/all_history/') - + self.assertEqual(response.status_code, 200) def test_system_transitions_without_user(self): """Test that system transitions work without a user.""" from django_fsm_log.models import StateLog - + submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, @@ -978,18 +980,18 @@ class TransitionLoggingTestCase(TestCase): status='PENDING', reason='Test reason' ) - + # Perform transition without user submission.transition_to_rejected(user=None) submission.save() - + # Check log was created even without user submission_ct = ContentType.objects.get_for_model(submission) log = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).first() - + self.assertIsNotNone(log) self.assertEqual(log.state, 'REJECTED') self.assertIsNone(log.by, "System transitions should have no user") @@ -997,7 +999,7 @@ class TransitionLoggingTestCase(TestCase): def test_transition_log_includes_description(self): """Test that transition logs can include descriptions.""" from django_fsm_log.models import StateLog - + submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, @@ -1007,27 +1009,27 @@ class TransitionLoggingTestCase(TestCase): status='PENDING', reason='Test reason' ) - + # Perform transition submission.transition_to_approved(user=self.moderator) submission.save() - + # Check log submission_ct = ContentType.objects.get_for_model(submission) log = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).first() - + self.assertIsNotNone(log) # Description field exists and can be used for audit trails self.assertTrue(hasattr(log, 'description')) def test_log_ordering_by_timestamp(self): """Test that logs are properly ordered by timestamp.""" + from django_fsm_log.models import StateLog - import time - + submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, @@ -1037,22 +1039,22 @@ class TransitionLoggingTestCase(TestCase): status='PENDING', reason='Test reason' ) - + submission_ct = ContentType.objects.get_for_model(submission) - + # Create multiple transitions submission.transition_to_escalated(user=self.moderator) submission.save() - + submission.transition_to_approved(user=self.moderator) submission.save() - + # Get logs ordered by timestamp logs = list(StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).order_by('timestamp')) - + # Verify ordering self.assertEqual(len(logs), 2) self.assertTrue(logs[0].timestamp <= logs[1].timestamp) @@ -1091,7 +1093,7 @@ class ModerationActionTests(TestCase): moderator=self.moderator, target_user=self.target_user ) - + self.assertIsNotNone(action.expires_at) # expires_at should be approximately 24 hours from now time_diff = action.expires_at - timezone.now() @@ -1106,7 +1108,7 @@ class ModerationActionTests(TestCase): moderator=self.moderator, target_user=self.target_user ) - + self.assertIsNone(action.expires_at) def test_action_is_active_by_default(self): @@ -1168,7 +1170,7 @@ class PhotoSubmissionTransitionTests(TestCase): def _create_submission(self, status='PENDING'): """Helper to create a PhotoSubmission.""" # Create using direct database creation to bypass FK validation - from unittest.mock import patch, Mock + from unittest.mock import Mock, patch with patch.object(PhotoSubmission, 'photo', Mock()): submission = PhotoSubmission( diff --git a/backend/apps/moderation/tests/test_admin.py b/backend/apps/moderation/tests/test_admin.py index 5b93c9df..9d9cf1e4 100644 --- a/backend/apps/moderation/tests/test_admin.py +++ b/backend/apps/moderation/tests/test_admin.py @@ -6,7 +6,6 @@ state log, and history event admin classes including query optimization and custom moderation actions. """ -import pytest from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase @@ -14,7 +13,6 @@ from django.test import RequestFactory, TestCase from apps.moderation.admin import ( EditSubmissionAdmin, HistoryEventAdmin, - ModerationAdminSite, PhotoSubmissionAdmin, StateLogAdmin, moderation_site, diff --git a/backend/apps/moderation/tests/test_workflows.py b/backend/apps/moderation/tests/test_workflows.py index b9605e31..7ff807ce 100644 --- a/backend/apps/moderation/tests/test_workflows.py +++ b/backend/apps/moderation/tests/test_workflows.py @@ -9,18 +9,18 @@ This module tests end-to-end moderation workflows including: - Bulk operation workflow """ -from django.test import TestCase + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.test import TestCase from django.utils import timezone -from unittest.mock import patch, Mock User = get_user_model() class SubmissionApprovalWorkflowTests(TestCase): """Tests for the complete submission approval workflow.""" - + @classmethod def setUpTestData(cls): """Set up test data for all tests.""" @@ -42,22 +42,22 @@ class SubmissionApprovalWorkflowTests(TestCase): password='testpass123', role='ADMIN' ) - + def test_edit_submission_approval_workflow(self): """ Test complete edit submission approval workflow. - + Flow: User submits → Moderator reviews → Moderator approves → Changes applied """ from apps.moderation.models import EditSubmission from apps.parks.models import Company - + # Create target object company = Company.objects.create( name='Test Company', description='Original description' ) - + # User submits an edit content_type = ContentType.objects.get_for_model(company) submission = EditSubmission.objects.create( @@ -69,31 +69,31 @@ class SubmissionApprovalWorkflowTests(TestCase): status='PENDING', reason='Fixing typo' ) - + self.assertEqual(submission.status, 'PENDING') self.assertIsNone(submission.handled_by) self.assertIsNone(submission.handled_at) - + # Moderator approves submission.transition_to_approved(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.moderator) self.assertIsNotNone(submission.handled_at) - + def test_photo_submission_approval_workflow(self): """ Test complete photo submission approval workflow. - + Flow: User submits photo → Moderator reviews → Moderator approves → Photo created """ from apps.moderation.models import PhotoSubmission - from apps.parks.models import Park, Company - + from apps.parks.models import Company, Park + # Create target park operator = Company.objects.create( name='Test Operator', @@ -106,7 +106,7 @@ class SubmissionApprovalWorkflowTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + # User submits a photo content_type = ContentType.objects.get_for_model(park) submission = PhotoSubmission.objects.create( @@ -117,22 +117,22 @@ class SubmissionApprovalWorkflowTests(TestCase): photo_type='GENERAL', description='Beautiful park entrance' ) - + self.assertEqual(submission.status, 'PENDING') - + # Moderator approves submission.transition_to_approved(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') class SubmissionRejectionWorkflowTests(TestCase): """Tests for the submission rejection workflow.""" - + @classmethod def setUpTestData(cls): cls.regular_user = User.objects.create_user( @@ -147,21 +147,21 @@ class SubmissionRejectionWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def test_edit_submission_rejection_with_reason(self): """ Test rejection workflow with reason. - + Flow: User submits → Moderator rejects with reason → User notified """ from apps.moderation.models import EditSubmission from apps.parks.models import Company - + company = Company.objects.create( name='Test Company', description='Original' ) - + content_type = ContentType.objects.get_for_model(company) submission = EditSubmission.objects.create( user=self.regular_user, @@ -172,14 +172,14 @@ class SubmissionRejectionWorkflowTests(TestCase): status='PENDING', reason='Name change request' ) - + # Moderator rejects submission.transition_to_rejected(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.notes = 'Rejected: Content appears to be spam' submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') self.assertIn('spam', submission.notes.lower()) @@ -187,7 +187,7 @@ class SubmissionRejectionWorkflowTests(TestCase): class SubmissionEscalationWorkflowTests(TestCase): """Tests for the submission escalation workflow.""" - + @classmethod def setUpTestData(cls): cls.regular_user = User.objects.create_user( @@ -208,21 +208,21 @@ class SubmissionEscalationWorkflowTests(TestCase): password='testpass123', role='ADMIN' ) - + def test_escalation_workflow(self): """ Test complete escalation workflow. - + Flow: User submits → Moderator escalates → Admin reviews → Admin approves """ from apps.moderation.models import EditSubmission from apps.parks.models import Company - + company = Company.objects.create( name='Sensitive Company', description='Original' ) - + content_type = ContentType.objects.get_for_model(company) submission = EditSubmission.objects.create( user=self.regular_user, @@ -233,20 +233,20 @@ class SubmissionEscalationWorkflowTests(TestCase): status='PENDING', reason='Major name change' ) - + # Moderator escalates submission.transition_to_escalated(user=self.moderator) submission.notes = 'Escalated: Major change needs admin review' submission.save() - + self.assertEqual(submission.status, 'ESCALATED') - + # Admin approves submission.transition_to_approved(user=self.admin) submission.handled_by = self.admin submission.handled_at = timezone.now() submission.save() - + submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.admin) @@ -254,7 +254,7 @@ class SubmissionEscalationWorkflowTests(TestCase): class ReportHandlingWorkflowTests(TestCase): """Tests for the moderation report handling workflow.""" - + @classmethod def setUpTestData(cls): cls.reporter = User.objects.create_user( @@ -269,23 +269,23 @@ class ReportHandlingWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def test_report_resolution_workflow(self): """ Test complete report resolution workflow. - + Flow: User reports → Moderator assigned → Moderator investigates → Resolved """ from apps.moderation.models import ModerationReport from apps.parks.models import Company - + reported_company = Company.objects.create( name='Problematic Company', description='Some inappropriate content' ) - + content_type = ContentType.objects.get_for_model(reported_company) - + # User reports content report = ModerationReport.objects.create( report_type='CONTENT', @@ -298,44 +298,44 @@ class ReportHandlingWorkflowTests(TestCase): description='This content is inappropriate', reported_by=self.reporter ) - + self.assertEqual(report.status, 'PENDING') - + # Moderator claims and starts review report.transition_to_under_review(user=self.moderator) report.assigned_moderator = self.moderator report.save() - + self.assertEqual(report.status, 'UNDER_REVIEW') self.assertEqual(report.assigned_moderator, self.moderator) - + # Moderator resolves report.transition_to_resolved(user=self.moderator) report.resolution_action = 'CONTENT_REMOVED' report.resolution_notes = 'Content was removed' report.resolved_at = timezone.now() report.save() - + report.refresh_from_db() self.assertEqual(report.status, 'RESOLVED') self.assertIsNotNone(report.resolved_at) - + def test_report_dismissal_workflow(self): """ Test report dismissal workflow for invalid reports. - + Flow: User reports → Moderator reviews → Moderator dismisses """ from apps.moderation.models import ModerationReport from apps.parks.models import Company - + company = Company.objects.create( name='Valid Company', description='Normal content' ) - + content_type = ContentType.objects.get_for_model(company) - + report = ModerationReport.objects.create( report_type='CONTENT', status='PENDING', @@ -347,25 +347,25 @@ class ReportHandlingWorkflowTests(TestCase): description='I just do not like this', reported_by=self.reporter ) - + # Moderator claims report.transition_to_under_review(user=self.moderator) report.assigned_moderator = self.moderator report.save() - + # Moderator dismisses as invalid report.transition_to_dismissed(user=self.moderator) report.resolution_notes = 'Report does not violate any guidelines' report.resolved_at = timezone.now() report.save() - + report.refresh_from_db() self.assertEqual(report.status, 'DISMISSED') class BulkOperationWorkflowTests(TestCase): """Tests for bulk operation workflows.""" - + @classmethod def setUpTestData(cls): cls.admin = User.objects.create_user( @@ -374,15 +374,15 @@ class BulkOperationWorkflowTests(TestCase): password='testpass123', role='ADMIN' ) - + def test_bulk_operation_success_workflow(self): """ Test successful bulk operation workflow. - + Flow: Admin creates → Operation runs → Progress tracked → Completed """ from apps.moderation.models import BulkOperation - + operation = BulkOperation.objects.create( operation_type='APPROVE_SUBMISSIONS', status='PENDING', @@ -391,39 +391,39 @@ class BulkOperationWorkflowTests(TestCase): created_by=self.admin, parameters={'submission_ids': list(range(1, 11))} ) - + self.assertEqual(operation.status, 'PENDING') - + # Start operation operation.transition_to_running(user=self.admin) operation.started_at = timezone.now() operation.save() - + self.assertEqual(operation.status, 'RUNNING') - + # Simulate progress for i in range(1, 11): operation.processed_items = i operation.save() - + # Complete operation operation.transition_to_completed(user=self.admin) operation.completed_at = timezone.now() operation.results = {'approved': 10, 'failed': 0} operation.save() - + operation.refresh_from_db() self.assertEqual(operation.status, 'COMPLETED') self.assertEqual(operation.processed_items, 10) - + def test_bulk_operation_failure_workflow(self): """ Test bulk operation failure workflow. - + Flow: Admin creates → Operation runs → Error occurs → Failed """ from apps.moderation.models import BulkOperation - + operation = BulkOperation.objects.create( operation_type='DELETE_CONTENT', status='PENDING', @@ -432,11 +432,11 @@ class BulkOperationWorkflowTests(TestCase): created_by=self.admin, parameters={'content_ids': list(range(1, 6))} ) - + operation.transition_to_running(user=self.admin) operation.started_at = timezone.now() operation.save() - + # Simulate partial progress then failure operation.processed_items = 2 operation.failed_items = 3 @@ -444,19 +444,19 @@ class BulkOperationWorkflowTests(TestCase): operation.completed_at = timezone.now() operation.results = {'error': 'Database connection lost', 'processed': 2} operation.save() - + operation.refresh_from_db() self.assertEqual(operation.status, 'FAILED') self.assertEqual(operation.failed_items, 3) - + def test_bulk_operation_cancellation_workflow(self): """ Test bulk operation cancellation workflow. - + Flow: Admin creates → Operation runs → Admin cancels """ from apps.moderation.models import BulkOperation - + operation = BulkOperation.objects.create( operation_type='BATCH_UPDATE', status='PENDING', @@ -466,20 +466,20 @@ class BulkOperationWorkflowTests(TestCase): parameters={'update_field': 'status'}, can_cancel=True ) - + operation.transition_to_running(user=self.admin) operation.save() - + # Partial progress operation.processed_items = 30 operation.save() - + # Admin cancels operation.transition_to_cancelled(user=self.admin) operation.completed_at = timezone.now() operation.results = {'cancelled_at': 30, 'reason': 'User requested cancellation'} operation.save() - + operation.refresh_from_db() self.assertEqual(operation.status, 'CANCELLED') self.assertEqual(operation.processed_items, 30) @@ -487,7 +487,7 @@ class BulkOperationWorkflowTests(TestCase): class ModerationQueueWorkflowTests(TestCase): """Tests for moderation queue workflows.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -496,15 +496,15 @@ class ModerationQueueWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def test_queue_completion_workflow(self): """ Test queue item completion workflow. - + Flow: Item created → Moderator claims → Work done → Completed """ from apps.moderation.models import ModerationQueue - + queue_item = ModerationQueue.objects.create( queue_type='SUBMISSION_REVIEW', status='PENDING', @@ -512,21 +512,21 @@ class ModerationQueueWorkflowTests(TestCase): item_type='edit_submission', item_id=123 ) - + self.assertEqual(queue_item.status, 'PENDING') - + # Moderator claims queue_item.transition_to_in_progress(user=self.moderator) queue_item.assigned_to = self.moderator queue_item.assigned_at = timezone.now() queue_item.save() - + self.assertEqual(queue_item.status, 'IN_PROGRESS') - + # Work completed queue_item.transition_to_completed(user=self.moderator) queue_item.completed_at = timezone.now() queue_item.save() - + queue_item.refresh_from_db() self.assertEqual(queue_item.status, 'COMPLETED') diff --git a/backend/apps/moderation/urls.py b/backend/apps/moderation/urls.py index 7b78ea3f..548fa6b8 100644 --- a/backend/apps/moderation/urls.py +++ b/backend/apps/moderation/urls.py @@ -6,29 +6,29 @@ All endpoints are nested under /api/moderation/ and provide comprehensive moderation functionality including reports, queue management, actions, and bulk operations. """ -from django.urls import path, include +from django.urls import include, path from django.views.generic import TemplateView from rest_framework.routers import DefaultRouter -from .views import ( - ModerationReportViewSet, - ModerationQueueViewSet, - ModerationActionViewSet, - BulkOperationViewSet, - UserModerationViewSet, - EditSubmissionViewSet, - PhotoSubmissionViewSet, -) -from .sse import ModerationSSEView, ModerationSSETestView from apps.core.views.views import FSMTransitionView +from .sse import ModerationSSETestView, ModerationSSEView +from .views import ( + BulkOperationViewSet, + EditSubmissionViewSet, + ModerationActionViewSet, + ModerationQueueViewSet, + ModerationReportViewSet, + PhotoSubmissionViewSet, + UserModerationViewSet, +) + class ModerationDashboardView(TemplateView): """Moderation dashboard view with HTMX integration.""" template_name = "moderation/dashboard.html" def get_context_data(self, **kwargs): - from .models import EditSubmission, PhotoSubmission from .selectors import pending_submissions_for_review context = super().get_context_data(**kwargs) @@ -41,9 +41,10 @@ class SubmissionListView(TemplateView): template_name = "moderation/partials/dashboard_content.html" def get_context_data(self, **kwargs): - from .models import EditSubmission, PhotoSubmission from itertools import chain + from .models import EditSubmission, PhotoSubmission + context = super().get_context_data(**kwargs) status = self.request.GET.get("status", "PENDING") diff --git a/backend/apps/moderation/views.py b/backend/apps/moderation/views.py index e66d713a..8a7b6c87 100644 --- a/backend/apps/moderation/views.py +++ b/backend/apps/moderation/views.py @@ -10,63 +10,62 @@ This module contains DRF viewsets for the moderation system, including: All views include comprehensive permissions, filtering, and pagination. """ -from rest_framework import viewsets, status, permissions -from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework.filters import SearchFilter, OrderingFilter -from django_filters.rest_framework import DjangoFilterBackend -from django.contrib.auth import get_user_model -from django.utils import timezone -from django.db.models import Q, Count -from django.shortcuts import render -from django.core.paginator import Paginator +import logging from datetime import timedelta -from django_fsm import can_proceed, TransitionNotAllowed +from django.contrib.auth import get_user_model +from django.core.paginator import Paginator +from django.db.models import Count, Q +from django.shortcuts import render +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend +from django_fsm import TransitionNotAllowed, can_proceed +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.response import Response + +from apps.core.logging import log_business_event from apps.core.state_machine.exceptions import ( TransitionPermissionDenied, TransitionValidationError, format_transition_error, ) +from .filters import ( + BulkOperationFilter, + ModerationActionFilter, + ModerationQueueFilter, + ModerationReportFilter, +) from .models import ( - ModerationReport, - ModerationQueue, - ModerationAction, BulkOperation, EditSubmission, + ModerationAction, + ModerationQueue, + ModerationReport, PhotoSubmission, ) -from .serializers import ( - ModerationReportSerializer, - CreateModerationReportSerializer, - UpdateModerationReportSerializer, - ModerationQueueSerializer, - AssignQueueItemSerializer, - CompleteQueueItemSerializer, - ModerationActionSerializer, - CreateModerationActionSerializer, - BulkOperationSerializer, - CreateBulkOperationSerializer, - UserModerationProfileSerializer, - EditSubmissionSerializer, - EditSubmissionListSerializer, - PhotoSubmissionSerializer, -) -from .filters import ( - ModerationReportFilter, - ModerationQueueFilter, - ModerationActionFilter, - BulkOperationFilter, -) -import logging - -from apps.core.logging import log_exception, log_business_event - from .permissions import ( - IsModeratorOrAdmin, - IsAdminOrSuperuser, CanViewModerationData, + IsAdminOrSuperuser, + IsModeratorOrAdmin, +) +from .serializers import ( + AssignQueueItemSerializer, + BulkOperationSerializer, + CompleteQueueItemSerializer, + CreateBulkOperationSerializer, + CreateModerationActionSerializer, + CreateModerationReportSerializer, + EditSubmissionListSerializer, + EditSubmissionSerializer, + ModerationActionSerializer, + ModerationQueueSerializer, + ModerationReportSerializer, + PhotoSubmissionSerializer, + UpdateModerationReportSerializer, + UserModerationProfileSerializer, ) User = get_user_model() @@ -352,8 +351,8 @@ class ModerationReportViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["get"], permission_classes=[CanViewModerationData]) def history(self, request, pk=None): """Get transition history for this report.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog report = self.get_object() content_type = ContentType.objects.get_for_model(report) @@ -387,8 +386,8 @@ class ModerationReportViewSet(viewsets.ModelViewSet): Supports both HTMX (returns HTML partials) and API (returns JSON) requests. """ - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog queryset = StateLog.objects.select_related("by", "content_type").all() @@ -829,8 +828,8 @@ class ModerationQueueViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["get"], permission_classes=[CanViewModerationData]) def history(self, request, pk=None): """Get transition history for this queue item.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog queue_item = self.get_object() content_type = ContentType.objects.get_for_model(queue_item) @@ -890,10 +889,7 @@ class ModerationActionViewSet(viewsets.ModelViewSet): def get_permissions(self): """Return appropriate permissions based on action.""" - if self.action == "create": - permission_classes = [IsModeratorOrAdmin] - else: - permission_classes = [CanViewModerationData] + permission_classes = [IsModeratorOrAdmin] if self.action == "create" else [CanViewModerationData] return [permission() for permission in permission_classes] @@ -1125,8 +1121,8 @@ class BulkOperationViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["get"]) def history(self, request, pk=None): """Get transition history for this bulk operation.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog operation = self.get_object() content_type = ContentType.objects.get_for_model(operation) @@ -1404,7 +1400,7 @@ class UserModerationViewSet(viewsets.ViewSet): class EditSubmissionViewSet(viewsets.ModelViewSet): """ ViewSet for managing edit submissions. - + Includes claim/unclaim endpoints with concurrency protection using database row locking (select_for_update) to prevent race conditions. """ @@ -1425,7 +1421,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet): status = self.request.query_params.get("status") if status: queryset = queryset.filter(status=status) - + # User filter user_id = self.request.query_params.get("user") if user_id: @@ -1437,20 +1433,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet): def claim(self, request, pk=None): """ Claim a submission for review with concurrency protection. - + Uses select_for_update() to acquire a database row lock, preventing race conditions when multiple moderators try to claim the same submission simultaneously. - + Returns: 200: Submission successfully claimed 404: Submission not found 409: Submission already claimed or being claimed by another moderator 400: Invalid state for claiming """ - from django.db import transaction, DatabaseError from django.core.exceptions import ValidationError - + from django.db import DatabaseError, transaction + with transaction.atomic(): try: # Lock the row for update - other transactions will fail immediately @@ -1466,7 +1462,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet): {"error": "Submission is being claimed by another moderator. Please try again."}, status=status.HTTP_409_CONFLICT ) - + # Check if already claimed if submission.status == "CLAIMED": return Response( @@ -1477,14 +1473,14 @@ class EditSubmissionViewSet(viewsets.ModelViewSet): }, status=status.HTTP_409_CONFLICT ) - + # Check if in valid state for claiming if submission.status != "PENDING": return Response( {"error": f"Cannot claim submission in {submission.status} state"}, status=status.HTTP_400_BAD_REQUEST ) - + try: submission.claim(user=request.user) log_business_event( @@ -1506,26 +1502,26 @@ class EditSubmissionViewSet(viewsets.ModelViewSet): def unclaim(self, request, pk=None): """ Release claim on a submission. - + Only the claiming moderator or an admin can unclaim a submission. """ from django.core.exceptions import ValidationError - + submission = self.get_object() - + # Only the claiming user or an admin can unclaim if submission.claimed_by != request.user and not request.user.is_staff: return Response( {"error": "Only the claiming moderator or an admin can unclaim"}, status=status.HTTP_403_FORBIDDEN ) - + if submission.status != "CLAIMED": return Response( {"error": "Submission is not claimed"}, status=status.HTTP_400_BAD_REQUEST ) - + try: submission.unclaim(user=request.user) log_business_event( @@ -1547,7 +1543,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet): def approve(self, request, pk=None): submission = self.get_object() user = request.user - + try: submission.approve(moderator=user) return Response(self.get_serializer(submission).data) @@ -1559,19 +1555,19 @@ class EditSubmissionViewSet(viewsets.ModelViewSet): submission = self.get_object() user = request.user reason = request.data.get("reason", "") - + try: submission.reject(moderator=user, reason=reason) return Response(self.get_serializer(submission).data) except Exception as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) def escalate(self, request, pk=None): submission = self.get_object() user = request.user reason = request.data.get("reason", "") - + try: submission.escalate(moderator=user, reason=reason) return Response(self.get_serializer(submission).data) @@ -1582,7 +1578,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet): class PhotoSubmissionViewSet(viewsets.ModelViewSet): """ ViewSet for managing photo submissions. - + Includes claim/unclaim endpoints with concurrency protection using database row locking (select_for_update) to prevent race conditions. """ @@ -1599,24 +1595,24 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet): status = self.request.query_params.get("status") if status: queryset = queryset.filter(status=status) - + # User filter user_id = self.request.query_params.get("user") if user_id: queryset = queryset.filter(user_id=user_id) - + return queryset @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) def claim(self, request, pk=None): """ Claim a photo submission for review with concurrency protection. - + Uses select_for_update() to acquire a database row lock. """ - from django.db import transaction, DatabaseError from django.core.exceptions import ValidationError - + from django.db import DatabaseError, transaction + with transaction.atomic(): try: submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk) @@ -1630,7 +1626,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet): {"error": "Submission is being claimed by another moderator. Please try again."}, status=status.HTTP_409_CONFLICT ) - + if submission.status == "CLAIMED": return Response( { @@ -1640,13 +1636,13 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet): }, status=status.HTTP_409_CONFLICT ) - + if submission.status != "PENDING": return Response( {"error": f"Cannot claim submission in {submission.status} state"}, status=status.HTTP_400_BAD_REQUEST ) - + try: submission.claim(user=request.user) log_business_event( @@ -1668,21 +1664,21 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet): def unclaim(self, request, pk=None): """Release claim on a photo submission.""" from django.core.exceptions import ValidationError - + submission = self.get_object() - + if submission.claimed_by != request.user and not request.user.is_staff: return Response( {"error": "Only the claiming moderator or an admin can unclaim"}, status=status.HTTP_403_FORBIDDEN ) - + if submission.status != "CLAIMED": return Response( {"error": "Submission is not claimed"}, status=status.HTTP_400_BAD_REQUEST ) - + try: submission.unclaim(user=request.user) log_business_event( @@ -1705,7 +1701,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet): submission = self.get_object() user = request.user notes = request.data.get("notes", "") - + try: submission.approve(moderator=user, notes=notes) return Response(self.get_serializer(submission).data) @@ -1717,7 +1713,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet): submission = self.get_object() user = request.user notes = request.data.get("notes", "") - + try: submission.reject(moderator=user, notes=notes) return Response(self.get_serializer(submission).data) @@ -1729,7 +1725,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet): submission = self.get_object() user = request.user notes = request.data.get("notes", "") - + try: submission.escalate(moderator=user, notes=notes) return Response(self.get_serializer(submission).data) diff --git a/backend/apps/parks/admin.py b/backend/apps/parks/admin.py index e2bc454e..9921c85a 100644 --- a/backend/apps/parks/admin.py +++ b/backend/apps/parks/admin.py @@ -325,10 +325,7 @@ class ParkAdmin( @admin.display(description="Avg Rating") def average_rating(self, obj): """Display average park review rating.""" - if hasattr(obj, "_avg_rating"): - rating = obj._avg_rating - else: - rating = obj.reviews.aggregate(avg=Avg("rating"))["avg"] + rating = obj._avg_rating if hasattr(obj, "_avg_rating") else obj.reviews.aggregate(avg=Avg("rating"))["avg"] if rating: stars = "★" * int(rating) + "☆" * (5 - int(rating)) return format_html('{} {:.1f}', stars, rating) diff --git a/backend/apps/parks/apps.py b/backend/apps/parks/apps.py index d8fb5af7..a88f63c7 100644 --- a/backend/apps/parks/apps.py +++ b/backend/apps/parks/apps.py @@ -2,7 +2,6 @@ import logging from django.apps import AppConfig - logger = logging.getLogger(__name__) @@ -11,8 +10,8 @@ class ParksConfig(AppConfig): name = "apps.parks" def ready(self): - import apps.parks.signals # noqa: F401 - Register signals import apps.parks.choices # noqa: F401 - Register choices + import apps.parks.signals # noqa: F401 - Register signals self._apply_state_machines() self._register_callbacks() @@ -29,10 +28,9 @@ class ParksConfig(AppConfig): def _register_callbacks(self): """Register FSM transition callbacks for park models.""" - from apps.core.state_machine.registry import register_callback from apps.core.state_machine.callbacks.cache import ( - ParkCacheInvalidation, APICacheInvalidation, + ParkCacheInvalidation, ) from apps.core.state_machine.callbacks.notifications import ( StatusChangeNotification, @@ -40,6 +38,7 @@ class ParksConfig(AppConfig): from apps.core.state_machine.callbacks.related_updates import ( SearchTextUpdateCallback, ) + from apps.core.state_machine.registry import register_callback from apps.parks.models import Park # Cache invalidation for all park status changes diff --git a/backend/apps/parks/choices.py b/backend/apps/parks/choices.py index 9292da97..37e83da8 100644 --- a/backend/apps/parks/choices.py +++ b/backend/apps/parks/choices.py @@ -5,10 +5,9 @@ This module defines all choice objects for the parks domain, replacing the legacy tuple-based choices with rich choice objects. """ -from apps.core.choices import RichChoice, ChoiceCategory +from apps.core.choices import ChoiceCategory, RichChoice from apps.core.choices.registry import register_choices - # Park Status Choices PARK_STATUSES = [ RichChoice( @@ -287,7 +286,7 @@ PARKS_COMPANY_ROLES = [ def register_parks_choices(): """Register all parks domain choices with the global registry""" - + register_choices( name="statuses", choices=PARK_STATUSES, @@ -295,7 +294,7 @@ def register_parks_choices(): description="Park operational status options", metadata={'domain': 'parks', 'type': 'status'} ) - + register_choices( name="types", choices=PARK_TYPES, @@ -303,7 +302,7 @@ def register_parks_choices(): description="Park type and category classifications", metadata={'domain': 'parks', 'type': 'park_type'} ) - + register_choices( name="company_roles", choices=PARKS_COMPANY_ROLES, diff --git a/backend/apps/parks/filters.py b/backend/apps/parks/filters.py index a99bd831..25148dd9 100644 --- a/backend/apps/parks/filters.py +++ b/backend/apps/parks/filters.py @@ -1,22 +1,24 @@ -from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ -from django.db import models +import requests from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ from django_filters import ( - NumberFilter, - ModelChoiceFilter, - DateFromToRangeFilter, - ChoiceFilter, - FilterSet, - CharFilter, BooleanFilter, + CharFilter, + ChoiceFilter, + DateFromToRangeFilter, + FilterSet, + ModelChoiceFilter, + NumberFilter, OrderingFilter, ) -from .models import Park, Company -from .querysets import get_base_park_queryset + from apps.core.choices.registry import get_choices -import requests + +from .models import Company, Park +from .querysets import get_base_park_queryset def validate_positive_integer(value): diff --git a/backend/apps/parks/forms.py b/backend/apps/parks/forms.py index 77d29097..1be322eb 100644 --- a/backend/apps/parks/forms.py +++ b/backend/apps/parks/forms.py @@ -1,8 +1,10 @@ -from django import forms -from decimal import Decimal, InvalidOperation, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal, InvalidOperation + from autocomplete.core import register from autocomplete.shortcuts import ModelAutocomplete from autocomplete.widgets import AutocompleteWidget +from django import forms + from .models import Park from .models.location import ParkLocation diff --git a/backend/apps/parks/location_utils.py b/backend/apps/parks/location_utils.py index 88e2e385..cee95c97 100644 --- a/backend/apps/parks/location_utils.py +++ b/backend/apps/parks/location_utils.py @@ -1,4 +1,4 @@ -from decimal import Decimal, ROUND_DOWN, InvalidOperation +from decimal import ROUND_DOWN, Decimal, InvalidOperation def normalize_coordinate(value, max_digits, decimal_places): diff --git a/backend/apps/parks/management/commands/create_sample_data.py b/backend/apps/parks/management/commands/create_sample_data.py index c1bad679..d89aa716 100644 --- a/backend/apps/parks/management/commands/create_sample_data.py +++ b/backend/apps/parks/management/commands/create_sample_data.py @@ -208,110 +208,3 @@ class Command(BaseCommand): # Park creation data - will be used to create parks in the database # See FUTURE_WORK.md - THRILLWIKI-111 for implementation plan - parks_data = [ - { - "name": "Magic Kingdom", - "slug": "magic-kingdom", - "operator_slug": "walt-disney-company", - "property_owner_slug": "walt-disney-company", - "description": "The first theme park at Walt Disney World Resort in Florida, opened in 1971.", - "opening_date": "1971-10-01", - "size_acres": 142, - "website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/", - "location": { - "street_address": "1180 Seven Seas Dr", - "city": "Lake Buena Vista", - "state_province": "Florida", - "country": "USA", - "postal_code": "32830", - "latitude": 28.4177, - "longitude": -81.5812, - }, - }, - { - "name": "Universal Studios Florida", - "slug": "universal-studios-florida", - "operator_slug": "universal-parks-resorts", - "property_owner_slug": "universal-parks-resorts", - "description": "Movie and television-based theme park in Orlando, Florida.", - "opening_date": "1990-06-07", - "size_acres": 108, - "website": "https://www.universalorlando.com/web/en/us/theme-parks/universal-studios-florida", - "location": { - "street_address": "6000 Universal Blvd", - "city": "Orlando", - "state_province": "Florida", - "country": "USA", - "postal_code": "32819", - "latitude": 28.4749, - "longitude": -81.4687, - }, - }, - { - "name": "Cedar Point", - "slug": "cedar-point", - "operator_slug": "cedar-fair-entertainment", - "property_owner_slug": "cedar-fair-entertainment", - "description": 'Known as the "Roller Coaster Capital of the World".', - "opening_date": "1870-06-01", - "size_acres": 364, - "website": "https://www.cedarpoint.com/", - "location": { - "street_address": "1 Cedar Point Dr", - "city": "Sandusky", - "state_province": "Ohio", - "country": "USA", - "postal_code": "44870", - "latitude": 41.4822, - "longitude": -82.6835, - }, - }, - { - "name": "Six Flags Magic Mountain", - "slug": "six-flags-magic-mountain", - "operator_slug": "six-flags-entertainment", - "property_owner_slug": "six-flags-entertainment", - "description": "Known for its world-record 19 roller coasters.", - "opening_date": "1971-05-29", - "size_acres": 262, - "website": "https://www.sixflags.com/magicmountain", - "location": { - "street_address": "26101 Magic Mountain Pkwy", - "city": "Valencia", - "state_province": "California", - "country": "USA", - "postal_code": "91355", - "latitude": 34.4253, - "longitude": -118.5971, - }, - }, - { - "name": "Europa-Park", - "slug": "europa-park", - "operator_slug": "merlin-entertainments", - "property_owner_slug": "merlin-entertainments", - "description": "One of the most popular theme parks in Europe, located in Germany.", - "opening_date": "1975-07-12", - "size_acres": 234, - "website": "https://www.europapark.de/", - "location": { - "street_address": "Europa-Park-Straße 2", - "city": "Rust", - "state_province": "Baden-Württemberg", - "country": "Germany", - "postal_code": "77977", - "latitude": 48.2667, - "longitude": 7.7167, - }, - }, - { - "name": "Alton Towers", - "slug": "alton-towers", - "operator_slug": "merlin-entertainments", - "property_owner_slug": "merlin-entertainments", - "description": "Major theme park and former country estate in Staffordshire, England.", - "opening_date": "1980-04-23", - "size_acres": 500, - # Add other fields as needed - }, - ] diff --git a/backend/apps/parks/management/commands/seed_initial_data.py b/backend/apps/parks/management/commands/seed_initial_data.py index d96aa255..3f386436 100644 --- a/backend/apps/parks/management/commands/seed_initial_data.py +++ b/backend/apps/parks/management/commands/seed_initial_data.py @@ -1,5 +1,7 @@ from django.core.management.base import BaseCommand -from apps.parks.models import Park, ParkArea, ParkLocation, Company as Operator + +from apps.parks.models import Company as Operator +from apps.parks.models import Park, ParkArea, ParkLocation class Command(BaseCommand): diff --git a/backend/apps/parks/management/commands/seed_sample_data.py b/backend/apps/parks/management/commands/seed_sample_data.py index 93ece7a2..623ab271 100644 --- a/backend/apps/parks/management/commands/seed_sample_data.py +++ b/backend/apps/parks/management/commands/seed_sample_data.py @@ -1,16 +1,17 @@ -from django.core.management.base import BaseCommand -from django.db import transaction, connection import logging -from apps.parks.models import Company, Park, ParkArea, ParkReview, ParkLocation -from apps.rides.models.company import Company as RideCompany +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.accounts.models import User +from apps.parks.models import Company, Park, ParkArea, ParkLocation, ParkReview from apps.rides.models import ( Ride, RideModel, RideReview, RollerCoasterStats, ) -from apps.accounts.models import User +from apps.rides.models.company import Company as RideCompany class Command(BaseCommand): diff --git a/backend/apps/parks/management/commands/test_location.py b/backend/apps/parks/management/commands/test_location.py index 47e68fba..9a945086 100644 --- a/backend/apps/parks/management/commands/test_location.py +++ b/backend/apps/parks/management/commands/test_location.py @@ -1,5 +1,6 @@ from django.core.management.base import BaseCommand -from apps.parks.models import Park, ParkLocation, Company + +from apps.parks.models import Company, Park, ParkLocation class Command(BaseCommand): @@ -100,8 +101,8 @@ class Command(BaseCommand): # Test spatial indexing self.stdout.write("\n🔍 Testing spatial queries:") try: - from django.contrib.gis.measure import D from django.contrib.gis.geos import Point + from django.contrib.gis.measure import D # Find parks within 100km of a point # Same as Disneyland diff --git a/backend/apps/parks/management/commands/update_park_counts.py b/backend/apps/parks/management/commands/update_park_counts.py index 40de4a6b..82bc925c 100644 --- a/backend/apps/parks/management/commands/update_park_counts.py +++ b/backend/apps/parks/management/commands/update_park_counts.py @@ -1,5 +1,6 @@ from django.core.management.base import BaseCommand from django.db.models import Q + from apps.parks.models import Park diff --git a/backend/apps/parks/managers.py b/backend/apps/parks/managers.py index 62608a74..68dbb084 100644 --- a/backend/apps/parks/managers.py +++ b/backend/apps/parks/managers.py @@ -3,17 +3,17 @@ Custom managers and QuerySets for Parks models. Optimized queries following Django styleguide patterns. """ -from django.db.models import Q, Count, Avg, Max, Min, Prefetch +from django.db.models import Avg, Count, Max, Min, Prefetch, Q from apps.core.managers import ( - BaseQuerySet, BaseManager, - LocationQuerySet, + BaseQuerySet, LocationManager, - ReviewableQuerySet, + LocationQuerySet, ReviewableManager, - StatusQuerySet, + ReviewableQuerySet, StatusManager, + StatusQuerySet, ) @@ -51,6 +51,7 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet): def optimized_for_detail(self): """Optimize for park detail display.""" from apps.rides.models import Ride + from .models import ParkReview return self.select_related("operator", "property_owner").prefetch_related( diff --git a/backend/apps/parks/migrations/0008_parkphoto_parkphotoevent_and_more.py b/backend/apps/parks/migrations/0008_parkphoto_parkphotoevent_and_more.py index 7beb70f4..0b1d2fd0 100644 --- a/backend/apps/parks/migrations/0008_parkphoto_parkphotoevent_and_more.py +++ b/backend/apps/parks/migrations/0008_parkphoto_parkphotoevent_and_more.py @@ -1,12 +1,13 @@ # Generated by Django 5.2.5 on 2025-08-26 17:39 -import apps.parks.models.media import django.db.models.deletion import pgtrigger.compiler import pgtrigger.migrations from django.conf import settings from django.db import migrations, models +import apps.parks.models.media + class Migration(migrations.Migration): dependencies = [ diff --git a/backend/apps/parks/migrations/0015_populate_hybrid_filtering_fields.py b/backend/apps/parks/migrations/0015_populate_hybrid_filtering_fields.py index a538f341..8c6ea11b 100644 --- a/backend/apps/parks/migrations/0015_populate_hybrid_filtering_fields.py +++ b/backend/apps/parks/migrations/0015_populate_hybrid_filtering_fields.py @@ -5,40 +5,40 @@ from django.db import migrations def populate_computed_fields(apps, schema_editor): """Populate computed fields for existing parks using raw SQL with disabled triggers""" - + # Temporarily disable pghistory triggers schema_editor.execute("ALTER TABLE parks_park DISABLE TRIGGER ALL;") - + try: # Use raw SQL to update opening_year from opening_date schema_editor.execute(""" - UPDATE parks_park + UPDATE parks_park SET opening_year = EXTRACT(YEAR FROM opening_date) WHERE opening_date IS NOT NULL; """) - + # Use raw SQL to populate search_text # This is a simplified version - we'll populate it with just name and description schema_editor.execute(""" - UPDATE parks_park + UPDATE parks_park SET search_text = LOWER( - COALESCE(name, '') || ' ' || + COALESCE(name, '') || ' ' || COALESCE(description, '') ); """) - + # Update search_text to include operator names using a join schema_editor.execute(""" - UPDATE parks_park + UPDATE parks_park SET search_text = LOWER( - COALESCE(parks_park.name, '') || ' ' || + COALESCE(parks_park.name, '') || ' ' || COALESCE(parks_park.description, '') || ' ' || COALESCE(parks_company.name, '') ) FROM parks_company WHERE parks_park.operator_id = parks_company.id; """) - + finally: # Re-enable pghistory triggers schema_editor.execute("ALTER TABLE parks_park ENABLE TRIGGER ALL;") diff --git a/backend/apps/parks/migrations/0016_add_hybrid_filtering_indexes.py b/backend/apps/parks/migrations/0016_add_hybrid_filtering_indexes.py index c424c288..7b1678b2 100644 --- a/backend/apps/parks/migrations/0016_add_hybrid_filtering_indexes.py +++ b/backend/apps/parks/migrations/0016_add_hybrid_filtering_indexes.py @@ -27,13 +27,13 @@ class Migration(migrations.Migration): "CREATE INDEX IF NOT EXISTS parks_park_ride_coaster_count_idx ON parks_park (ride_count, coaster_count) WHERE ride_count IS NOT NULL AND coaster_count IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS parks_park_ride_coaster_count_idx;" ), - + # Full-text search index for search_text field migrations.RunSQL( "CREATE INDEX IF NOT EXISTS parks_park_search_text_gin_idx ON parks_park USING gin(to_tsvector('english', search_text));", reverse_sql="DROP INDEX IF EXISTS parks_park_search_text_gin_idx;" ), - + # Trigram index for fuzzy search on search_text migrations.RunSQL( "CREATE EXTENSION IF NOT EXISTS pg_trgm;", @@ -43,40 +43,40 @@ class Migration(migrations.Migration): "CREATE INDEX IF NOT EXISTS parks_park_search_text_trgm_idx ON parks_park USING gin(search_text gin_trgm_ops);", reverse_sql="DROP INDEX IF EXISTS parks_park_search_text_trgm_idx;" ), - + # Indexes for location-based filtering (assuming location relationship exists) migrations.RunSQL( """ - CREATE INDEX IF NOT EXISTS parks_parklocation_country_state_idx - ON parks_parklocation (country, state) + CREATE INDEX IF NOT EXISTS parks_parklocation_country_state_idx + ON parks_parklocation (country, state) WHERE country IS NOT NULL AND state IS NOT NULL; """, reverse_sql="DROP INDEX IF EXISTS parks_parklocation_country_state_idx;" ), - + # Index for operator-based filtering migrations.RunSQL( "CREATE INDEX IF NOT EXISTS parks_park_operator_status_idx ON parks_park (operator_id, status);", reverse_sql="DROP INDEX IF EXISTS parks_park_operator_status_idx;" ), - + # Partial indexes for common status filters migrations.RunSQL( "CREATE INDEX IF NOT EXISTS parks_park_operating_parks_idx ON parks_park (name, opening_year) WHERE status IN ('OPERATING', 'CLOSED_TEMP');", reverse_sql="DROP INDEX IF EXISTS parks_park_operating_parks_idx;" ), - + # Index for ordering by name (already exists but ensuring it's optimized) migrations.RunSQL( "CREATE INDEX IF NOT EXISTS parks_park_name_lower_idx ON parks_park (LOWER(name));", reverse_sql="DROP INDEX IF EXISTS parks_park_name_lower_idx;" ), - + # Covering index for common query patterns migrations.RunSQL( """ - CREATE INDEX IF NOT EXISTS parks_park_hybrid_covering_idx - ON parks_park (status, park_type, opening_year) + CREATE INDEX IF NOT EXISTS parks_park_hybrid_covering_idx + ON parks_park (status, park_type, opening_year) INCLUDE (name, slug, size_acres, average_rating, ride_count, coaster_count, operator_id) WHERE status IN ('OPERATING', 'CLOSED_TEMP'); """, diff --git a/backend/apps/parks/migrations/0019_fix_pghistory_timezone.py b/backend/apps/parks/migrations/0019_fix_pghistory_timezone.py index ac322dad..d0eb0fa8 100644 --- a/backend/apps/parks/migrations/0019_fix_pghistory_timezone.py +++ b/backend/apps/parks/migrations/0019_fix_pghistory_timezone.py @@ -14,17 +14,17 @@ class Migration(migrations.Migration): sql=""" -- Drop the existing trigger function DROP FUNCTION IF EXISTS pgtrigger_insert_insert_66883() CASCADE; - + -- Recreate the trigger function with timezone field CREATE OR REPLACE FUNCTION pgtrigger_insert_insert_66883() RETURNS TRIGGER AS $$ BEGIN INSERT INTO "parks_parkevent" ( - "average_rating", "banner_image_id", "card_image_id", "closing_date", - "coaster_count", "created_at", "description", "id", "name", "opening_date", - "opening_year", "operating_season", "operator_id", "park_type", - "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", - "property_owner_id", "ride_count", "search_text", "size_acres", + "average_rating", "banner_image_id", "card_image_id", "closing_date", + "coaster_count", "created_at", "description", "id", "name", "opening_date", + "opening_year", "operating_season", "operator_id", "park_type", + "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", + "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "updated_at", "url", "website", "timezone" ) VALUES ( NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", @@ -37,7 +37,7 @@ class Migration(migrations.Migration): RETURN NEW; END; $$ LANGUAGE plpgsql; - + -- Recreate the trigger CREATE TRIGGER pgtrigger_insert_insert_66883 AFTER INSERT ON parks_park diff --git a/backend/apps/parks/migrations/0020_fix_pghistory_update_timezone.py b/backend/apps/parks/migrations/0020_fix_pghistory_update_timezone.py index cf7efdef..de8057bc 100644 --- a/backend/apps/parks/migrations/0020_fix_pghistory_update_timezone.py +++ b/backend/apps/parks/migrations/0020_fix_pghistory_update_timezone.py @@ -14,17 +14,17 @@ class Migration(migrations.Migration): sql=""" -- Drop the existing UPDATE trigger function DROP FUNCTION IF EXISTS pgtrigger_update_update_19f56() CASCADE; - + -- Recreate the UPDATE trigger function with timezone field CREATE OR REPLACE FUNCTION pgtrigger_update_update_19f56() RETURNS TRIGGER AS $$ BEGIN INSERT INTO "parks_parkevent" ( - "average_rating", "banner_image_id", "card_image_id", "closing_date", - "coaster_count", "created_at", "description", "id", "name", "opening_date", - "opening_year", "operating_season", "operator_id", "park_type", - "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", - "property_owner_id", "ride_count", "search_text", "size_acres", + "average_rating", "banner_image_id", "card_image_id", "closing_date", + "coaster_count", "created_at", "description", "id", "name", "opening_date", + "opening_year", "operating_season", "operator_id", "park_type", + "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", + "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "updated_at", "url", "website", "timezone" ) VALUES ( NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", @@ -37,7 +37,7 @@ class Migration(migrations.Migration): RETURN NEW; END; $$ LANGUAGE plpgsql; - + -- Recreate the UPDATE trigger CREATE TRIGGER pgtrigger_update_update_19f56 AFTER UPDATE ON parks_park diff --git a/backend/apps/parks/migrations/0021_alter_park_park_type_alter_park_status_and_more.py b/backend/apps/parks/migrations/0021_alter_park_park_type_alter_park_status_and_more.py index fe6cb7a5..146cd054 100644 --- a/backend/apps/parks/migrations/0021_alter_park_park_type_alter_park_status_and_more.py +++ b/backend/apps/parks/migrations/0021_alter_park_park_type_alter_park_status_and_more.py @@ -1,8 +1,9 @@ # Generated by Django 5.2.5 on 2025-09-15 17:35 -import apps.core.choices.fields from django.db import migrations +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/parks/migrations/0022_alter_company_roles_alter_companyevent_roles.py b/backend/apps/parks/migrations/0022_alter_company_roles_alter_companyevent_roles.py index 9ed9f624..b09ccddd 100644 --- a/backend/apps/parks/migrations/0022_alter_company_roles_alter_companyevent_roles.py +++ b/backend/apps/parks/migrations/0022_alter_company_roles_alter_companyevent_roles.py @@ -1,9 +1,10 @@ # Generated by Django 5.2.5 on 2025-09-15 18:07 -import apps.core.choices.fields import django.contrib.postgres.fields from django.db import migrations +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/parks/migrations/0025_alter_company_options_alter_park_options_and_more.py b/backend/apps/parks/migrations/0025_alter_company_options_alter_park_options_and_more.py index 98365b7d..737c0988 100644 --- a/backend/apps/parks/migrations/0025_alter_company_options_alter_park_options_and_more.py +++ b/backend/apps/parks/migrations/0025_alter_company_options_alter_park_options_and_more.py @@ -1,13 +1,14 @@ # Generated by Django 5.1.6 on 2025-12-26 14:10 -import apps.core.choices.fields -import apps.core.state_machine.fields import django.contrib.postgres.fields import django.core.validators import django.db.models.deletion from django.conf import settings from django.db import migrations, models +import apps.core.choices.fields +import apps.core.state_machine.fields + class Migration(migrations.Migration): diff --git a/backend/apps/parks/migrations/0026_remove_park_insert_insert_remove_park_update_update_and_more.py b/backend/apps/parks/migrations/0026_remove_park_insert_insert_remove_park_update_update_and_more.py new file mode 100644 index 00000000..ba187e5f --- /dev/null +++ b/backend/apps/parks/migrations/0026_remove_park_insert_insert_remove_park_update_update_and_more.py @@ -0,0 +1,72 @@ +# Generated by Django 5.2.9 on 2025-12-27 20:58 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("parks", "0025_alter_company_options_alter_park_options_and_more"), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name="park", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="park", + name="update_update", + ), + migrations.AddField( + model_name="park", + name="email", + field=models.EmailField(blank=True, help_text="Contact email address", max_length=254), + ), + migrations.AddField( + model_name="park", + name="phone", + field=models.CharField(blank=True, help_text="Contact phone number", max_length=30), + ), + migrations.AddField( + model_name="parkevent", + name="email", + field=models.EmailField(blank=True, help_text="Contact email address", max_length=254), + ), + migrations.AddField( + model_name="parkevent", + name="phone", + field=models.CharField(blank=True, help_text="Contact phone number", max_length=30), + ), + pgtrigger.migrations.AddTrigger( + model_name="park", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "email", "id", "name", "opening_date", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."email", NEW."id", NEW."name", NEW."opening_date", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."phone", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;', + hash="003fcf4a2b230ed1d4bba51881c3675cf8270d0c", + operation="INSERT", + pgid="pgtrigger_insert_insert_66883", + table="parks_park", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="park", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "email", "id", "name", "opening_date", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."email", NEW."id", NEW."name", NEW."opening_date", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."phone", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;', + hash="cee05755a8a1c1de844f51927c15ed35a029253b", + operation="UPDATE", + pgid="pgtrigger_update_update_19f56", + table="parks_park", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/parks/models/__init__.py b/backend/apps/parks/models/__init__.py index 857d44dc..a9410647 100644 --- a/backend/apps/parks/models/__init__.py +++ b/backend/apps/parks/models/__init__.py @@ -8,15 +8,14 @@ The Company model is aliased as Operator to clarify its role as park operators, while maintaining backward compatibility through the Company alias. """ -from .parks import Park -from .areas import ParkArea -from .location import ParkLocation -from .reviews import ParkReview -from .companies import Company, CompanyHeadquarters -from .media import ParkPhoto - # Import choices to trigger registration from ..choices import * +from .areas import ParkArea +from .companies import Company, CompanyHeadquarters +from .location import ParkLocation +from .media import ParkPhoto +from .parks import Park +from .reviews import ParkReview # Alias Company as Operator for clarity Operator = Company diff --git a/backend/apps/parks/models/areas.py b/backend/apps/parks/models/areas.py index b8f8be6a..57df59ab 100644 --- a/backend/apps/parks/models/areas.py +++ b/backend/apps/parks/models/areas.py @@ -1,8 +1,9 @@ +import pghistory from django.db import models from django.utils.text import slugify -import pghistory from apps.core.history import TrackedModel + from .parks import Park diff --git a/backend/apps/parks/models/companies.py b/backend/apps/parks/models/companies.py index 969a09e0..bc981a51 100644 --- a/backend/apps/parks/models/companies.py +++ b/backend/apps/parks/models/companies.py @@ -1,9 +1,10 @@ +import pghistory from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.text import slugify -from apps.core.models import TrackedModel + from apps.core.choices.fields import RichChoiceField -import pghistory +from apps.core.models import TrackedModel @pghistory.track() diff --git a/backend/apps/parks/models/location.py b/backend/apps/parks/models/location.py index 31509b6f..bae65b10 100644 --- a/backend/apps/parks/models/location.py +++ b/backend/apps/parks/models/location.py @@ -1,6 +1,6 @@ +import pghistory from django.contrib.gis.db import models from django.contrib.gis.geos import Point -import pghistory @pghistory.track() diff --git a/backend/apps/parks/models/media.py b/backend/apps/parks/models/media.py index 71e394ad..48ae2bfd 100644 --- a/backend/apps/parks/models/media.py +++ b/backend/apps/parks/models/media.py @@ -4,12 +4,14 @@ Park-specific media models for ThrillWiki. This module contains media models specific to parks domain. """ -from typing import Any, List, Optional, cast -from django.db import models +from typing import Any, cast + +import pghistory from django.conf import settings +from django.db import models + from apps.core.history import TrackedModel from apps.core.services.media_service import MediaService -import pghistory def park_photo_upload_path(instance: models.Model, filename: str) -> str: @@ -114,7 +116,7 @@ class ParkPhoto(TrackedModel): super().save(*args, **kwargs) @property - def file_size(self) -> Optional[int]: + def file_size(self) -> int | None: """Get file size in bytes.""" try: return self.image.size @@ -122,7 +124,7 @@ class ParkPhoto(TrackedModel): return None @property - def dimensions(self) -> Optional[List[int]]: + def dimensions(self) -> list[int] | None: """Get image dimensions as [width, height].""" try: return [self.image.width, self.image.height] diff --git a/backend/apps/parks/models/parks.py b/backend/apps/parks/models/parks.py index 4a359089..1cc9b199 100644 --- a/backend/apps/parks/models/parks.py +++ b/backend/apps/parks/models/parks.py @@ -1,20 +1,23 @@ +from typing import TYPE_CHECKING, Any, Optional + +import pghistory +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.text import slugify -from django.core.exceptions import ValidationError -from config.django import base as settings -from typing import Optional, Any, TYPE_CHECKING, List -import pghistory -from apps.core.history import TrackedModel -from apps.core.choices import RichChoiceField -from apps.core.state_machine import RichFSMField, StateMachineMixin +from apps.core.choices import RichChoiceField +from apps.core.history import TrackedModel +from apps.core.state_machine import RichFSMField, StateMachineMixin +from config.django import base as settings if TYPE_CHECKING: - from apps.rides.models import Ride - from . import ParkArea from django.contrib.auth.models import AbstractBaseUser + from apps.rides.models import Ride + + from . import ParkArea + @pghistory.track() class Park(StateMachineMixin, TrackedModel): @@ -57,6 +60,8 @@ class Park(StateMachineMixin, TrackedModel): max_digits=10, decimal_places=2, null=True, blank=True, help_text="Park size in acres" ) website = models.URLField(blank=True, help_text="Official website URL") + phone = models.CharField(max_length=30, blank=True, help_text="Contact phone number") + email = models.EmailField(blank=True, help_text="Contact email address") # Statistics average_rating = models.DecimalField( @@ -113,17 +118,17 @@ class Park(StateMachineMixin, TrackedModel): # Computed fields for hybrid filtering opening_year = models.IntegerField( - null=True, - blank=True, + null=True, + blank=True, db_index=True, help_text="Year the park opened (computed from opening_date)" ) search_text = models.TextField( - blank=True, + blank=True, db_index=True, help_text="Searchable text combining name, description, location, and operator" ) - + # Timezone for park operations timezone = models.CharField( max_length=50, @@ -220,6 +225,7 @@ class Park(StateMachineMixin, TrackedModel): def save(self, *args: Any, **kwargs: Any) -> None: from django.contrib.contenttypes.models import ContentType + from apps.core.history import HistoricalSlug # Get old instance if it exists @@ -264,13 +270,13 @@ class Park(StateMachineMixin, TrackedModel): self.opening_year = self.opening_date.year else: self.opening_year = None - + # Populate search_text for client-side filtering search_parts = [self.name] - + if self.description: search_parts.append(self.description) - + # Add location information if available try: if hasattr(self, 'location') and self.location: @@ -283,15 +289,15 @@ class Park(StateMachineMixin, TrackedModel): except Exception: # Handle case where location relationship doesn't exist yet pass - + # Add operator information if self.operator: search_parts.append(self.operator.name) - + # Add property owner information if different if self.property_owner and self.property_owner != self.operator: search_parts.append(self.property_owner.name) - + # Combine all parts into searchable text self.search_text = ' '.join(filter(None, search_parts)).lower() @@ -315,7 +321,7 @@ class Park(StateMachineMixin, TrackedModel): return "" @property - def coordinates(self) -> Optional[List[float]]: + def coordinates(self) -> list[float] | None: """Returns coordinates as a list [latitude, longitude]""" if hasattr(self, "location") and self.location: coords = self.location.coordinates @@ -327,6 +333,7 @@ class Park(StateMachineMixin, TrackedModel): def get_by_slug(cls, slug: str) -> tuple["Park", bool]: """Get park by current or historical slug""" from django.contrib.contenttypes.models import ContentType + from apps.core.history import HistoricalSlug print(f"\nLooking up slug: {slug}") diff --git a/backend/apps/parks/models/reviews.py b/backend/apps/parks/models/reviews.py index 54342b58..6450c7e7 100644 --- a/backend/apps/parks/models/reviews.py +++ b/backend/apps/parks/models/reviews.py @@ -1,8 +1,9 @@ +import pghistory +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import functions -from django.core.validators import MinValueValidator, MaxValueValidator + from apps.core.history import TrackedModel -import pghistory @pghistory.track() diff --git a/backend/apps/parks/querysets.py b/backend/apps/parks/querysets.py index 86d99c5c..d5347c31 100644 --- a/backend/apps/parks/querysets.py +++ b/backend/apps/parks/querysets.py @@ -1,4 +1,5 @@ -from django.db.models import QuerySet, Count, Q +from django.db.models import Count, Q, QuerySet + from .models import Park diff --git a/backend/apps/parks/selectors.py b/backend/apps/parks/selectors.py index 490367db..1f95a78a 100644 --- a/backend/apps/parks/selectors.py +++ b/backend/apps/parks/selectors.py @@ -3,16 +3,18 @@ Selectors for park-related data retrieval. Following Django styleguide pattern for separating data access from business logic. """ -from typing import Optional, Dict, Any -from django.db.models import QuerySet, Q, Count, Avg, Prefetch +from typing import Any + from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance +from django.db.models import Avg, Count, Prefetch, Q, QuerySet -from .models import Park, ParkArea, ParkReview from apps.rides.models import Ride +from .models import Park, ParkArea, ParkReview -def park_list_with_stats(*, filters: Optional[Dict[str, Any]] = None) -> QuerySet[Park]: + +def park_list_with_stats(*, filters: dict[str, Any] | None = None) -> QuerySet[Park]: """ Get parks optimized for list display with basic stats. @@ -116,7 +118,7 @@ def parks_near_location( ) -def park_statistics() -> Dict[str, Any]: +def park_statistics() -> dict[str, Any]: """ Get overall park statistics for dashboard/analytics. @@ -167,9 +169,10 @@ def parks_with_recent_reviews(*, days: int = 30) -> QuerySet[Park]: Returns: QuerySet of parks with recent reviews """ - from django.utils import timezone from datetime import timedelta + from django.utils import timezone + cutoff_date = timezone.now() - timedelta(days=days) return ( diff --git a/backend/apps/parks/services.py b/backend/apps/parks/services.py index 113a89a6..26156824 100644 --- a/backend/apps/parks/services.py +++ b/backend/apps/parks/services.py @@ -3,11 +3,12 @@ Services for park-related business logic. Following Django styleguide pattern for business logic encapsulation. """ -from typing import Optional, Dict, Any -from django.db import transaction -from django.db.models import Q +from typing import Any + from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractBaseUser +from django.db import transaction +from django.db.models import Q from .models import Park, ParkArea from .services.location_service import ParkLocationService @@ -26,15 +27,15 @@ class ParkService: name: str, description: str = "", status: str = "OPERATING", - operator_id: Optional[int] = None, - property_owner_id: Optional[int] = None, - opening_date: Optional[str] = None, - closing_date: Optional[str] = None, + operator_id: int | None = None, + property_owner_id: int | None = None, + opening_date: str | None = None, + closing_date: str | None = None, operating_season: str = "", - size_acres: Optional[float] = None, + size_acres: float | None = None, website: str = "", - location_data: Optional[Dict[str, Any]] = None, - created_by: Optional[UserType] = None, + location_data: dict[str, Any] | None = None, + created_by: UserType | None = None, ) -> Park: """ Create a new park with validation and location handling. @@ -97,8 +98,8 @@ class ParkService: def update_park( *, park_id: int, - updates: Dict[str, Any], - updated_by: Optional[UserType] = None, + updates: dict[str, Any], + updated_by: UserType | None = None, ) -> Park: """ Update an existing park with validation. @@ -130,7 +131,7 @@ class ParkService: return park @staticmethod - def delete_park(*, park_id: int, deleted_by: Optional[UserType] = None) -> bool: + def delete_park(*, park_id: int, deleted_by: UserType | None = None) -> bool: """ Soft delete a park by setting status to DEMOLISHED. @@ -156,7 +157,7 @@ class ParkService: park_id: int, name: str, description: str = "", - created_by: Optional[UserType] = None, + created_by: UserType | None = None, ) -> ParkArea: """ Create a new area within a park. @@ -195,9 +196,11 @@ class ParkService: Returns: Updated Park instance with fresh statistics """ + from django.db.models import Avg, Count + from apps.rides.models import Ride + from .models import ParkReview - from django.db.models import Count, Avg with transaction.atomic(): park = Park.objects.select_for_update().get(id=park_id) diff --git a/backend/apps/parks/services/__init__.py b/backend/apps/parks/services/__init__.py index f2ee4bf7..7ea25c24 100644 --- a/backend/apps/parks/services/__init__.py +++ b/backend/apps/parks/services/__init__.py @@ -1,8 +1,8 @@ -from .roadtrip import RoadTripService -from .park_management import ParkService -from .location_service import ParkLocationService from .filter_service import ParkFilterService +from .location_service import ParkLocationService from .media_service import ParkMediaService +from .park_management import ParkService +from .roadtrip import RoadTripService __all__ = [ "RoadTripService", diff --git a/backend/apps/parks/services/filter_service.py b/backend/apps/parks/services/filter_service.py index 1a122a27..2e6f6dfa 100644 --- a/backend/apps/parks/services/filter_service.py +++ b/backend/apps/parks/services/filter_service.py @@ -5,11 +5,13 @@ Provides filtering functionality, aggregations, and caching for park filters. This service handles complex filter logic and provides useful filter statistics. """ -from typing import Dict, List, Any, Optional -from django.db.models import QuerySet, Count, Q -from django.core.cache import cache +from typing import Any + from django.conf import settings -from ..models import Park, Company +from django.core.cache import cache +from django.db.models import Count, Q, QuerySet + +from ..models import Company, Park from ..querysets import get_base_park_queryset @@ -25,8 +27,8 @@ class ParkFilterService: self.cache_prefix = "park_filter" def get_filter_counts( - self, base_queryset: Optional[QuerySet] = None - ) -> Dict[str, Any]: + self, base_queryset: QuerySet | None = None + ) -> dict[str, Any]: """ Get counts for various filter options to show users what's available. @@ -61,7 +63,7 @@ class ParkFilterService: cache.set(cache_key, filter_counts, self.CACHE_TIMEOUT) return filter_counts - def _get_park_type_counts(self, queryset: QuerySet) -> Dict[str, int]: + def _get_park_type_counts(self, queryset: QuerySet) -> dict[str, int]: """Get counts for different park types based on operator names.""" return { "disney": queryset.filter(operator__name__icontains="Disney").count(), @@ -76,7 +78,7 @@ class ParkFilterService: def _get_top_operators( self, queryset: QuerySet, limit: int = 10 - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Get the top operators by number of parks.""" return list( queryset.values("operator__name", "operator__id") @@ -87,7 +89,7 @@ class ParkFilterService: def _get_country_counts( self, queryset: QuerySet, limit: int = 10 - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Get countries with the most parks.""" return list( queryset.filter(location__country__isnull=False) @@ -97,7 +99,7 @@ class ParkFilterService: .order_by("-park_count")[:limit] ) - def get_filter_suggestions(self, query: str) -> Dict[str, List[str]]: + def get_filter_suggestions(self, query: str) -> dict[str, list[str]]: """ Get filter suggestions based on a search query. @@ -151,7 +153,7 @@ class ParkFilterService: cache.set(cache_key, suggestions, 60) # 1 minute cache return suggestions - def get_popular_filters(self) -> Dict[str, Any]: + def get_popular_filters(self) -> dict[str, Any]: """ Get commonly used filter combinations and popular filter values. @@ -210,7 +212,7 @@ class ParkFilterService: for key in cache_keys: cache.delete(key) - def get_filtered_queryset(self, filters: Dict[str, Any]) -> QuerySet: # noqa: C901 + def get_filtered_queryset(self, filters: dict[str, Any]) -> QuerySet: # noqa: C901 """ Apply filters to get a filtered queryset with optimizations. diff --git a/backend/apps/parks/services/hybrid_loader.py b/backend/apps/parks/services/hybrid_loader.py index fa968abe..10dc5a37 100644 --- a/backend/apps/parks/services/hybrid_loader.py +++ b/backend/apps/parks/services/hybrid_loader.py @@ -5,10 +5,12 @@ This module provides intelligent data loading capabilities for the hybrid filter optimizing database queries and implementing progressive loading strategies. """ -from typing import Dict, Optional, Any -from django.db import models -from django.core.cache import cache +from typing import Any + from django.conf import settings +from django.core.cache import cache +from django.db import models + from apps.parks.models import Park @@ -17,19 +19,19 @@ class SmartParkLoader: Intelligent park data loader that optimizes queries based on filtering requirements. Implements progressive loading and smart caching strategies. """ - + # Cache configuration CACHE_TIMEOUT = getattr(settings, 'HYBRID_FILTER_CACHE_TIMEOUT', 300) # 5 minutes CACHE_KEY_PREFIX = 'hybrid_parks' - + # Progressive loading thresholds INITIAL_LOAD_SIZE = 50 PROGRESSIVE_LOAD_SIZE = 25 MAX_CLIENT_SIDE_RECORDS = 200 - + def __init__(self): self.base_queryset = self._get_optimized_queryset() - + def _get_optimized_queryset(self) -> models.QuerySet: """Get optimized base queryset with all necessary prefetches.""" return Park.objects.select_related( @@ -43,31 +45,31 @@ class SmartParkLoader: # Only include operating and temporarily closed parks by default status__in=['OPERATING', 'CLOSED_TEMP'] ).order_by('name') - - def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + + def get_initial_load(self, filters: dict[str, Any] | None = None) -> dict[str, Any]: """ Get initial park data load with smart filtering decisions. - + Args: filters: Optional filters to apply - + Returns: Dictionary containing parks data and metadata """ cache_key = self._generate_cache_key('initial', filters) cached_result = cache.get(cache_key) - + if cached_result: return cached_result - + # Apply filters if provided queryset = self.base_queryset if filters: queryset = self._apply_filters(queryset, filters) - + # Get total count for pagination decisions total_count = queryset.count() - + # Determine loading strategy if total_count <= self.MAX_CLIENT_SIDE_RECORDS: # Load all data for client-side filtering @@ -79,7 +81,7 @@ class SmartParkLoader: parks = list(queryset[:self.INITIAL_LOAD_SIZE]) strategy = 'server_side' has_more = total_count > self.INITIAL_LOAD_SIZE - + result = { 'parks': parks, 'total_count': total_count, @@ -88,163 +90,163 @@ class SmartParkLoader: 'next_offset': len(parks) if has_more else None, 'filter_metadata': self._get_filter_metadata(queryset), } - + # Cache the result cache.set(cache_key, result, self.CACHE_TIMEOUT) - + return result - + def get_progressive_load( - self, - offset: int, - filters: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: + self, + offset: int, + filters: dict[str, Any] | None = None + ) -> dict[str, Any]: """ Get next batch of parks for progressive loading. - + Args: offset: Starting offset for the batch filters: Optional filters to apply - + Returns: Dictionary containing parks data and metadata """ cache_key = self._generate_cache_key(f'progressive_{offset}', filters) cached_result = cache.get(cache_key) - + if cached_result: return cached_result - + # Apply filters if provided queryset = self.base_queryset if filters: queryset = self._apply_filters(queryset, filters) - + # Get the batch end_offset = offset + self.PROGRESSIVE_LOAD_SIZE parks = list(queryset[offset:end_offset]) - + # Check if there are more records total_count = queryset.count() has_more = end_offset < total_count - + result = { 'parks': parks, 'total_count': total_count, 'has_more': has_more, 'next_offset': end_offset if has_more else None, } - + # Cache the result cache.set(cache_key, result, self.CACHE_TIMEOUT) - + return result - - def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + + def get_filter_metadata(self, filters: dict[str, Any] | None = None) -> dict[str, Any]: """ Get metadata about available filter options. - + Args: filters: Current filters to scope the metadata - + Returns: Dictionary containing filter metadata """ cache_key = self._generate_cache_key('metadata', filters) cached_result = cache.get(cache_key) - + if cached_result: return cached_result - + # Apply filters if provided queryset = self.base_queryset if filters: queryset = self._apply_filters(queryset, filters) - + result = self._get_filter_metadata(queryset) - + # Cache the result cache.set(cache_key, result, self.CACHE_TIMEOUT) - + return result - - def _apply_filters(self, queryset: models.QuerySet, filters: Dict[str, Any]) -> models.QuerySet: + + def _apply_filters(self, queryset: models.QuerySet, filters: dict[str, Any]) -> models.QuerySet: """Apply filters to the queryset.""" - + # Status filter if 'status' in filters and filters['status']: if isinstance(filters['status'], list): queryset = queryset.filter(status__in=filters['status']) else: queryset = queryset.filter(status=filters['status']) - + # Park type filter if 'park_type' in filters and filters['park_type']: if isinstance(filters['park_type'], list): queryset = queryset.filter(park_type__in=filters['park_type']) else: queryset = queryset.filter(park_type=filters['park_type']) - + # Country filter if 'country' in filters and filters['country']: queryset = queryset.filter(location__country__in=filters['country']) - + # State filter if 'state' in filters and filters['state']: queryset = queryset.filter(location__state__in=filters['state']) - + # Opening year range if 'opening_year_min' in filters and filters['opening_year_min']: queryset = queryset.filter(opening_year__gte=filters['opening_year_min']) - + if 'opening_year_max' in filters and filters['opening_year_max']: queryset = queryset.filter(opening_year__lte=filters['opening_year_max']) - + # Size range if 'size_min' in filters and filters['size_min']: queryset = queryset.filter(size_acres__gte=filters['size_min']) - + if 'size_max' in filters and filters['size_max']: queryset = queryset.filter(size_acres__lte=filters['size_max']) - + # Rating range if 'rating_min' in filters and filters['rating_min']: queryset = queryset.filter(average_rating__gte=filters['rating_min']) - + if 'rating_max' in filters and filters['rating_max']: queryset = queryset.filter(average_rating__lte=filters['rating_max']) - + # Ride count range if 'ride_count_min' in filters and filters['ride_count_min']: queryset = queryset.filter(ride_count__gte=filters['ride_count_min']) - + if 'ride_count_max' in filters and filters['ride_count_max']: queryset = queryset.filter(ride_count__lte=filters['ride_count_max']) - + # Coaster count range if 'coaster_count_min' in filters and filters['coaster_count_min']: queryset = queryset.filter(coaster_count__gte=filters['coaster_count_min']) - + if 'coaster_count_max' in filters and filters['coaster_count_max']: queryset = queryset.filter(coaster_count__lte=filters['coaster_count_max']) - + # Operator filter if 'operator' in filters and filters['operator']: if isinstance(filters['operator'], list): queryset = queryset.filter(operator__slug__in=filters['operator']) else: queryset = queryset.filter(operator__slug=filters['operator']) - + # Search query if 'search' in filters and filters['search']: search_term = filters['search'].lower() queryset = queryset.filter(search_text__icontains=search_term) - + return queryset - - def _get_filter_metadata(self, queryset: models.QuerySet) -> Dict[str, Any]: + + def _get_filter_metadata(self, queryset: models.QuerySet) -> dict[str, Any]: """Generate filter metadata from the current queryset.""" - + # Get distinct values for categorical filters with counts countries_data = list( queryset.values('location__country') @@ -252,27 +254,27 @@ class SmartParkLoader: .annotate(count=models.Count('id')) .order_by('location__country') ) - + states_data = list( queryset.values('location__state') .exclude(location__state__isnull=True) .annotate(count=models.Count('id')) .order_by('location__state') ) - + park_types_data = list( queryset.values('park_type') .exclude(park_type__isnull=True) .annotate(count=models.Count('id')) .order_by('park_type') ) - + statuses_data = list( queryset.values('status') .annotate(count=models.Count('id')) .order_by('status') ) - + operators_data = list( queryset.select_related('operator') .values('operator__id', 'operator__name', 'operator__slug') @@ -280,7 +282,7 @@ class SmartParkLoader: .annotate(count=models.Count('id')) .order_by('operator__name') ) - + # Convert to frontend-expected format with value/label/count countries = [ { @@ -290,7 +292,7 @@ class SmartParkLoader: } for item in countries_data ] - + states = [ { 'value': item['location__state'], @@ -299,7 +301,7 @@ class SmartParkLoader: } for item in states_data ] - + park_types = [ { 'value': item['park_type'], @@ -308,7 +310,7 @@ class SmartParkLoader: } for item in park_types_data ] - + statuses = [ { 'value': item['status'], @@ -317,7 +319,7 @@ class SmartParkLoader: } for item in statuses_data ] - + operators = [ { 'value': item['operator__slug'], @@ -326,7 +328,7 @@ class SmartParkLoader: } for item in operators_data ] - + # Get ranges for numerical filters aggregates = queryset.aggregate( opening_year_min=models.Min('opening_year'), @@ -340,7 +342,7 @@ class SmartParkLoader: coaster_count_min=models.Min('coaster_count'), coaster_count_max=models.Max('coaster_count'), ) - + return { 'categorical': { 'countries': countries, @@ -383,7 +385,7 @@ class SmartParkLoader: }, 'total_count': queryset.count(), } - + def _get_status_label(self, status: str) -> str: """Convert status code to human-readable label.""" status_labels = { @@ -396,19 +398,19 @@ class SmartParkLoader: return status_labels[status] else: raise ValueError(f"Unknown park status: {status}") - - def _generate_cache_key(self, operation: str, filters: Optional[Dict[str, Any]] = None) -> str: + + def _generate_cache_key(self, operation: str, filters: dict[str, Any] | None = None) -> str: """Generate cache key for the given operation and filters.""" key_parts = [self.CACHE_KEY_PREFIX, operation] - + if filters: # Create a consistent string representation of filters filter_str = '_'.join(f"{k}:{v}" for k, v in sorted(filters.items()) if v) key_parts.append(filter_str) - + return '_'.join(key_parts) - - def invalidate_cache(self, filters: Optional[Dict[str, Any]] = None) -> None: + + def invalidate_cache(self, filters: dict[str, Any] | None = None) -> None: """Invalidate cached data for the given filters.""" # This is a simplified implementation # In production, you might want to use cache versioning or tags @@ -416,11 +418,11 @@ class SmartParkLoader: self._generate_cache_key('initial', filters), self._generate_cache_key('metadata', filters), ] - + # Also invalidate progressive load caches for offset in range(0, 1000, self.PROGRESSIVE_LOAD_SIZE): cache_keys.append(self._generate_cache_key(f'progressive_{offset}', filters)) - + cache.delete_many(cache_keys) diff --git a/backend/apps/parks/services/location_service.py b/backend/apps/parks/services/location_service.py index 3a721427..d7eae540 100644 --- a/backend/apps/parks/services/location_service.py +++ b/backend/apps/parks/services/location_service.py @@ -3,11 +3,12 @@ Parks-specific location services with OpenStreetMap integration. Handles geocoding, reverse geocoding, and location search for parks. """ +import logging +from typing import Any + import requests -from typing import List, Dict, Any, Optional from django.core.cache import cache from django.db import transaction -import logging from ..models import ParkLocation @@ -23,7 +24,7 @@ class ParkLocationService: USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)" @classmethod - def search_locations(cls, query: str, limit: int = 10) -> Dict[str, Any]: + def search_locations(cls, query: str, limit: int = 10) -> dict[str, Any]: """ Search for locations using OpenStreetMap Nominatim API. Optimized for finding theme parks and amusement parks. @@ -98,7 +99,7 @@ class ParkLocationService: } @classmethod - def reverse_geocode(cls, latitude: float, longitude: float) -> Dict[str, Any]: + def reverse_geocode(cls, latitude: float, longitude: float) -> dict[str, Any]: """ Reverse geocode coordinates to get location information using OSM. @@ -159,7 +160,7 @@ class ParkLocationService: return {"error": "Reverse geocoding service temporarily unavailable"} @classmethod - def geocode_address(cls, address: str) -> Dict[str, Any]: + def geocode_address(cls, address: str) -> dict[str, Any]: """ Geocode an address to get coordinates using OSM. @@ -185,8 +186,8 @@ class ParkLocationService: cls, *, park, - latitude: Optional[float] = None, - longitude: Optional[float] = None, + latitude: float | None = None, + longitude: float | None = None, street_address: str = "", city: str = "", state: str = "", @@ -195,7 +196,7 @@ class ParkLocationService: highway_exit: str = "", parking_notes: str = "", seasonal_notes: str = "", - osm_id: Optional[int] = None, + osm_id: int | None = None, osm_type: str = "", ) -> ParkLocation: """ @@ -279,7 +280,7 @@ class ParkLocationService: @classmethod def find_nearby_parks( cls, latitude: float, longitude: float, radius_km: float = 50 - ) -> List[ParkLocation]: + ) -> list[ParkLocation]: """ Find parks near given coordinates using PostGIS. @@ -349,8 +350,8 @@ class ParkLocationService: @classmethod def _transform_osm_result( - cls, osm_item: Dict[str, Any] - ) -> Optional[Dict[str, Any]]: + cls, osm_item: dict[str, Any] + ) -> dict[str, Any] | None: """Transform OSM search result to our standard format.""" try: address = osm_item.get("address", {}) @@ -432,8 +433,8 @@ class ParkLocationService: @classmethod def _transform_osm_reverse_result( - cls, osm_result: Dict[str, Any] - ) -> Dict[str, Any]: + cls, osm_result: dict[str, Any] + ) -> dict[str, Any]: """Transform OSM reverse geocoding result to our standard format.""" address = osm_result.get("address", {}) diff --git a/backend/apps/parks/services/media_service.py b/backend/apps/parks/services/media_service.py index 1afbaae0..3582b9fb 100644 --- a/backend/apps/parks/services/media_service.py +++ b/backend/apps/parks/services/media_service.py @@ -5,11 +5,14 @@ This module provides media management functionality specific to parks. """ import logging -from typing import List, Optional, Dict, Any +from typing import Any + +from django.contrib.auth import get_user_model from django.core.files.uploadedfile import UploadedFile from django.db import transaction -from django.contrib.auth import get_user_model + from apps.core.services.media_service import MediaService + from ..models import Park, ParkPhoto User = get_user_model() @@ -78,7 +81,7 @@ class ParkMediaService: @staticmethod def get_park_photos( park: Park, approved_only: bool = True, primary_first: bool = True - ) -> List[ParkPhoto]: + ) -> list[ParkPhoto]: """ Get photos for a park. @@ -103,7 +106,7 @@ class ParkMediaService: return list(queryset) @staticmethod - def get_primary_photo(park: Park) -> Optional[ParkPhoto]: + def get_primary_photo(park: Park) -> ParkPhoto | None: """ Get the primary photo for a park. @@ -196,7 +199,7 @@ class ParkMediaService: return False @staticmethod - def get_photo_stats(park: Park) -> Dict[str, Any]: + def get_photo_stats(park: Park) -> dict[str, Any]: """ Get photo statistics for a park. @@ -217,7 +220,7 @@ class ParkMediaService: } @staticmethod - def bulk_approve_photos(photos: List[ParkPhoto], approved_by: User) -> int: + def bulk_approve_photos(photos: list[ParkPhoto], approved_by: User) -> int: """ Bulk approve multiple photos. diff --git a/backend/apps/parks/services/park_management.py b/backend/apps/parks/services/park_management.py index 3dfa7405..e957296f 100644 --- a/backend/apps/parks/services/park_management.py +++ b/backend/apps/parks/services/park_management.py @@ -4,10 +4,11 @@ Following Django styleguide pattern for business logic encapsulation. """ import logging -from typing import Optional, Dict, Any, List, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional + +from django.core.files.uploadedfile import UploadedFile from django.db import transaction from django.db.models import Q -from django.core.files.uploadedfile import UploadedFile if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser @@ -28,14 +29,14 @@ class ParkService: name: str, description: str = "", status: str = "OPERATING", - operator_id: Optional[int] = None, - property_owner_id: Optional[int] = None, - opening_date: Optional[str] = None, - closing_date: Optional[str] = None, + operator_id: int | None = None, + property_owner_id: int | None = None, + opening_date: str | None = None, + closing_date: str | None = None, operating_season: str = "", - size_acres: Optional[float] = None, + size_acres: float | None = None, website: str = "", - location_data: Optional[Dict[str, Any]] = None, + location_data: dict[str, Any] | None = None, created_by: Optional["AbstractUser"] = None, ) -> Park: """ @@ -99,7 +100,7 @@ class ParkService: def update_park( *, park_id: int, - updates: Dict[str, Any], + updates: dict[str, Any], updated_by: Optional["AbstractUser"] = None, ) -> Park: """ @@ -203,9 +204,10 @@ class ParkService: Returns: Updated Park instance with fresh statistics """ - from apps.rides.models import Ride + from django.db.models import Avg, Count + from apps.parks.models import ParkReview - from django.db.models import Count, Avg + from apps.rides.models import Ride with transaction.atomic(): park = Park.objects.select_for_update().get(id=park_id) @@ -235,11 +237,11 @@ class ParkService: @staticmethod def create_park_with_moderation( *, - changes: Dict[str, Any], + changes: dict[str, Any], submitter: "AbstractUser", reason: str = "", source: str = "", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Create a park through the moderation system. @@ -267,11 +269,11 @@ class ParkService: def update_park_with_moderation( *, park: Park, - changes: Dict[str, Any], + changes: dict[str, Any], submitter: "AbstractUser", reason: str = "", source: str = "", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Update a park through the moderation system. @@ -300,14 +302,14 @@ class ParkService: def create_or_update_location( *, park: Park, - latitude: Optional[float], - longitude: Optional[float], + latitude: float | None, + longitude: float | None, street_address: str = "", city: str = "", state: str = "", country: str = "USA", postal_code: str = "", - ) -> Optional[ParkLocation]: + ) -> ParkLocation | None: """ Create or update a park's location. @@ -356,9 +358,9 @@ class ParkService: def upload_photos( *, park: Park, - photos: List[UploadedFile], + photos: list[UploadedFile], uploaded_by: "AbstractUser", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Upload multiple photos for a park. @@ -370,10 +372,9 @@ class ParkService: Returns: Dictionary with uploaded_count and errors list """ - from django.contrib.contenttypes.models import ContentType uploaded_count = 0 - errors: List[str] = [] + errors: list[str] = [] for photo_file in photos: try: @@ -396,11 +397,11 @@ class ParkService: @staticmethod def handle_park_creation_result( *, - result: Dict[str, Any], - form_data: Dict[str, Any], - photos: List[UploadedFile], + result: dict[str, Any], + form_data: dict[str, Any], + photos: list[UploadedFile], user: "AbstractUser", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Handle the result of park creation through moderation. @@ -413,7 +414,7 @@ class ParkService: Returns: Dictionary with status, park (if created), uploaded_count, and errors """ - response: Dict[str, Any] = { + response: dict[str, Any] = { "status": result["status"], "park": None, "uploaded_count": 0, @@ -454,12 +455,12 @@ class ParkService: @staticmethod def handle_park_update_result( *, - result: Dict[str, Any], + result: dict[str, Any], park: Park, - form_data: Dict[str, Any], - photos: List[UploadedFile], + form_data: dict[str, Any], + photos: list[UploadedFile], user: "AbstractUser", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Handle the result of park update through moderation. @@ -473,7 +474,7 @@ class ParkService: Returns: Dictionary with status, park, uploaded_count, and errors """ - response: Dict[str, Any] = { + response: dict[str, Any] = { "status": result["status"], "park": park, "uploaded_count": 0, diff --git a/backend/apps/parks/services/roadtrip.py b/backend/apps/parks/services/roadtrip.py index 3b7c9f76..cefaa599 100644 --- a/backend/apps/parks/services/roadtrip.py +++ b/backend/apps/parks/services/roadtrip.py @@ -9,18 +9,19 @@ This service provides functionality for: - Proper rate limiting and caching """ -import time -import math import logging -import requests -from typing import Dict, List, Optional, Any +import math +import time from dataclasses import dataclass from itertools import permutations +from typing import Any +import requests from django.conf import settings -from django.core.cache import cache from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance +from django.core.cache import cache + from apps.parks.models import Park logger = logging.getLogger(__name__) @@ -33,7 +34,7 @@ class Coordinates: latitude: float longitude: float - def to_list(self) -> List[float]: + def to_list(self) -> list[float]: """Return as [lat, lon] list.""" return [self.latitude, self.longitude] @@ -48,7 +49,7 @@ class RouteInfo: distance_km: float duration_minutes: int - geometry: Optional[str] = None # Encoded polyline + geometry: str | None = None # Encoded polyline @property def formatted_distance(self) -> str: @@ -79,7 +80,7 @@ class TripLeg: route: RouteInfo @property - def parks_along_route(self) -> List["Park"]: + def parks_along_route(self) -> list["Park"]: """Get parks along this route segment.""" # This would be populated by find_parks_along_route return [] @@ -89,8 +90,8 @@ class TripLeg: class RoadTrip: """Complete road trip with multiple parks.""" - parks: List["Park"] - legs: List[TripLeg] + parks: list["Park"] + legs: list[TripLeg] total_distance_km: float total_duration_minutes: int @@ -170,7 +171,7 @@ class RoadTripService: } ) - def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]: + def _make_request(self, url: str, params: dict[str, Any]) -> dict[str, Any]: """ Make HTTP request with rate limiting, retries, and error handling. """ @@ -195,7 +196,7 @@ class RoadTripService: f"Failed to make request after {self.max_retries} attempts: {e}" ) - def geocode_address(self, address: str) -> Optional[Coordinates]: + def geocode_address(self, address: str) -> Coordinates | None: """ Convert address to coordinates using Nominatim geocoding service. @@ -256,7 +257,7 @@ class RoadTripService: def calculate_route( self, start_coords: Coordinates, end_coords: Coordinates - ) -> Optional[RouteInfo]: + ) -> RouteInfo | None: """ Calculate route between two coordinate points using OSRM. @@ -377,7 +378,7 @@ class RoadTripService: def find_parks_along_route( self, start_park: "Park", end_park: "Park", max_detour_km: float = 50 - ) -> List["Park"]: + ) -> list["Park"]: """ Find parks along a route within specified detour distance. @@ -444,7 +445,7 @@ class RoadTripService: def _calculate_detour_distance( self, start: Coordinates, end: Coordinates, waypoint: Coordinates - ) -> Optional[float]: + ) -> float | None: """ Calculate the detour distance when visiting a waypoint. """ @@ -470,7 +471,7 @@ class RoadTripService: logger.error(f"Failed to calculate detour distance: {e}") return None - def create_multi_park_trip(self, park_list: List["Park"]) -> Optional[RoadTrip]: + def create_multi_park_trip(self, park_list: list["Park"]) -> RoadTrip | None: """ Create optimized multi-park road trip using simple nearest neighbor heuristic. @@ -489,7 +490,7 @@ class RoadTripService: else: return self._optimize_trip_nearest_neighbor(park_list) - def _optimize_trip_exhaustive(self, park_list: List["Park"]) -> Optional[RoadTrip]: + def _optimize_trip_exhaustive(self, park_list: list["Park"]) -> RoadTrip | None: """ Find optimal route by testing all permutations (for small lists). """ @@ -508,8 +509,8 @@ class RoadTripService: return best_trip def _optimize_trip_nearest_neighbor( - self, park_list: List["Park"] - ) -> Optional[RoadTrip]: + self, park_list: list["Park"] + ) -> RoadTrip | None: """ Optimize trip using nearest neighbor heuristic (for larger lists). """ @@ -553,8 +554,8 @@ class RoadTripService: return self._create_trip_from_order(ordered_parks) def _create_trip_from_order( - self, ordered_parks: List["Park"] - ) -> Optional[RoadTrip]: + self, ordered_parks: list["Park"] + ) -> RoadTrip | None: """ Create a RoadTrip object from an ordered list of parks. """ @@ -596,7 +597,7 @@ class RoadTripService: def get_park_distances( self, center_park: "Park", radius_km: float = 100 - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Get all parks within radius of a center park with distances. diff --git a/backend/apps/parks/signals.py b/backend/apps/parks/signals.py index b01a8c66..a67d6886 100644 --- a/backend/apps/parks/signals.py +++ b/backend/apps/parks/signals.py @@ -1,12 +1,12 @@ import logging -from django.db.models.signals import post_save, post_delete -from django.dispatch import receiver from django.db.models import Q +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver from apps.rides.models import Ride -from .models import Park +from .models import Park logger = logging.getLogger(__name__) diff --git a/backend/apps/parks/tests.py b/backend/apps/parks/tests.py index fff7d81b..216b75b0 100644 --- a/backend/apps/parks/tests.py +++ b/backend/apps/parks/tests.py @@ -8,13 +8,14 @@ This module contains tests for: - Related model updates during transitions """ -from django.test import TestCase +from datetime import date + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.utils import timezone +from django.test import TestCase from django_fsm import TransitionNotAllowed -from .models import Park, Company -from datetime import date + +from .models import Company, Park User = get_user_model() @@ -47,7 +48,7 @@ class ParkTransitionTests(TestCase): password='testpass123', role='ADMIN' ) - + # Create operator company self.operator = Company.objects.create( name='Test Operator', @@ -75,21 +76,21 @@ class ParkTransitionTests(TestCase): """Test transition from OPERATING to CLOSED_TEMP.""" park = self._create_park(status='OPERATING') self.assertEqual(park.status, 'OPERATING') - + park.transition_to_closed_temp(user=self.user) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_TEMP') def test_operating_to_closed_perm_transition(self): """Test transition from OPERATING to CLOSED_PERM.""" park = self._create_park(status='OPERATING') - + park.transition_to_closed_perm(user=self.moderator) park.closing_date = date.today() park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_PERM') self.assertIsNotNone(park.closing_date) @@ -102,10 +103,10 @@ class ParkTransitionTests(TestCase): """Test transition from UNDER_CONSTRUCTION to OPERATING.""" park = self._create_park(status='UNDER_CONSTRUCTION') self.assertEqual(park.status, 'UNDER_CONSTRUCTION') - + park.transition_to_operating(user=self.user) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'OPERATING') @@ -116,21 +117,21 @@ class ParkTransitionTests(TestCase): def test_closed_temp_to_operating_transition(self): """Test transition from CLOSED_TEMP to OPERATING (reopen).""" park = self._create_park(status='CLOSED_TEMP') - + park.transition_to_operating(user=self.user) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'OPERATING') def test_closed_temp_to_closed_perm_transition(self): """Test transition from CLOSED_TEMP to CLOSED_PERM.""" park = self._create_park(status='CLOSED_TEMP') - + park.transition_to_closed_perm(user=self.moderator) park.closing_date = date.today() park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_PERM') @@ -141,20 +142,20 @@ class ParkTransitionTests(TestCase): def test_closed_perm_to_demolished_transition(self): """Test transition from CLOSED_PERM to DEMOLISHED.""" park = self._create_park(status='CLOSED_PERM') - + park.transition_to_demolished(user=self.moderator) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'DEMOLISHED') def test_closed_perm_to_relocated_transition(self): """Test transition from CLOSED_PERM to RELOCATED.""" park = self._create_park(status='CLOSED_PERM') - + park.transition_to_relocated(user=self.moderator) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'RELOCATED') @@ -165,28 +166,28 @@ class ParkTransitionTests(TestCase): def test_demolished_cannot_transition(self): """Test that DEMOLISHED state cannot transition further.""" park = self._create_park(status='DEMOLISHED') - + with self.assertRaises(TransitionNotAllowed): park.transition_to_operating(user=self.moderator) def test_relocated_cannot_transition(self): """Test that RELOCATED state cannot transition further.""" park = self._create_park(status='RELOCATED') - + with self.assertRaises(TransitionNotAllowed): park.transition_to_operating(user=self.moderator) def test_operating_cannot_directly_demolish(self): """Test that OPERATING cannot directly transition to DEMOLISHED.""" park = self._create_park(status='OPERATING') - + with self.assertRaises(TransitionNotAllowed): park.transition_to_demolished(user=self.moderator) def test_operating_cannot_directly_relocate(self): """Test that OPERATING cannot directly transition to RELOCATED.""" park = self._create_park(status='OPERATING') - + with self.assertRaises(TransitionNotAllowed): park.transition_to_relocated(user=self.moderator) @@ -197,18 +198,18 @@ class ParkTransitionTests(TestCase): def test_reopen_wrapper_method(self): """Test the reopen() wrapper method.""" park = self._create_park(status='CLOSED_TEMP') - + park.reopen(user=self.user) - + park.refresh_from_db() self.assertEqual(park.status, 'OPERATING') def test_close_temporarily_wrapper_method(self): """Test the close_temporarily() wrapper method.""" park = self._create_park(status='OPERATING') - + park.close_temporarily(user=self.user) - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_TEMP') @@ -216,9 +217,9 @@ class ParkTransitionTests(TestCase): """Test the close_permanently() wrapper method.""" park = self._create_park(status='OPERATING') closing = date(2025, 12, 31) - + park.close_permanently(closing_date=closing, user=self.moderator) - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_PERM') self.assertEqual(park.closing_date, closing) @@ -226,9 +227,9 @@ class ParkTransitionTests(TestCase): def test_close_permanently_without_date(self): """Test close_permanently() without closing_date.""" park = self._create_park(status='OPERATING') - + park.close_permanently(user=self.moderator) - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_PERM') self.assertIsNone(park.closing_date) @@ -236,18 +237,18 @@ class ParkTransitionTests(TestCase): def test_demolish_wrapper_method(self): """Test the demolish() wrapper method.""" park = self._create_park(status='CLOSED_PERM') - + park.demolish(user=self.moderator) - + park.refresh_from_db() self.assertEqual(park.status, 'DEMOLISHED') def test_relocate_wrapper_method(self): """Test the relocate() wrapper method.""" park = self._create_park(status='CLOSED_PERM') - + park.relocate(user=self.moderator) - + park.refresh_from_db() self.assertEqual(park.status, 'RELOCATED') @@ -300,18 +301,18 @@ class ParkTransitionHistoryTests(TestCase): def test_transition_creates_state_log(self): """Test that transitions create StateLog entries.""" from django_fsm_log.models import StateLog - + park = self._create_park(status='OPERATING') - + park.transition_to_closed_temp(user=self.moderator) park.save() - + park_ct = ContentType.objects.get_for_model(park) log = StateLog.objects.filter( content_type=park_ct, object_id=park.id ).first() - + self.assertIsNotNone(log) self.assertEqual(log.state, 'CLOSED_TEMP') self.assertEqual(log.by, self.moderator) @@ -319,23 +320,23 @@ class ParkTransitionHistoryTests(TestCase): def test_multiple_transitions_create_multiple_logs(self): """Test that multiple transitions create multiple log entries.""" from django_fsm_log.models import StateLog - + park = self._create_park(status='OPERATING') park_ct = ContentType.objects.get_for_model(park) - + # First transition park.transition_to_closed_temp(user=self.moderator) park.save() - + # Second transition park.transition_to_operating(user=self.moderator) park.save() - + logs = StateLog.objects.filter( content_type=park_ct, object_id=park.id ).order_by('timestamp') - + self.assertEqual(logs.count(), 2) self.assertEqual(logs[0].state, 'CLOSED_TEMP') self.assertEqual(logs[1].state, 'OPERATING') @@ -343,18 +344,18 @@ class ParkTransitionHistoryTests(TestCase): def test_transition_log_includes_user(self): """Test that transition logs include the user who made the change.""" from django_fsm_log.models import StateLog - + park = self._create_park(status='OPERATING') - + park.transition_to_closed_perm(user=self.moderator) park.save() - + park_ct = ContentType.objects.get_for_model(park) log = StateLog.objects.filter( content_type=park_ct, object_id=park.id ).first() - + self.assertEqual(log.by, self.moderator) @@ -388,7 +389,7 @@ class ParkBusinessLogicTests(TestCase): operator=self.operator, timezone='America/New_York' ) - + self.assertEqual(park.operator, self.operator) def test_park_slug_auto_generated(self): @@ -399,7 +400,7 @@ class ParkBusinessLogicTests(TestCase): operator=self.operator, timezone='America/New_York' ) - + self.assertEqual(park.slug, 'my-amazing-theme-park') def test_park_url_generated(self): @@ -411,7 +412,7 @@ class ParkBusinessLogicTests(TestCase): operator=self.operator, timezone='America/New_York' ) - + self.assertIn('test-park', park.url) def test_opening_year_computed_from_opening_date(self): @@ -424,7 +425,7 @@ class ParkBusinessLogicTests(TestCase): opening_date=date(2020, 6, 15), timezone='America/New_York' ) - + self.assertEqual(park.opening_year, 2020) def test_search_text_populated(self): @@ -436,7 +437,7 @@ class ParkBusinessLogicTests(TestCase): operator=self.operator, timezone='America/New_York' ) - + self.assertIn('test park', park.search_text) self.assertIn('wonderful theme park', park.search_text) self.assertIn('test operator', park.search_text) @@ -451,7 +452,7 @@ class ParkBusinessLogicTests(TestCase): property_owner=self.property_owner, timezone='America/New_York' ) - + self.assertEqual(park.operator, self.operator) self.assertEqual(park.property_owner, self.property_owner) @@ -475,21 +476,22 @@ class ParkSlugHistoryTests(TestCase): def test_historical_slug_created_on_name_change(self): """Test that historical slug is created when name changes.""" from django.contrib.contenttypes.models import ContentType + from apps.core.history import HistoricalSlug - + park = Park.objects.create( name='Original Name', description='A test park', operator=self.operator, timezone='America/New_York' ) - + original_slug = park.slug - + # Change name park.name = 'New Name' park.save() - + # Check historical slug was created park_ct = ContentType.objects.get_for_model(park) historical = HistoricalSlug.objects.filter( @@ -497,7 +499,7 @@ class ParkSlugHistoryTests(TestCase): object_id=park.id, slug=original_slug ).first() - + self.assertIsNotNone(historical) self.assertEqual(historical.slug, original_slug) @@ -510,32 +512,30 @@ class ParkSlugHistoryTests(TestCase): operator=self.operator, timezone='America/New_York' ) - + found_park, is_historical = Park.get_by_slug('test-park') - + self.assertEqual(found_park, park) self.assertFalse(is_historical) def test_get_by_slug_finds_historical_slug(self): """Test get_by_slug finds park by historical slug.""" - from django.contrib.contenttypes.models import ContentType - from apps.core.history import HistoricalSlug - + park = Park.objects.create( name='Original Name', description='A test park', operator=self.operator, timezone='America/New_York' ) - + original_slug = park.slug - + # Change name to create historical slug park.name = 'New Name' park.save() - + # Find by historical slug found_park, is_historical = Park.get_by_slug(original_slug) - + self.assertEqual(found_park, park) self.assertTrue(is_historical) diff --git a/backend/apps/parks/tests/test_admin.py b/backend/apps/parks/tests/test_admin.py index 584e19ad..9c97ad1d 100644 --- a/backend/apps/parks/tests/test_admin.py +++ b/backend/apps/parks/tests/test_admin.py @@ -5,20 +5,18 @@ These tests verify the functionality of park, area, company, location, and review admin classes including query optimization and custom actions. """ -import pytest from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from apps.parks.admin import ( CompanyAdmin, - CompanyHeadquartersAdmin, ParkAdmin, ParkAreaAdmin, ParkLocationAdmin, ParkReviewAdmin, ) -from apps.parks.models import Company, CompanyHeadquarters, Park, ParkArea, ParkLocation, ParkReview +from apps.parks.models import Company, Park, ParkArea, ParkLocation, ParkReview User = get_user_model() diff --git a/backend/apps/parks/tests/test_park_workflows.py b/backend/apps/parks/tests/test_park_workflows.py index 53d15f7d..6b953fad 100644 --- a/backend/apps/parks/tests/test_park_workflows.py +++ b/backend/apps/parks/tests/test_park_workflows.py @@ -9,8 +9,8 @@ This module tests end-to-end park lifecycle workflows including: - Related ride status updates """ -from django.test import TestCase from django.contrib.auth import get_user_model +from django.test import TestCase from django.utils import timezone User = get_user_model() @@ -18,7 +18,7 @@ User = get_user_model() class ParkOpeningWorkflowTests(TestCase): """Tests for park opening workflow.""" - + @classmethod def setUpTestData(cls): cls.user = User.objects.create_user( @@ -33,16 +33,16 @@ class ParkOpeningWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_park(self, status='OPERATING', **kwargs): """Helper to create a park.""" - from apps.parks.models import Park, Company - + from apps.parks.models import Company, Park + operator = Company.objects.create( name=f'Operator {status}', roles=['OPERATOR'] ) - + defaults = { 'name': f'Test Park {status}', 'slug': f'test-park-{status.lower()}-{timezone.now().timestamp()}', @@ -52,28 +52,28 @@ class ParkOpeningWorkflowTests(TestCase): } defaults.update(kwargs) return Park.objects.create(**defaults) - + def test_park_opens_from_under_construction(self): """ Test park opening from under construction state. - + Flow: UNDER_CONSTRUCTION → OPERATING """ park = self._create_park(status='UNDER_CONSTRUCTION') - + self.assertEqual(park.status, 'UNDER_CONSTRUCTION') - + # Park opens park.transition_to_operating(user=self.user) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'OPERATING') class ParkTemporaryClosureWorkflowTests(TestCase): """Tests for park temporary closure workflow.""" - + @classmethod def setUpTestData(cls): cls.user = User.objects.create_user( @@ -82,15 +82,15 @@ class ParkTemporaryClosureWorkflowTests(TestCase): password='testpass123', role='USER' ) - + def _create_park(self, status='OPERATING', **kwargs): - from apps.parks.models import Park, Company - + from apps.parks.models import Company, Park + operator = Company.objects.create( name=f'Operator Temp {timezone.now().timestamp()}', roles=['OPERATOR'] ) - + defaults = { 'name': f'Test Park Temp {timezone.now().timestamp()}', 'slug': f'test-park-temp-{timezone.now().timestamp()}', @@ -100,35 +100,35 @@ class ParkTemporaryClosureWorkflowTests(TestCase): } defaults.update(kwargs) return Park.objects.create(**defaults) - + def test_park_temporary_closure_and_reopen(self): """ Test park temporary closure and reopening. - + Flow: OPERATING → CLOSED_TEMP → OPERATING """ park = self._create_park(status='OPERATING') - + self.assertEqual(park.status, 'OPERATING') - + # Close temporarily (e.g., off-season) park.transition_to_closed_temp(user=self.user) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_TEMP') - + # Reopen park.transition_to_operating(user=self.user) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'OPERATING') class ParkPermanentClosureWorkflowTests(TestCase): """Tests for park permanent closure workflow.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -137,15 +137,15 @@ class ParkPermanentClosureWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_park(self, status='OPERATING', **kwargs): - from apps.parks.models import Park, Company - + from apps.parks.models import Company, Park + operator = Company.objects.create( name=f'Operator Perm {timezone.now().timestamp()}', roles=['OPERATOR'] ) - + defaults = { 'name': f'Test Park Perm {timezone.now().timestamp()}', 'slug': f'test-park-perm-{timezone.now().timestamp()}', @@ -155,48 +155,48 @@ class ParkPermanentClosureWorkflowTests(TestCase): } defaults.update(kwargs) return Park.objects.create(**defaults) - + def test_park_permanent_closure(self): """ Test park permanent closure from operating state. - + Flow: OPERATING → CLOSED_PERM """ park = self._create_park(status='OPERATING') - + # Close permanently park.transition_to_closed_perm(user=self.moderator) park.closing_date = timezone.now().date() park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_PERM') self.assertIsNotNone(park.closing_date) - + def test_park_permanent_closure_from_temp(self): """ Test park permanent closure from temporary closure. - + Flow: OPERATING → CLOSED_TEMP → CLOSED_PERM """ park = self._create_park(status='OPERATING') - + # Temporary closure park.transition_to_closed_temp(user=self.moderator) park.save() - + # Becomes permanent park.transition_to_closed_perm(user=self.moderator) park.closing_date = timezone.now().date() park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_PERM') class ParkDemolitionWorkflowTests(TestCase): """Tests for park demolition workflow.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -205,15 +205,15 @@ class ParkDemolitionWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_park(self, status='CLOSED_PERM', **kwargs): - from apps.parks.models import Park, Company - + from apps.parks.models import Company, Park + operator = Company.objects.create( name=f'Operator Demo {timezone.now().timestamp()}', roles=['OPERATOR'] ) - + defaults = { 'name': f'Test Park Demo {timezone.now().timestamp()}', 'slug': f'test-park-demo-{timezone.now().timestamp()}', @@ -223,28 +223,28 @@ class ParkDemolitionWorkflowTests(TestCase): } defaults.update(kwargs) return Park.objects.create(**defaults) - + def test_park_demolition_workflow(self): """ Test complete park demolition workflow. - + Flow: OPERATING → CLOSED_PERM → DEMOLISHED """ park = self._create_park(status='CLOSED_PERM') - + # Demolish park.transition_to_demolished(user=self.moderator) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'DEMOLISHED') - + def test_demolished_is_final_state(self): """Test that demolished parks cannot transition further.""" from django_fsm import TransitionNotAllowed - + park = self._create_park(status='DEMOLISHED') - + # Cannot transition from demolished with self.assertRaises(TransitionNotAllowed): park.transition_to_operating(user=self.moderator) @@ -252,7 +252,7 @@ class ParkDemolitionWorkflowTests(TestCase): class ParkRelocationWorkflowTests(TestCase): """Tests for park relocation workflow.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -261,15 +261,15 @@ class ParkRelocationWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_park(self, status='CLOSED_PERM', **kwargs): - from apps.parks.models import Park, Company - + from apps.parks.models import Company, Park + operator = Company.objects.create( name=f'Operator Reloc {timezone.now().timestamp()}', roles=['OPERATOR'] ) - + defaults = { 'name': f'Test Park Reloc {timezone.now().timestamp()}', 'slug': f'test-park-reloc-{timezone.now().timestamp()}', @@ -279,28 +279,28 @@ class ParkRelocationWorkflowTests(TestCase): } defaults.update(kwargs) return Park.objects.create(**defaults) - + def test_park_relocation_workflow(self): """ Test park relocation workflow. - + Flow: OPERATING → CLOSED_PERM → RELOCATED """ park = self._create_park(status='CLOSED_PERM') - + # Relocate park.transition_to_relocated(user=self.moderator) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'RELOCATED') - + def test_relocated_is_final_state(self): """Test that relocated parks cannot transition further.""" from django_fsm import TransitionNotAllowed - + park = self._create_park(status='RELOCATED') - + # Cannot transition from relocated with self.assertRaises(TransitionNotAllowed): park.transition_to_operating(user=self.moderator) @@ -308,7 +308,7 @@ class ParkRelocationWorkflowTests(TestCase): class ParkWrapperMethodTests(TestCase): """Tests for park wrapper methods.""" - + @classmethod def setUpTestData(cls): cls.user = User.objects.create_user( @@ -323,15 +323,15 @@ class ParkWrapperMethodTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_park(self, status='OPERATING', **kwargs): - from apps.parks.models import Park, Company - + from apps.parks.models import Company, Park + operator = Company.objects.create( name=f'Operator Wrapper {timezone.now().timestamp()}', roles=['OPERATOR'] ) - + defaults = { 'name': f'Test Park Wrapper {timezone.now().timestamp()}', 'slug': f'test-park-wrapper-{timezone.now().timestamp()}', @@ -341,40 +341,40 @@ class ParkWrapperMethodTests(TestCase): } defaults.update(kwargs) return Park.objects.create(**defaults) - + def test_close_temporarily_wrapper(self): """Test close_temporarily wrapper method.""" park = self._create_park(status='OPERATING') - + # Use wrapper method if it exists if hasattr(park, 'close_temporarily'): park.close_temporarily(user=self.user) else: park.transition_to_closed_temp(user=self.user) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_TEMP') - + def test_reopen_wrapper(self): """Test reopen wrapper method.""" park = self._create_park(status='CLOSED_TEMP') - + # Use wrapper method if it exists if hasattr(park, 'reopen'): park.reopen(user=self.user) else: park.transition_to_operating(user=self.user) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'OPERATING') - + def test_close_permanently_wrapper(self): """Test close_permanently wrapper method.""" park = self._create_park(status='OPERATING') closing_date = timezone.now().date() - + # Use wrapper method if it exists if hasattr(park, 'close_permanently'): park.close_permanently(closing_date=closing_date, user=self.moderator) @@ -382,24 +382,24 @@ class ParkWrapperMethodTests(TestCase): park.transition_to_closed_perm(user=self.moderator) park.closing_date = closing_date park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'CLOSED_PERM') - + def test_demolish_wrapper(self): """Test demolish wrapper method.""" park = self._create_park(status='CLOSED_PERM') - + # Use wrapper method if it exists if hasattr(park, 'demolish'): park.demolish(user=self.moderator) else: park.transition_to_demolished(user=self.moderator) park.save() - + park.refresh_from_db() self.assertEqual(park.status, 'DEMOLISHED') - + def test_relocate_wrapper(self): """Test relocate wrapper method.""" park = self._create_park(status='CLOSED_PERM') @@ -434,7 +434,7 @@ class ParkStateLogTests(TestCase): ) def _create_park(self, status='OPERATING', **kwargs): - from apps.parks.models import Park, Company + from apps.parks.models import Company, Park operator = Company.objects.create( name=f'Operator Log {timezone.now().timestamp()}', @@ -453,8 +453,8 @@ class ParkStateLogTests(TestCase): def test_transition_creates_state_log(self): """Test that park transitions create StateLog entries.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog park = self._create_park(status='OPERATING') park_ct = ContentType.objects.get_for_model(park) @@ -475,8 +475,8 @@ class ParkStateLogTests(TestCase): def test_multiple_transitions_logged(self): """Test that multiple park transitions are all logged.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog park = self._create_park(status='OPERATING') park_ct = ContentType.objects.get_for_model(park) @@ -503,8 +503,8 @@ class ParkStateLogTests(TestCase): def test_full_lifecycle_logged(self): """Test complete park lifecycle is logged.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog park = self._create_park(status='OPERATING') park_ct = ContentType.objects.get_for_model(park) diff --git a/backend/apps/parks/tests/test_query_optimization.py b/backend/apps/parks/tests/test_query_optimization.py index 9d965bbd..28c7e3ae 100644 --- a/backend/apps/parks/tests/test_query_optimization.py +++ b/backend/apps/parks/tests/test_query_optimization.py @@ -7,11 +7,11 @@ These tests verify that: 3. Computed fields are updated correctly """ -from django.test import TestCase from django.db import connection +from django.test import TestCase from django.test.utils import CaptureQueriesContext -from apps.parks.models import Park, ParkLocation, Company +from apps.parks.models import Company, Park, ParkLocation class ParkQueryOptimizationTests(TestCase): @@ -225,10 +225,9 @@ class ComputedFieldMaintenanceTests(TestCase): ) # Initially no location in search_text - original_search_text = park.search_text # Add location - location = ParkLocation.objects.create( + ParkLocation.objects.create( park=park, city="Orlando", state="Florida", diff --git a/backend/apps/parks/tests_disabled/test_filters.py b/backend/apps/parks/tests_disabled/test_filters.py index 3b2e99df..02f53be3 100644 --- a/backend/apps/parks/tests_disabled/test_filters.py +++ b/backend/apps/parks/tests_disabled/test_filters.py @@ -3,11 +3,12 @@ Tests for park filtering functionality including search, status filtering, date ranges, and numeric validations. """ -from django.test import TestCase from datetime import date -from apps.parks.models import Park, ParkLocation, Company +from django.test import TestCase + from apps.parks.filters import ParkFilter +from apps.parks.models import Company, Park, ParkLocation # NOTE: These tests need to be updated to work with the new ParkLocation model # instead of the generic Location model diff --git a/backend/apps/parks/tests_disabled/test_models.py b/backend/apps/parks/tests_disabled/test_models.py index 862689f2..cffc0740 100644 --- a/backend/apps/parks/tests_disabled/test_models.py +++ b/backend/apps/parks/tests_disabled/test_models.py @@ -3,10 +3,10 @@ Tests for park models functionality including CRUD operations, slug handling, status management, and location integration. """ -from django.test import TestCase from django.db import IntegrityError +from django.test import TestCase -from apps.parks.models import Park, ParkArea, ParkLocation, Company +from apps.parks.models import Company, Park, ParkArea, ParkLocation # NOTE: These tests need to be updated to work with the new ParkLocation model # instead of the generic Location model @@ -55,9 +55,9 @@ class ParkModelTests(TestCase): def test_historical_slug_lookup(self): """Test finding park by historical slug""" - from django.db import transaction - from django.contrib.contenttypes.models import ContentType from core.history import HistoricalSlug + from django.contrib.contenttypes.models import ContentType + from django.db import transaction with transaction.atomic(): # Create initial park with a specific name/slug @@ -162,12 +162,11 @@ class ParkAreaModelTests(TestCase): from django.db import transaction # Try to create area with same slug in same park - with transaction.atomic(): - with self.assertRaises(IntegrityError): - ParkArea.objects.create( - park=self.park, - name="Test Area", # Will generate same slug - ) + with transaction.atomic(), self.assertRaises(IntegrityError): + ParkArea.objects.create( + park=self.park, + name="Test Area", # Will generate same slug + ) # Should be able to use same name in different park other_park = Park.objects.create(name="Other Park", operator=self.operator) diff --git a/backend/apps/parks/tests_disabled/test_search.py b/backend/apps/parks/tests_disabled/test_search.py index dd4d29e9..e9d356b8 100644 --- a/backend/apps/parks/tests_disabled/test_search.py +++ b/backend/apps/parks/tests_disabled/test_search.py @@ -1,9 +1,9 @@ import pytest -from django.urls import reverse from django.test import Client +from django.urls import reverse -from apps.parks.models import Park from apps.parks.forms import ParkAutocomplete, ParkSearchForm +from apps.parks.models import Park @pytest.mark.django_db diff --git a/backend/apps/parks/urls.py b/backend/apps/parks/urls.py index 1a763d95..61c35a61 100644 --- a/backend/apps/parks/urls.py +++ b/backend/apps/parks/urls.py @@ -1,14 +1,16 @@ -from django.urls import path, include -from . import views, views_search -from apps.rides.views import ParkSingleCategoryListView +from django.urls import include, path + from apps.core.views.views import FSMTransitionView +from apps.rides.views import ParkSingleCategoryListView + +from . import views, views_search from .views_roadtrip import ( - RoadTripPlannerView, CreateTripView, - TripDetailView, FindParksAlongRouteView, GeocodeAddressView, ParkDistanceCalculatorView, + RoadTripPlannerView, + TripDetailView, ) app_name = "parks" diff --git a/backend/apps/parks/views.py b/backend/apps/parks/views.py index ca3ded82..b92cc612 100644 --- a/backend/apps/parks/views.py +++ b/backend/apps/parks/views.py @@ -1,41 +1,41 @@ -from .querysets import get_base_park_queryset -from apps.core.mixins import HTMXFilterableMixin -from .models.location import ParkLocation -from .models.media import ParkPhoto -from apps.moderation.mixins import ( - EditSubmissionMixin, - PhotoSubmissionMixin, - HistoryMixin, -) -from apps.core.views.views import SlugRedirectMixin -from .filters import ParkFilter -from .forms import ParkForm -from .models import Park, ParkArea, ParkReview as Review -from .services import ParkFilterService, ParkService -from django.http import ( - HttpResponseRedirect, - HttpResponse, - HttpRequest, - JsonResponse, -) -from django.core.exceptions import ObjectDoesNotExist -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import QuerySet -from django.urls import reverse -from django.shortcuts import get_object_or_404, render -from decimal import InvalidOperation -from django.views.generic import DetailView, ListView, CreateView, UpdateView -import requests -from decimal import Decimal, ROUND_DOWN -from typing import Any, Optional, cast, Literal, Dict -from django.views.decorators.http import require_POST -from django.template.loader import render_to_string - +import contextlib import json import logging +from decimal import ROUND_DOWN, Decimal, InvalidOperation +from typing import Any, Literal, cast -from apps.core.logging import log_exception, log_business_event +import requests +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import QuerySet +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseRedirect, + JsonResponse, +) +from django.shortcuts import get_object_or_404, render +from django.template.loader import render_to_string +from django.urls import reverse +from django.views.decorators.http import require_POST +from django.views.generic import CreateView, DetailView, ListView, UpdateView + +from apps.core.logging import log_business_event, log_exception +from apps.core.mixins import HTMXFilterableMixin +from apps.core.views.views import SlugRedirectMixin +from apps.moderation.mixins import ( + EditSubmissionMixin, + HistoryMixin, + PhotoSubmissionMixin, +) + +from .filters import ParkFilter +from .forms import ParkForm +from .models import Park, ParkArea +from .models import ParkReview as Review +from .querysets import get_base_park_queryset +from .services import ParkFilterService, ParkService logger = logging.getLogger(__name__) @@ -364,7 +364,7 @@ class ParkListView(HTMXFilterableMixin, ListView): "search_query": self.request.GET.get("search", ""), } - def _get_clean_filter_params(self) -> Dict[str, Any]: + def _get_clean_filter_params(self) -> dict[str, Any]: """Extract and clean filter parameters from request.""" filter_params = {} @@ -391,7 +391,7 @@ class ParkListView(HTMXFilterableMixin, ListView): return {k: v for k, v in filter_params.items() if v is not None} - def _clean_filter_value(self, param: str, value: str) -> Optional[Any]: + def _clean_filter_value(self, param: str, value: str) -> Any | None: """Clean and validate a single filter value.""" if param in ("has_coasters", "big_parks_only"): # Boolean filters @@ -413,7 +413,7 @@ class ParkListView(HTMXFilterableMixin, ListView): # String filters return value.strip() - def _build_filter_query_string(self, filter_params: Dict[str, Any]) -> str: + def _build_filter_query_string(self, filter_params: dict[str, Any]) -> str: """Build query string from filter parameters.""" from urllib.parse import urlencode @@ -428,8 +428,8 @@ class ParkListView(HTMXFilterableMixin, ListView): return urlencode(url_params) def _get_pagination_urls( - self, page_obj, filter_params: Dict[str, Any] - ) -> Dict[str, str]: + self, page_obj, filter_params: dict[str, Any] + ) -> dict[str, str]: """Generate pagination URLs that preserve filter state.""" base_query = self._build_filter_query_string(filter_params) @@ -841,10 +841,8 @@ def htmx_save_trip(request: HttpRequest) -> HttpResponse: trip = Trip.objects.create(owner=request.user, name=name) # attempt to associate parks if the Trip model supports it - try: + with contextlib.suppress(Exception): trip.parks.set([p.id for p in parks]) - except Exception: - pass trips = list( Trip.objects.filter(owner=request.user).order_by("-created_at")[:10] ) @@ -1133,7 +1131,7 @@ class ParkDetailView( template_name = "parks/park_detail.html" context_object_name = "park" - def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park: + def get_object(self, queryset: QuerySet[Park] | None = None) -> Park: if queryset is None: queryset = self.get_queryset() slug = self.kwargs.get(self.slug_url_kwarg) @@ -1184,7 +1182,7 @@ class ParkAreaDetailView( context_object_name = "area" slug_url_kwarg = "area_slug" - def get_object(self, queryset: Optional[QuerySet[ParkArea]] = None) -> ParkArea: + def get_object(self, queryset: QuerySet[ParkArea] | None = None) -> ParkArea: if queryset is None: queryset = self.get_queryset() park_slug = self.kwargs.get("park_slug") @@ -1217,9 +1215,10 @@ class OperatorListView(ListView): def get_queryset(self): """Get companies that are operators with optimized query""" - from .models.companies import Company from django.db.models import Count + from .models.companies import Company + return ( Company.objects.filter(roles__contains=["OPERATOR"]) .annotate(park_count=Count("operated_parks")) diff --git a/backend/apps/parks/views_roadtrip.py b/backend/apps/parks/views_roadtrip.py index a5809c36..ff977ae1 100644 --- a/backend/apps/parks/views_roadtrip.py +++ b/backend/apps/parks/views_roadtrip.py @@ -4,16 +4,18 @@ Provides interfaces for creating and managing multi-park road trips. """ import json -from typing import Dict, Any, List +from typing import Any + +from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import render -from django.http import JsonResponse, HttpRequest, HttpResponse -from django.views.generic import TemplateView, View from django.urls import reverse +from django.views.generic import TemplateView, View + +from apps.core.services.data_structures import LocationType +from apps.core.services.map_service import unified_map_service from .models import Park from .services.roadtrip import RoadTripService -from apps.core.services.map_service import unified_map_service -from apps.core.services.data_structures import LocationType JSON_DECODE_ERROR_MSG = "Invalid JSON data" PARKS_ALONG_ROUTE_HTML = "parks/partials/parks_along_route.html" @@ -26,7 +28,7 @@ class RoadTripViewMixin: super().__init__() self.roadtrip_service = RoadTripService() - def get_roadtrip_context(self) -> Dict[str, Any]: + def get_roadtrip_context(self) -> dict[str, Any]: """Get common context data for road trip views.""" return { "roadtrip_api_urls": { @@ -72,7 +74,7 @@ class RoadTripPlannerView(RoadTripViewMixin, TemplateView): return context - def _get_countries_with_parks(self) -> List[str]: + def _get_countries_with_parks(self) -> list[str]: """Get list of countries that have theme parks.""" countries = ( Park.objects.filter(status="OPERATING", location__country__isnull=False) @@ -177,7 +179,7 @@ class CreateTripView(RoadTripViewMixin, View): status=500, ) - def _park_to_dict(self, park: Park) -> Dict[str, Any]: + def _park_to_dict(self, park: Park) -> dict[str, Any]: """Convert park instance to dictionary.""" return { "id": park.id, @@ -190,7 +192,7 @@ class CreateTripView(RoadTripViewMixin, View): "url": reverse("parks:park_detail", kwargs={"slug": park.slug}), } - def _leg_to_dict(self, leg) -> Dict[str, Any]: + def _leg_to_dict(self, leg) -> dict[str, Any]: """Convert trip leg to dictionary.""" return { "from_park": self._park_to_dict(leg.from_park), diff --git a/backend/apps/parks/views_search.py b/backend/apps/parks/views_search.py index 1b3106e9..eb963d86 100644 --- a/backend/apps/parks/views_search.py +++ b/backend/apps/parks/views_search.py @@ -1,6 +1,6 @@ from django.http import HttpRequest, JsonResponse -from django.views.generic import TemplateView from django.urls import reverse +from django.views.generic import TemplateView from .filters import ParkFilter from .forms import ParkSearchForm @@ -23,10 +23,7 @@ class ParkSearchView(TemplateView): # Apply search if park ID selected via autocomplete park_id = self.request.GET.get("park") - if park_id: - queryset = filter_instance.qs.filter(id=park_id) - else: - queryset = filter_instance.qs + queryset = filter_instance.qs.filter(id=park_id) if park_id else filter_instance.qs # Handle view mode context["view_mode"] = self.request.GET.get("view_mode", "grid") diff --git a/backend/apps/reviews/apps.py b/backend/apps/reviews/apps.py index 8eb3cfd3..125374af 100644 --- a/backend/apps/reviews/apps.py +++ b/backend/apps/reviews/apps.py @@ -1,9 +1,10 @@ from django.apps import AppConfig + class ReviewsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.reviews" verbose_name = "User Reviews" def ready(self): - import apps.reviews.signals + pass diff --git a/backend/apps/reviews/models.py b/backend/apps/reviews/models.py index 78ab0c4c..5ef49477 100644 --- a/backend/apps/reviews/models.py +++ b/backend/apps/reviews/models.py @@ -1,9 +1,11 @@ -from django.db import models +import pghistory from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.db import models + from apps.core.history import TrackedModel -import pghistory + @pghistory.track() class Review(TrackedModel): @@ -13,7 +15,7 @@ class Review(TrackedModel): related_name="reviews", help_text="User who wrote the review", ) - + # Generic relation to target object (Park, Ride, etc.) content_type = models.ForeignKey( ContentType, @@ -30,14 +32,14 @@ class Review(TrackedModel): db_index=True, ) text = models.TextField(blank=True, help_text="Review text (optional)") - + # Metadata is_public = models.BooleanField( - default=True, + default=True, help_text="Whether this review is visible to others" ) helpful_votes = models.PositiveIntegerField( - default=0, + default=0, help_text="Number of users who found this helpful" ) diff --git a/backend/apps/reviews/serializers.py b/backend/apps/reviews/serializers.py index 7ba629f5..9eea7bf9 100644 --- a/backend/apps/reviews/serializers.py +++ b/backend/apps/reviews/serializers.py @@ -1,10 +1,13 @@ from rest_framework import serializers -from .models import Review + from apps.accounts.serializers import UserSerializer +from .models import Review + + class ReviewSerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) - + class Meta: model = Review fields = [ diff --git a/backend/apps/reviews/signals.py b/backend/apps/reviews/signals.py index 9a926d80..dfa7f510 100644 --- a/backend/apps/reviews/signals.py +++ b/backend/apps/reviews/signals.py @@ -1,8 +1,10 @@ -from django.db.models.signals import post_save, post_delete -from django.dispatch import receiver from django.db.models import Avg +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + from .models import Review + @receiver(post_save, sender=Review) @receiver(post_delete, sender=Review) def update_average_rating(sender, instance, **kwargs): diff --git a/backend/apps/reviews/urls.py b/backend/apps/reviews/urls.py index e80a56f1..f43b970e 100644 --- a/backend/apps/reviews/urls.py +++ b/backend/apps/reviews/urls.py @@ -1,5 +1,6 @@ -from django.urls import path, include +from django.urls import include, path from rest_framework.routers import DefaultRouter + from .views import ReviewViewSet router = DefaultRouter() diff --git a/backend/apps/reviews/views.py b/backend/apps/reviews/views.py index d2dc48b3..4fc3a194 100644 --- a/backend/apps/reviews/views.py +++ b/backend/apps/reviews/views.py @@ -1,8 +1,11 @@ -from rest_framework import viewsets, permissions, filters from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, permissions, viewsets + +from apps.core.permissions import IsOwnerOrReadOnly + from .models import Review from .serializers import ReviewSerializer -from apps.core.permissions import IsOwnerOrReadOnly + class ReviewViewSet(viewsets.ModelViewSet): queryset = Review.objects.filter(is_public=True) @@ -14,8 +17,8 @@ class ReviewViewSet(viewsets.ModelViewSet): ordering = ["-created_at"] def get_queryset(self): - # Users can see their own non-public reviews? - # Standard queryset is public only. + # Users can see their own non-public reviews? + # Standard queryset is public only. # But if we want authors to see their own pending/private reviews: qs = Review.objects.filter(is_public=True) if self.request.user.is_authenticated: diff --git a/backend/apps/rides/apps.py b/backend/apps/rides/apps.py index 10a2e461..0888a8b8 100644 --- a/backend/apps/rides/apps.py +++ b/backend/apps/rides/apps.py @@ -2,7 +2,6 @@ import logging from django.apps import AppConfig - logger = logging.getLogger(__name__) @@ -30,15 +29,15 @@ class RidesConfig(AppConfig): def _register_callbacks(self): """Register FSM transition callbacks for ride models.""" - from apps.core.state_machine.registry import register_callback from apps.core.state_machine.callbacks.cache import ( - RideCacheInvalidation, APICacheInvalidation, + RideCacheInvalidation, ) from apps.core.state_machine.callbacks.related_updates import ( ParkCountUpdateCallback, SearchTextUpdateCallback, ) + from apps.core.state_machine.registry import register_callback from apps.rides.models import Ride # Cache invalidation for all ride status changes diff --git a/backend/apps/rides/choices.py b/backend/apps/rides/choices.py index 12e433c0..69a80308 100644 --- a/backend/apps/rides/choices.py +++ b/backend/apps/rides/choices.py @@ -5,10 +5,9 @@ This module defines all choice objects for the rides domain, replacing the legacy tuple-based choices with rich choice objects. """ -from apps.core.choices import RichChoice, ChoiceCategory +from apps.core.choices import ChoiceCategory, RichChoice from apps.core.choices.registry import register_choices - # Ride Category Choices RIDE_CATEGORIES = [ RichChoice( @@ -762,7 +761,7 @@ RIDES_COMPANY_ROLES = [ def register_rides_choices(): """Register all rides domain choices with the global registry""" - + register_choices( name="categories", choices=RIDE_CATEGORIES, @@ -770,7 +769,7 @@ def register_rides_choices(): description="Ride category classifications", metadata={'domain': 'rides', 'type': 'category'} ) - + register_choices( name="statuses", choices=RIDE_STATUSES, @@ -778,7 +777,7 @@ def register_rides_choices(): description="Ride operational status options", metadata={'domain': 'rides', 'type': 'status'} ) - + register_choices( name="post_closing_statuses", choices=POST_CLOSING_STATUSES, @@ -786,7 +785,7 @@ def register_rides_choices(): description="Status options after ride closure", metadata={'domain': 'rides', 'type': 'post_closing_status'} ) - + register_choices( name="track_materials", choices=TRACK_MATERIALS, @@ -794,7 +793,7 @@ def register_rides_choices(): description="Roller coaster track material types", metadata={'domain': 'rides', 'type': 'track_material', 'applies_to': 'roller_coasters'} ) - + register_choices( name="coaster_types", choices=COASTER_TYPES, @@ -802,7 +801,7 @@ def register_rides_choices(): description="Roller coaster type classifications", metadata={'domain': 'rides', 'type': 'coaster_type', 'applies_to': 'roller_coasters'} ) - + register_choices( name="propulsion_systems", choices=PROPULSION_SYSTEMS, @@ -810,7 +809,7 @@ def register_rides_choices(): description="Roller coaster propulsion and lift systems", metadata={'domain': 'rides', 'type': 'propulsion_system', 'applies_to': 'roller_coasters'} ) - + register_choices( name="target_markets", choices=TARGET_MARKETS, @@ -818,7 +817,7 @@ def register_rides_choices(): description="Target market classifications for ride models", metadata={'domain': 'rides', 'type': 'target_market', 'applies_to': 'ride_models'} ) - + register_choices( name="photo_types", choices=PHOTO_TYPES, @@ -826,7 +825,7 @@ def register_rides_choices(): description="Photo type classifications for ride model images", metadata={'domain': 'rides', 'type': 'photo_type', 'applies_to': 'ride_model_photos'} ) - + register_choices( name="spec_categories", choices=SPEC_CATEGORIES, @@ -834,7 +833,7 @@ def register_rides_choices(): description="Technical specification category classifications", metadata={'domain': 'rides', 'type': 'spec_category', 'applies_to': 'ride_model_specs'} ) - + register_choices( name="company_roles", choices=RIDES_COMPANY_ROLES, diff --git a/backend/apps/rides/events.py b/backend/apps/rides/events.py index 7efb60c4..b0bcc805 100644 --- a/backend/apps/rides/events.py +++ b/backend/apps/rides/events.py @@ -1,7 +1,6 @@ -from typing import Dict -def get_ride_display_changes(changes: Dict) -> Dict: +def get_ride_display_changes(changes: dict) -> dict: """Returns a human-readable version of the ride changes""" field_names = { "name": "Name", @@ -26,7 +25,7 @@ def get_ride_display_changes(changes: Dict) -> Dict: # Format specific fields if field == "status": from .choices import RIDE_STATUSES - + choices = {choice.value: choice.label for choice in RIDE_STATUSES} if old_value in choices: old_value = choices[old_value] @@ -34,7 +33,7 @@ def get_ride_display_changes(changes: Dict) -> Dict: new_value = choices[new_value] elif field == "post_closing_status": from .choices import POST_CLOSING_STATUSES - + choices = {choice.value: choice.label for choice in POST_CLOSING_STATUSES} if old_value in choices: old_value = choices[old_value] @@ -49,7 +48,7 @@ def get_ride_display_changes(changes: Dict) -> Dict: return display_changes -def get_ride_model_display_changes(changes: Dict) -> Dict: +def get_ride_model_display_changes(changes: dict) -> dict: """Returns a human-readable version of the ride model changes""" field_names = { "name": "Name", diff --git a/backend/apps/rides/forms.py b/backend/apps/rides/forms.py index 546ed9ff..b225a3bb 100644 --- a/backend/apps/rides/forms.py +++ b/backend/apps/rides/forms.py @@ -1,7 +1,9 @@ -from apps.parks.models import Park, ParkArea from django import forms from django.forms import ModelChoiceField from django.urls import reverse_lazy + +from apps.parks.models import Park, ParkArea + from .models.company import Company from .models.rides import Ride, RideModel diff --git a/backend/apps/rides/forms/__init__.py b/backend/apps/rides/forms/__init__.py index 13dfb8a6..dd8297bc 100644 --- a/backend/apps/rides/forms/__init__.py +++ b/backend/apps/rides/forms/__init__.py @@ -7,10 +7,9 @@ This package contains form classes for ride-related functionality including: """ # Import forms from the search module in this package -from .search import MasterFilterForm - # Import forms from the base module in this package from .base import RideForm, RideSearchForm +from .search import MasterFilterForm __all__ = [ "MasterFilterForm", diff --git a/backend/apps/rides/forms/base.py b/backend/apps/rides/forms/base.py index b826f641..bb64a378 100644 --- a/backend/apps/rides/forms/base.py +++ b/backend/apps/rides/forms/base.py @@ -1,7 +1,9 @@ -from apps.parks.models import Park, ParkArea from django import forms from django.forms import ModelChoiceField from django.urls import reverse_lazy + +from apps.parks.models import Park, ParkArea + from ..models.company import Company from ..models.rides import Ride, RideModel diff --git a/backend/apps/rides/forms/search.py b/backend/apps/rides/forms/search.py index 134f9731..18ae69c2 100644 --- a/backend/apps/rides/forms/search.py +++ b/backend/apps/rides/forms/search.py @@ -14,9 +14,10 @@ Forms for the comprehensive ride filtering system with 8 categories: Each form handles validation and provides clean data for the RideSearchService. """ +from typing import Any + from django import forms from django.core.exceptions import ValidationError -from typing import Dict, Any from apps.parks.models import Park, ParkArea from apps.rides.models.company import Company @@ -95,14 +96,14 @@ class BasicInfoForm(BaseFilterForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + # Get choices from Rich Choice registry from apps.core.choices.registry import get_choices - + # Get choices - let exceptions propagate if registry fails category_choices = [(choice.value, choice.label) for choice in get_choices("categories", "rides")] status_choices = [(choice.value, choice.label) for choice in get_choices("statuses", "rides")] - + # Update field choices dynamically self.fields['category'].choices = category_choices self.fields['status'].choices = status_choices @@ -189,9 +190,8 @@ class DateRangeField(forms.MultiValueField): def validate(self, value): super().validate(value) - if value and value.get("start") and value.get("end"): - if value["start"] > value["end"]: - raise ValidationError("Start date must be before end date.") + if value and value.get("start") and value.get("end") and value["start"] > value["end"]: + raise ValidationError("Start date must be before end date.") class DateFiltersForm(BaseFilterForm): @@ -340,15 +340,15 @@ class RollerCoasterForm(BaseFilterForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + # Get choices from Rich Choice registry from apps.core.choices.registry import get_choices - + # Get choices - let exceptions propagate if registry fails track_material_choices = [(choice.value, choice.label) for choice in get_choices("track_materials", "rides")] coaster_type_choices = [(choice.value, choice.label) for choice in get_choices("coaster_types", "rides")] propulsion_system_choices = [(choice.value, choice.label) for choice in get_choices("propulsion_systems", "rides")] - + # Update field choices dynamically self.fields['track_material'].choices = track_material_choices self.fields['coaster_type'].choices = coaster_type_choices @@ -398,15 +398,15 @@ class CompanyForm(BaseFilterForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + # Get choices from Rich Choice registry from apps.core.choices.registry import get_choices - + # Get both rides and parks company roles - let exceptions propagate if registry fails rides_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "rides")] parks_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "parks")] role_choices = rides_roles + parks_roles - + # Update field choices dynamically self.fields['manufacturer_roles'].choices = role_choices self.fields['designer_roles'].choices = role_choices @@ -434,7 +434,7 @@ class SortingForm(BaseFilterForm): # Static sorting choices - these are UI-specific and don't need Rich Choice Objects def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + # Static sort choices for UI functionality sort_choices = [ ("relevance", "Relevance"), @@ -451,7 +451,7 @@ class SortingForm(BaseFilterForm): ("capacity_asc", "Capacity (Lowest)"), ("capacity_desc", "Capacity (Highest)"), ] - + self.fields['sort_by'].choices = sort_choices sort_by = forms.ChoiceField( @@ -524,7 +524,7 @@ class MasterFilterForm(BaseFilterForm): return cleaned_data - def get_filter_dict(self) -> Dict[str, Any]: + def get_filter_dict(self) -> dict[str, Any]: """Convert form data to search service filter format.""" if not self.is_valid(): return {} @@ -538,11 +538,11 @@ class MasterFilterForm(BaseFilterForm): return filters - def get_search_filters(self) -> Dict[str, Any]: + def get_search_filters(self) -> dict[str, Any]: """Alias for get_filter_dict for backward compatibility.""" return self.get_filter_dict() - def get_filter_summary(self) -> Dict[str, Any]: + def get_filter_summary(self) -> dict[str, Any]: """Get summary of active filters for display.""" active_filters = {} @@ -588,7 +588,7 @@ class MasterFilterForm(BaseFilterForm): return active_filters - def get_active_filters_summary(self) -> Dict[str, Any]: + def get_active_filters_summary(self) -> dict[str, Any]: """Alias for get_filter_summary for backward compatibility.""" return self.get_filter_summary() @@ -597,8 +597,4 @@ class MasterFilterForm(BaseFilterForm): if not self.is_valid(): return False - for field_name, value in self.cleaned_data.items(): - if value: # If any field has a value, we have active filters - return True - - return False + return any(value for field_name, value in self.cleaned_data.items()) diff --git a/backend/apps/rides/managers.py b/backend/apps/rides/managers.py index b2831bd4..0b42dee7 100644 --- a/backend/apps/rides/managers.py +++ b/backend/apps/rides/managers.py @@ -3,23 +3,23 @@ Custom managers and QuerySets for Rides models. Optimized queries following Django styleguide patterns. """ -from typing import Optional, List, Union -from django.db.models import Q, F, Count, Prefetch + +from django.db.models import Count, F, Prefetch, Q from apps.core.managers import ( - BaseQuerySet, BaseManager, - ReviewableQuerySet, + BaseQuerySet, ReviewableManager, - StatusQuerySet, + ReviewableQuerySet, StatusManager, + StatusQuerySet, ) class RideQuerySet(StatusQuerySet, ReviewableQuerySet): """Optimized QuerySet for Ride model.""" - def by_category(self, *, category: Union[str, List[str]]): + def by_category(self, *, category: str | list[str]): """Filter rides by category.""" if isinstance(category, list): return self.filter(category__in=category) @@ -119,10 +119,10 @@ class RideQuerySet(StatusQuerySet, ReviewableQuerySet): def search_by_specs( self, *, - min_height: Optional[int] = None, - max_height: Optional[int] = None, - min_speed: Optional[float] = None, - inversions: Optional[bool] = None, + min_height: int | None = None, + max_height: int | None = None, + min_speed: float | None = None, + inversions: bool | None = None, ): """Search rides by physical specifications.""" queryset = self diff --git a/backend/apps/rides/migrations/0007_ridephoto_ridephotoevent_and_more.py b/backend/apps/rides/migrations/0007_ridephoto_ridephotoevent_and_more.py index 3864cfba..ba68d080 100644 --- a/backend/apps/rides/migrations/0007_ridephoto_ridephotoevent_and_more.py +++ b/backend/apps/rides/migrations/0007_ridephoto_ridephotoevent_and_more.py @@ -1,12 +1,13 @@ # Generated by Django 5.2.5 on 2025-08-26 17:39 -import apps.rides.models.media import django.db.models.deletion import pgtrigger.compiler import pgtrigger.migrations from django.conf import settings from django.db import migrations, models +import apps.rides.models.media + class Migration(migrations.Migration): dependencies = [ diff --git a/backend/apps/rides/migrations/0019_populate_hybrid_filtering_fields.py b/backend/apps/rides/migrations/0019_populate_hybrid_filtering_fields.py index e9fb44a2..afb3cf09 100644 --- a/backend/apps/rides/migrations/0019_populate_hybrid_filtering_fields.py +++ b/backend/apps/rides/migrations/0019_populate_hybrid_filtering_fields.py @@ -6,36 +6,36 @@ in the previous migration. These fields enable efficient hybrid filtering by pre-computing commonly filtered and searched data. """ -from django.db import migrations import pghistory +from django.db import migrations def populate_computed_fields(apps, schema_editor): """Populate computed fields for all existing rides.""" Ride = apps.get_model('rides', 'Ride') - + # Disable pghistory triggers during bulk operations to avoid performance issues with pghistory.context(disable=True): rides = list(Ride.objects.all().select_related( 'park', 'park__location', 'park_area', 'manufacturer', 'designer', 'ride_model' )) - + for ride in rides: # Extract opening year from opening_date if ride.opening_date: ride.opening_year = ride.opening_date.year else: ride.opening_year = None - + # Build comprehensive search text search_parts = [] - + # Basic ride info if ride.name: search_parts.append(ride.name) if ride.description: search_parts.append(ride.description) - + # Park info if ride.park: search_parts.append(ride.park.name) @@ -46,11 +46,11 @@ def populate_computed_fields(apps, schema_editor): search_parts.append(ride.park.location.state) if ride.park.location.country: search_parts.append(ride.park.location.country) - + # Park area if ride.park_area: search_parts.append(ride.park_area.name) - + # Category if ride.category: category_choices = [ @@ -65,7 +65,7 @@ def populate_computed_fields(apps, schema_editor): category_display = dict(category_choices).get(ride.category, '') if category_display: search_parts.append(category_display) - + # Status if ride.status: status_choices = [ @@ -82,21 +82,21 @@ def populate_computed_fields(apps, schema_editor): status_display = dict(status_choices).get(ride.status, '') if status_display: search_parts.append(status_display) - + # Companies if ride.manufacturer: search_parts.append(ride.manufacturer.name) if ride.designer: search_parts.append(ride.designer.name) - + # Ride model if ride.ride_model: search_parts.append(ride.ride_model.name) if ride.ride_model.manufacturer: search_parts.append(ride.ride_model.manufacturer.name) - + ride.search_text = ' '.join(filter(None, search_parts)).lower() - + # Bulk update all rides Ride.objects.bulk_update(rides, ['opening_year', 'search_text'], batch_size=1000) @@ -104,7 +104,7 @@ def populate_computed_fields(apps, schema_editor): def reverse_populate_computed_fields(apps, schema_editor): """Clear computed fields (reverse operation).""" Ride = apps.get_model('rides', 'Ride') - + # Disable pghistory triggers during bulk operations with pghistory.context(disable=True): Ride.objects.all().update(opening_year=None, search_text='') diff --git a/backend/apps/rides/migrations/0020_add_hybrid_filtering_indexes.py b/backend/apps/rides/migrations/0020_add_hybrid_filtering_indexes.py index eca815ea..d64ef750 100644 --- a/backend/apps/rides/migrations/0020_add_hybrid_filtering_indexes.py +++ b/backend/apps/rides/migrations/0020_add_hybrid_filtering_indexes.py @@ -28,151 +28,151 @@ class Migration(migrations.Migration): "CREATE INDEX rides_ride_park_category_idx ON rides_ride (park_id, category) WHERE category != '';", reverse_sql="DROP INDEX IF EXISTS rides_ride_park_category_idx;" ), - + # Composite index for park + status filtering (common) migrations.RunSQL( "CREATE INDEX rides_ride_park_status_idx ON rides_ride (park_id, status);", reverse_sql="DROP INDEX IF EXISTS rides_ride_park_status_idx;" ), - + # Composite index for category + status filtering migrations.RunSQL( "CREATE INDEX rides_ride_category_status_idx ON rides_ride (category, status) WHERE category != '';", reverse_sql="DROP INDEX IF EXISTS rides_ride_category_status_idx;" ), - + # Composite index for manufacturer + category migrations.RunSQL( "CREATE INDEX rides_ride_manufacturer_category_idx ON rides_ride (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL AND category != '';", reverse_sql="DROP INDEX IF EXISTS rides_ride_manufacturer_category_idx;" ), - + # Composite index for opening year + category (for timeline filtering) migrations.RunSQL( "CREATE INDEX rides_ride_opening_year_category_idx ON rides_ride (opening_year, category) WHERE opening_year IS NOT NULL AND category != '';", reverse_sql="DROP INDEX IF EXISTS rides_ride_opening_year_category_idx;" ), - + # Partial index for operating rides only (most common filter) migrations.RunSQL( "CREATE INDEX rides_ride_operating_only_idx ON rides_ride (park_id, category, opening_year) WHERE status = 'OPERATING';", reverse_sql="DROP INDEX IF EXISTS rides_ride_operating_only_idx;" ), - + # Partial index for roller coasters only (popular category) migrations.RunSQL( "CREATE INDEX rides_ride_roller_coasters_idx ON rides_ride (park_id, status, opening_year) WHERE category = 'RC';", reverse_sql="DROP INDEX IF EXISTS rides_ride_roller_coasters_idx;" ), - + # Covering index for list views (includes commonly displayed fields) migrations.RunSQL( "CREATE INDEX rides_ride_list_covering_idx ON rides_ride (park_id, category, status) INCLUDE (name, opening_date, average_rating);", reverse_sql="DROP INDEX IF EXISTS rides_ride_list_covering_idx;" ), - + # GIN index for full-text search on computed search_text field migrations.RunSQL( "CREATE INDEX rides_ride_search_text_gin_idx ON rides_ride USING gin(to_tsvector('english', search_text));", reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_gin_idx;" ), - + # Trigram index for fuzzy text search migrations.RunSQL( "CREATE INDEX rides_ride_search_text_trgm_idx ON rides_ride USING gin(search_text gin_trgm_ops);", reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_trgm_idx;" ), - + # Index for rating-based filtering migrations.RunSQL( "CREATE INDEX rides_ride_rating_idx ON rides_ride (average_rating) WHERE average_rating IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_ride_rating_idx;" ), - + # Index for capacity-based filtering migrations.RunSQL( "CREATE INDEX rides_ride_capacity_idx ON rides_ride (capacity_per_hour) WHERE capacity_per_hour IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_ride_capacity_idx;" ), - + # Index for height requirement filtering migrations.RunSQL( "CREATE INDEX rides_ride_height_req_idx ON rides_ride (min_height_in, max_height_in) WHERE min_height_in IS NOT NULL OR max_height_in IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_ride_height_req_idx;" ), - + # Composite index for ride model filtering migrations.RunSQL( "CREATE INDEX rides_ride_model_manufacturer_idx ON rides_ride (ride_model_id, manufacturer_id) WHERE ride_model_id IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_ride_model_manufacturer_idx;" ), - + # Index for designer filtering migrations.RunSQL( "CREATE INDEX rides_ride_designer_idx ON rides_ride (designer_id, category) WHERE designer_id IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_ride_designer_idx;" ), - + # Index for park area filtering migrations.RunSQL( "CREATE INDEX rides_ride_park_area_idx ON rides_ride (park_area_id, status) WHERE park_area_id IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_ride_park_area_idx;" ), - + # Roller coaster stats indexes for performance migrations.RunSQL( "CREATE INDEX rides_rollercoasterstats_height_idx ON rides_rollercoasterstats (height_ft) WHERE height_ft IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_height_idx;" ), - + migrations.RunSQL( "CREATE INDEX rides_rollercoasterstats_speed_idx ON rides_rollercoasterstats (speed_mph) WHERE speed_mph IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_speed_idx;" ), - + migrations.RunSQL( "CREATE INDEX rides_rollercoasterstats_inversions_idx ON rides_rollercoasterstats (inversions);", reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_inversions_idx;" ), - + migrations.RunSQL( "CREATE INDEX rides_rollercoasterstats_type_material_idx ON rides_rollercoasterstats (roller_coaster_type, track_material);", reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_type_material_idx;" ), - + migrations.RunSQL( "CREATE INDEX rides_rollercoasterstats_launch_type_idx ON rides_rollercoasterstats (launch_type);", reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_launch_type_idx;" ), - + # Composite index for complex roller coaster filtering migrations.RunSQL( "CREATE INDEX rides_rollercoasterstats_complex_idx ON rides_rollercoasterstats (roller_coaster_type, track_material, launch_type) INCLUDE (height_ft, speed_mph, inversions);", reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_complex_idx;" ), - + # Index for ride model filtering and search migrations.RunSQL( "CREATE INDEX rides_ridemodel_manufacturer_category_idx ON rides_ridemodel (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL;", reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_manufacturer_category_idx;" ), - + migrations.RunSQL( "CREATE INDEX rides_ridemodel_name_trgm_idx ON rides_ridemodel USING gin(name gin_trgm_ops);", reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_name_trgm_idx;" ), - + # Index for company role-based filtering migrations.RunSQL( "CREATE INDEX rides_company_manufacturer_role_idx ON rides_company USING gin(roles) WHERE 'MANUFACTURER' = ANY(roles);", reverse_sql="DROP INDEX IF EXISTS rides_company_manufacturer_role_idx;" ), - + migrations.RunSQL( "CREATE INDEX rides_company_designer_role_idx ON rides_company USING gin(roles) WHERE 'DESIGNER' = ANY(roles);", reverse_sql="DROP INDEX IF EXISTS rides_company_designer_role_idx;" ), - + # Ensure trigram extension is available for fuzzy search migrations.RunSQL( "CREATE EXTENSION IF NOT EXISTS pg_trgm;", diff --git a/backend/apps/rides/migrations/0021_alter_company_roles_alter_companyevent_roles_and_more.py b/backend/apps/rides/migrations/0021_alter_company_roles_alter_companyevent_roles_and_more.py index f4eea5f4..425e854e 100644 --- a/backend/apps/rides/migrations/0021_alter_company_roles_alter_companyevent_roles_and_more.py +++ b/backend/apps/rides/migrations/0021_alter_company_roles_alter_companyevent_roles_and_more.py @@ -1,9 +1,10 @@ # Generated by Django 5.2.5 on 2025-09-15 17:35 -import apps.core.choices.fields import django.contrib.postgres.fields from django.db import migrations, models +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/rides/migrations/0022_alter_company_roles_alter_companyevent_roles.py b/backend/apps/rides/migrations/0022_alter_company_roles_alter_companyevent_roles.py index 9b3029a6..d51862c2 100644 --- a/backend/apps/rides/migrations/0022_alter_company_roles_alter_companyevent_roles.py +++ b/backend/apps/rides/migrations/0022_alter_company_roles_alter_companyevent_roles.py @@ -1,9 +1,10 @@ # Generated by Django 5.2.5 on 2025-09-15 18:07 -import apps.core.choices.fields import django.contrib.postgres.fields from django.db import migrations +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/rides/migrations/0023_alter_ridemodelphoto_photo_type_and_more.py b/backend/apps/rides/migrations/0023_alter_ridemodelphoto_photo_type_and_more.py index b0ac80b6..7221ca2e 100644 --- a/backend/apps/rides/migrations/0023_alter_ridemodelphoto_photo_type_and_more.py +++ b/backend/apps/rides/migrations/0023_alter_ridemodelphoto_photo_type_and_more.py @@ -1,8 +1,9 @@ # Generated by Django 5.2.5 on 2025-09-15 19:06 -import apps.core.choices.fields from django.db import migrations +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/rides/migrations/0024_rename_launch_type_to_propulsion_system.py b/backend/apps/rides/migrations/0024_rename_launch_type_to_propulsion_system.py index 5e953e25..7bee6024 100644 --- a/backend/apps/rides/migrations/0024_rename_launch_type_to_propulsion_system.py +++ b/backend/apps/rides/migrations/0024_rename_launch_type_to_propulsion_system.py @@ -1,10 +1,11 @@ # Generated by Django 5.2.5 on 2025-09-17 01:25 -import apps.core.choices.fields import pgtrigger.compiler import pgtrigger.migrations from django.db import migrations +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/rides/migrations/0025_convert_ride_status_to_fsm.py b/backend/apps/rides/migrations/0025_convert_ride_status_to_fsm.py index 1776bc35..7220bda1 100644 --- a/backend/apps/rides/migrations/0025_convert_ride_status_to_fsm.py +++ b/backend/apps/rides/migrations/0025_convert_ride_status_to_fsm.py @@ -1,9 +1,10 @@ # Generated by Django 5.1.3 on 2025-12-21 03:20 -import apps.core.state_machine.fields import django.db.models.deletion from django.db import migrations, models +import apps.core.state_machine.fields + class Migration(migrations.Migration): diff --git a/backend/apps/rides/migrations/0027_alter_company_options_alter_rankingsnapshot_options_and_more.py b/backend/apps/rides/migrations/0027_alter_company_options_alter_rankingsnapshot_options_and_more.py index 84ae7e0a..759a2659 100644 --- a/backend/apps/rides/migrations/0027_alter_company_options_alter_rankingsnapshot_options_and_more.py +++ b/backend/apps/rides/migrations/0027_alter_company_options_alter_rankingsnapshot_options_and_more.py @@ -1,10 +1,11 @@ # Generated by Django 5.1.6 on 2025-12-26 14:10 -import apps.core.choices.fields import django.contrib.postgres.fields import django.db.models.deletion from django.db import migrations, models +import apps.core.choices.fields + class Migration(migrations.Migration): diff --git a/backend/apps/rides/migrations/0029_darkridestats_darkridestatsevent_flatridestats_and_more.py b/backend/apps/rides/migrations/0029_darkridestats_darkridestatsevent_flatridestats_and_more.py new file mode 100644 index 00000000..1fdbfd98 --- /dev/null +++ b/backend/apps/rides/migrations/0029_darkridestats_darkridestatsevent_flatridestats_and_more.py @@ -0,0 +1,533 @@ +# Generated by Django 5.2.9 on 2025-12-27 20:58 + +import django.db.models.deletion +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pghistory", "0007_auto_20250421_0444"), + ("rides", "0028_ridecredit_ridecreditevent_ridecredit_insert_insert_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DarkRideStats", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("scene_count", models.PositiveIntegerField(default=0, help_text="Number of themed scenes")), + ( + "animatronic_count", + models.PositiveIntegerField(default=0, help_text="Number of animatronic figures"), + ), + ( + "has_projection_technology", + models.BooleanField(default=False, help_text="Whether the ride uses projection mapping or screens"), + ), + ( + "is_interactive", + models.BooleanField( + default=False, help_text="Whether riders can interact with elements (shooting, etc.)" + ), + ), + ( + "ride_system", + models.CharField( + blank=True, + help_text="Type of ride system (Omnimover, Trackless, Classic Track, etc.)", + max_length=100, + ), + ), + ( + "uses_practical_effects", + models.BooleanField(default=True, help_text="Whether the ride uses practical/physical effects"), + ), + ( + "uses_motion_base", + models.BooleanField(default=False, help_text="Whether vehicles have motion simulation capability"), + ), + ( + "ride", + models.OneToOneField( + help_text="Ride these dark ride statistics belong to", + on_delete=django.db.models.deletion.CASCADE, + related_name="dark_stats", + to="rides.ride", + ), + ), + ], + options={ + "verbose_name": "Dark Ride Statistics", + "verbose_name_plural": "Dark Ride Statistics", + "ordering": ["ride"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="DarkRideStatsEvent", + fields=[ + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), + ("pgh_created_at", models.DateTimeField(auto_now_add=True)), + ("pgh_label", models.TextField(help_text="The event label.")), + ("id", models.BigIntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("scene_count", models.PositiveIntegerField(default=0, help_text="Number of themed scenes")), + ( + "animatronic_count", + models.PositiveIntegerField(default=0, help_text="Number of animatronic figures"), + ), + ( + "has_projection_technology", + models.BooleanField(default=False, help_text="Whether the ride uses projection mapping or screens"), + ), + ( + "is_interactive", + models.BooleanField( + default=False, help_text="Whether riders can interact with elements (shooting, etc.)" + ), + ), + ( + "ride_system", + models.CharField( + blank=True, + help_text="Type of ride system (Omnimover, Trackless, Classic Track, etc.)", + max_length=100, + ), + ), + ( + "uses_practical_effects", + models.BooleanField(default=True, help_text="Whether the ride uses practical/physical effects"), + ), + ( + "uses_motion_base", + models.BooleanField(default=False, help_text="Whether vehicles have motion simulation capability"), + ), + ( + "pgh_context", + models.ForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="pghistory.context", + ), + ), + ( + "pgh_obj", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="events", + to="rides.darkridestats", + ), + ), + ( + "ride", + models.ForeignKey( + db_constraint=False, + help_text="Ride these dark ride statistics belong to", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="rides.ride", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="FlatRideStats", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "max_height_ft", + models.DecimalField( + blank=True, decimal_places=2, help_text="Maximum ride height in feet", max_digits=6, null=True + ), + ), + ( + "rotation_speed_rpm", + models.DecimalField( + blank=True, decimal_places=2, help_text="Maximum rotation speed in RPM", max_digits=5, null=True + ), + ), + ( + "swing_angle_degrees", + models.PositiveIntegerField( + blank=True, help_text="Maximum swing angle in degrees (for swinging rides)", null=True + ), + ), + ( + "motion_type", + models.CharField( + choices=[ + ("SPINNING", "Spinning"), + ("SWINGING", "Swinging"), + ("BOUNCING", "Bouncing"), + ("ROTATING", "Rotating"), + ("DROPPING", "Dropping"), + ("MIXED", "Mixed Motion"), + ], + default="SPINNING", + help_text="Primary type of motion", + max_length=20, + ), + ), + ("arm_count", models.PositiveIntegerField(blank=True, help_text="Number of arms/gondolas", null=True)), + ( + "seats_per_gondola", + models.PositiveIntegerField(blank=True, help_text="Number of seats per gondola/arm", null=True), + ), + ( + "max_g_force", + models.DecimalField( + blank=True, decimal_places=2, help_text="Maximum G-force experienced", max_digits=4, null=True + ), + ), + ( + "ride", + models.OneToOneField( + help_text="Ride these flat ride statistics belong to", + on_delete=django.db.models.deletion.CASCADE, + related_name="flat_stats", + to="rides.ride", + ), + ), + ], + options={ + "verbose_name": "Flat Ride Statistics", + "verbose_name_plural": "Flat Ride Statistics", + "ordering": ["ride"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="FlatRideStatsEvent", + fields=[ + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), + ("pgh_created_at", models.DateTimeField(auto_now_add=True)), + ("pgh_label", models.TextField(help_text="The event label.")), + ("id", models.BigIntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "max_height_ft", + models.DecimalField( + blank=True, decimal_places=2, help_text="Maximum ride height in feet", max_digits=6, null=True + ), + ), + ( + "rotation_speed_rpm", + models.DecimalField( + blank=True, decimal_places=2, help_text="Maximum rotation speed in RPM", max_digits=5, null=True + ), + ), + ( + "swing_angle_degrees", + models.PositiveIntegerField( + blank=True, help_text="Maximum swing angle in degrees (for swinging rides)", null=True + ), + ), + ( + "motion_type", + models.CharField( + choices=[ + ("SPINNING", "Spinning"), + ("SWINGING", "Swinging"), + ("BOUNCING", "Bouncing"), + ("ROTATING", "Rotating"), + ("DROPPING", "Dropping"), + ("MIXED", "Mixed Motion"), + ], + default="SPINNING", + help_text="Primary type of motion", + max_length=20, + ), + ), + ("arm_count", models.PositiveIntegerField(blank=True, help_text="Number of arms/gondolas", null=True)), + ( + "seats_per_gondola", + models.PositiveIntegerField(blank=True, help_text="Number of seats per gondola/arm", null=True), + ), + ( + "max_g_force", + models.DecimalField( + blank=True, decimal_places=2, help_text="Maximum G-force experienced", max_digits=4, null=True + ), + ), + ( + "pgh_context", + models.ForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="pghistory.context", + ), + ), + ( + "pgh_obj", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="events", + to="rides.flatridestats", + ), + ), + ( + "ride", + models.ForeignKey( + db_constraint=False, + help_text="Ride these flat ride statistics belong to", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="rides.ride", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="WaterRideStats", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "wetness_level", + models.CharField( + choices=[ + ("DRY", "Dry - No water contact"), + ("MILD", "Mild - Light misting"), + ("MODERATE", "Moderate - Some splashing"), + ("SOAKING", "Soaking - Prepare to get drenched"), + ], + default="MODERATE", + help_text="How wet riders typically get", + max_length=10, + ), + ), + ( + "splash_height_ft", + models.DecimalField( + blank=True, decimal_places=2, help_text="Maximum splash height in feet", max_digits=5, null=True + ), + ), + ( + "has_splash_zone", + models.BooleanField( + default=False, help_text="Whether there is a designated splash zone for spectators" + ), + ), + ( + "boat_capacity", + models.PositiveIntegerField(blank=True, help_text="Number of riders per boat/raft", null=True), + ), + ( + "uses_flume", + models.BooleanField(default=False, help_text="Whether the ride uses a flume/log system"), + ), + ( + "rapids_sections", + models.PositiveIntegerField(default=0, help_text="Number of rapids/whitewater sections"), + ), + ( + "ride", + models.OneToOneField( + help_text="Ride these water statistics belong to", + on_delete=django.db.models.deletion.CASCADE, + related_name="water_stats", + to="rides.ride", + ), + ), + ], + options={ + "verbose_name": "Water Ride Statistics", + "verbose_name_plural": "Water Ride Statistics", + "ordering": ["ride"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="WaterRideStatsEvent", + fields=[ + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), + ("pgh_created_at", models.DateTimeField(auto_now_add=True)), + ("pgh_label", models.TextField(help_text="The event label.")), + ("id", models.BigIntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "wetness_level", + models.CharField( + choices=[ + ("DRY", "Dry - No water contact"), + ("MILD", "Mild - Light misting"), + ("MODERATE", "Moderate - Some splashing"), + ("SOAKING", "Soaking - Prepare to get drenched"), + ], + default="MODERATE", + help_text="How wet riders typically get", + max_length=10, + ), + ), + ( + "splash_height_ft", + models.DecimalField( + blank=True, decimal_places=2, help_text="Maximum splash height in feet", max_digits=5, null=True + ), + ), + ( + "has_splash_zone", + models.BooleanField( + default=False, help_text="Whether there is a designated splash zone for spectators" + ), + ), + ( + "boat_capacity", + models.PositiveIntegerField(blank=True, help_text="Number of riders per boat/raft", null=True), + ), + ( + "uses_flume", + models.BooleanField(default=False, help_text="Whether the ride uses a flume/log system"), + ), + ( + "rapids_sections", + models.PositiveIntegerField(default=0, help_text="Number of rapids/whitewater sections"), + ), + ( + "pgh_context", + models.ForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="pghistory.context", + ), + ), + ( + "pgh_obj", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="events", + to="rides.waterridestats", + ), + ), + ( + "ride", + models.ForeignKey( + db_constraint=False, + help_text="Ride these water statistics belong to", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="rides.ride", + ), + ), + ], + options={ + "abstract": False, + }, + ), + pgtrigger.migrations.AddTrigger( + model_name="darkridestats", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_darkridestatsevent" ("animatronic_count", "created_at", "has_projection_technology", "id", "is_interactive", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_system", "scene_count", "updated_at", "uses_motion_base", "uses_practical_effects") VALUES (NEW."animatronic_count", NEW."created_at", NEW."has_projection_technology", NEW."id", NEW."is_interactive", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."ride_system", NEW."scene_count", NEW."updated_at", NEW."uses_motion_base", NEW."uses_practical_effects"); RETURN NULL;', + hash="055ece465263a190b67dda7258c4bf26fa939107", + operation="INSERT", + pgid="pgtrigger_insert_insert_78d6d", + table="rides_darkridestats", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="darkridestats", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_darkridestatsevent" ("animatronic_count", "created_at", "has_projection_technology", "id", "is_interactive", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_system", "scene_count", "updated_at", "uses_motion_base", "uses_practical_effects") VALUES (NEW."animatronic_count", NEW."created_at", NEW."has_projection_technology", NEW."id", NEW."is_interactive", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."ride_system", NEW."scene_count", NEW."updated_at", NEW."uses_motion_base", NEW."uses_practical_effects"); RETURN NULL;', + hash="e7421fdd5dd3f044c5cc324db3e5d948e8f39afe", + operation="UPDATE", + pgid="pgtrigger_update_update_8eac1", + table="rides_darkridestats", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="flatridestats", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_flatridestatsevent" ("arm_count", "created_at", "id", "max_g_force", "max_height_ft", "motion_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "rotation_speed_rpm", "seats_per_gondola", "swing_angle_degrees", "updated_at") VALUES (NEW."arm_count", NEW."created_at", NEW."id", NEW."max_g_force", NEW."max_height_ft", NEW."motion_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."rotation_speed_rpm", NEW."seats_per_gondola", NEW."swing_angle_degrees", NEW."updated_at"); RETURN NULL;', + hash="04edef9ce6235f57c5b53f052a7fe392835f2971", + operation="INSERT", + pgid="pgtrigger_insert_insert_a589a", + table="rides_flatridestats", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="flatridestats", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_flatridestatsevent" ("arm_count", "created_at", "id", "max_g_force", "max_height_ft", "motion_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "rotation_speed_rpm", "seats_per_gondola", "swing_angle_degrees", "updated_at") VALUES (NEW."arm_count", NEW."created_at", NEW."id", NEW."max_g_force", NEW."max_height_ft", NEW."motion_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."rotation_speed_rpm", NEW."seats_per_gondola", NEW."swing_angle_degrees", NEW."updated_at"); RETURN NULL;', + hash="6f48930fae214744ad60cf8755ee0d50897d1040", + operation="UPDATE", + pgid="pgtrigger_update_update_f949f", + table="rides_flatridestats", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="waterridestats", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_waterridestatsevent" ("boat_capacity", "created_at", "has_splash_zone", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rapids_sections", "ride_id", "splash_height_ft", "updated_at", "uses_flume", "wetness_level") VALUES (NEW."boat_capacity", NEW."created_at", NEW."has_splash_zone", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rapids_sections", NEW."ride_id", NEW."splash_height_ft", NEW."updated_at", NEW."uses_flume", NEW."wetness_level"); RETURN NULL;', + hash="bb61031dc606f90bf9970f76417b30a72d8469ce", + operation="INSERT", + pgid="pgtrigger_insert_insert_fb731", + table="rides_waterridestats", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="waterridestats", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_waterridestatsevent" ("boat_capacity", "created_at", "has_splash_zone", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rapids_sections", "ride_id", "splash_height_ft", "updated_at", "uses_flume", "wetness_level") VALUES (NEW."boat_capacity", NEW."created_at", NEW."has_splash_zone", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rapids_sections", NEW."ride_id", NEW."splash_height_ft", NEW."updated_at", NEW."uses_flume", NEW."wetness_level"); RETURN NULL;', + hash="98404ec47c9b8d577f0a408a1182f9adffda7784", + operation="UPDATE", + pgid="pgtrigger_update_update_4f7a3", + table="rides_waterridestats", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/rides/mixins.py b/backend/apps/rides/mixins.py index f0bd6325..cf0901b5 100644 --- a/backend/apps/rides/mixins.py +++ b/backend/apps/rides/mixins.py @@ -5,7 +5,7 @@ This module contains mixins that provide reusable functionality for ride-related views, reducing code duplication. """ -from typing import Any, Dict +from typing import Any from django.contrib import messages @@ -20,7 +20,7 @@ class RideFormMixin: to handle new manufacturer, designer, and ride model suggestions. """ - def handle_entity_suggestions(self, form) -> Dict[str, Any]: + def handle_entity_suggestions(self, form) -> dict[str, Any]: """ Process new entity suggestions from form. diff --git a/backend/apps/rides/models/__init__.py b/backend/apps/rides/models/__init__.py index 5a260cd2..c968609a 100644 --- a/backend/apps/rides/models/__init__.py +++ b/backend/apps/rides/models/__init__.py @@ -8,19 +8,23 @@ The Company model is aliased as Manufacturer to clarify its role as ride manufac while maintaining backward compatibility through the Company alias. """ -from .rides import Ride, RideModel, RollerCoasterStats from .company import Company -from .location import RideLocation -from .reviews import RideReview -from .rankings import RideRanking, RidePairComparison, RankingSnapshot -from .media import RidePhoto from .credits import RideCredit +from .location import RideLocation +from .media import RidePhoto +from .rankings import RankingSnapshot, RidePairComparison, RideRanking +from .reviews import RideReview +from .rides import Ride, RideModel, RollerCoasterStats +from .stats import DarkRideStats, FlatRideStats, WaterRideStats __all__ = [ # Primary models "Ride", "RideModel", "RollerCoasterStats", + "WaterRideStats", + "DarkRideStats", + "FlatRideStats", "Company", "RideLocation", "RideReview", diff --git a/backend/apps/rides/models/company.py b/backend/apps/rides/models/company.py index 15d7bdcb..26f6b153 100644 --- a/backend/apps/rides/models/company.py +++ b/backend/apps/rides/models/company.py @@ -1,13 +1,13 @@ import pghistory +from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.db import models from django.urls import reverse from django.utils.text import slugify -from django.conf import settings +from apps.core.choices.fields import RichChoiceField from apps.core.history import HistoricalSlug from apps.core.models import TrackedModel -from apps.core.choices.fields import RichChoiceField @pghistory.track() diff --git a/backend/apps/rides/models/credits.py b/backend/apps/rides/models/credits.py index 45275dad..b3feb634 100644 --- a/backend/apps/rides/models/credits.py +++ b/backend/apps/rides/models/credits.py @@ -1,10 +1,11 @@ -from django.db import models -from django.conf import settings -from django.core.validators import MinValueValidator, MaxValueValidator import pghistory +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models from apps.core.history import TrackedModel + @pghistory.track() class RideCredit(TrackedModel): """ @@ -44,12 +45,15 @@ class RideCredit(TrackedModel): notes = models.TextField( blank=True, help_text="Personal notes about the experience" ) + display_order = models.PositiveIntegerField( + default=0, help_text="User-defined display order for drag-drop sorting" + ) class Meta(TrackedModel.Meta): verbose_name = "Ride Credit" verbose_name_plural = "Ride Credits" unique_together = ["user", "ride"] - ordering = ["-last_ridden_at", "-first_ridden_at", "-created_at"] + ordering = ["display_order", "-last_ridden_at", "-first_ridden_at", "-created_at"] def __str__(self): return f"{self.user} - {self.ride}" diff --git a/backend/apps/rides/models/location.py b/backend/apps/rides/models/location.py index 8cb2c4ce..19b60d0a 100644 --- a/backend/apps/rides/models/location.py +++ b/backend/apps/rides/models/location.py @@ -1,7 +1,7 @@ -from django.contrib.gis.db import models as gis_models -from django.db import models -from django.contrib.gis.geos import Point import pghistory +from django.contrib.gis.db import models as gis_models +from django.contrib.gis.geos import Point +from django.db import models @pghistory.track() diff --git a/backend/apps/rides/models/media.py b/backend/apps/rides/models/media.py index a7602b0f..5a603c01 100644 --- a/backend/apps/rides/models/media.py +++ b/backend/apps/rides/models/media.py @@ -4,13 +4,15 @@ Ride-specific media models for ThrillWiki. This module contains media models specific to rides domain. """ -from typing import Any, Optional, List, cast -from django.db import models -from django.conf import settings -from apps.core.history import TrackedModel -from apps.core.choices import RichChoiceField -from apps.core.services.media_service import MediaService +from typing import Any, cast + import pghistory +from django.conf import settings +from django.db import models + +from apps.core.choices import RichChoiceField +from apps.core.history import TrackedModel +from apps.core.services.media_service import MediaService def ride_photo_upload_path(instance: models.Model, filename: str) -> str: @@ -114,7 +116,7 @@ class RidePhoto(TrackedModel): super().save(*args, **kwargs) @property - def file_size(self) -> Optional[int]: + def file_size(self) -> int | None: """Get file size in bytes.""" try: return self.image.size @@ -122,7 +124,7 @@ class RidePhoto(TrackedModel): return None @property - def dimensions(self) -> Optional[List[int]]: + def dimensions(self) -> list[int] | None: """Get image dimensions as [width, height].""" try: return [self.image.width, self.image.height] diff --git a/backend/apps/rides/models/rankings.py b/backend/apps/rides/models/rankings.py index fd4d7371..0c82a99d 100644 --- a/backend/apps/rides/models/rankings.py +++ b/backend/apps/rides/models/rankings.py @@ -6,10 +6,10 @@ where each ride is compared to every other ride to determine which one more riders preferred. """ +import pghistory +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils import timezone -from django.core.validators import MinValueValidator, MaxValueValidator -import pghistory @pghistory.track() diff --git a/backend/apps/rides/models/reviews.py b/backend/apps/rides/models/reviews.py index 14bda863..2ba98d8b 100644 --- a/backend/apps/rides/models/reviews.py +++ b/backend/apps/rides/models/reviews.py @@ -1,8 +1,9 @@ +import pghistory +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import functions -from django.core.validators import MinValueValidator, MaxValueValidator + from apps.core.history import TrackedModel -import pghistory @pghistory.track() diff --git a/backend/apps/rides/models/rides.py b/backend/apps/rides/models/rides.py index 5027a78a..3e90d75f 100644 --- a/backend/apps/rides/models/rides.py +++ b/backend/apps/rides/models/rides.py @@ -1,14 +1,18 @@ +import contextlib +from typing import TYPE_CHECKING + +import pghistory +from django.contrib.auth.models import AbstractBaseUser +from django.core.exceptions import ValidationError from django.db import models from django.utils.text import slugify -from django.core.exceptions import ValidationError -from config.django import base as settings -from apps.core.models import TrackedModel + from apps.core.choices import RichChoiceField +from apps.core.models import TrackedModel from apps.core.state_machine import RichFSMField, StateMachineMixin +from config.django import base as settings + from .company import Company -import pghistory -from typing import TYPE_CHECKING, Optional -from django.contrib.auth.models import AbstractBaseUser if TYPE_CHECKING: from .rides import RollerCoasterStats @@ -498,7 +502,7 @@ class Ride(StateMachineMixin, TrackedModel): Note: The average_rating field is denormalized and refreshed by background jobs. Use selectors or annotations for real-time calculations if needed. """ - + if TYPE_CHECKING: coaster_stats: 'RollerCoasterStats' @@ -669,17 +673,17 @@ class Ride(StateMachineMixin, TrackedModel): return f"{self.name} at {self.park.name}" # FSM Transition Wrapper Methods - def open(self, *, user: Optional[AbstractBaseUser] = None) -> None: + def open(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to OPERATING status.""" self.transition_to_operating(user=user) self.save() - def close_temporarily(self, *, user: Optional[AbstractBaseUser] = None) -> None: + def close_temporarily(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to CLOSED_TEMP status.""" self.transition_to_closed_temp(user=user) self.save() - def mark_sbno(self, *, user: Optional[AbstractBaseUser] = None) -> None: + def mark_sbno(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to SBNO (Standing But Not Operating) status.""" self.transition_to_sbno(user=user) self.save() @@ -689,7 +693,7 @@ class Ride(StateMachineMixin, TrackedModel): *, closing_date, post_closing_status: str, - user: Optional[AbstractBaseUser] = None, + user: AbstractBaseUser | None = None, ) -> None: """Transition ride to CLOSING status with closing date and target status.""" from django.core.exceptions import ValidationError @@ -703,25 +707,25 @@ class Ride(StateMachineMixin, TrackedModel): self.post_closing_status = post_closing_status self.save() - def close_permanently(self, *, user: Optional[AbstractBaseUser] = None) -> None: + def close_permanently(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to CLOSED_PERM status.""" self.transition_to_closed_perm(user=user) self.save() - def demolish(self, *, user: Optional[AbstractBaseUser] = None) -> None: + def demolish(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to DEMOLISHED status.""" self.transition_to_demolished(user=user) self.save() - def relocate(self, *, user: Optional[AbstractBaseUser] = None) -> None: + def relocate(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to RELOCATED status.""" self.transition_to_relocated(user=user) self.save() - def apply_post_closing_status(self, *, user: Optional[AbstractBaseUser] = None) -> None: + def apply_post_closing_status(self, *, user: AbstractBaseUser | None = None) -> None: """Apply post_closing_status if closing_date has been reached.""" - from django.utils import timezone from django.core.exceptions import ValidationError + from django.utils import timezone if self.status != "CLOSING": raise ValidationError("Ride must be in CLOSING status") @@ -757,10 +761,8 @@ class Ride(StateMachineMixin, TrackedModel): # Check for slug conflicts when park changes or slug is new original_ride = None if self.pk: - try: + with contextlib.suppress(Ride.DoesNotExist): original_ride = Ride.objects.get(pk=self.pk) - except Ride.DoesNotExist: - pass # If park changed or this is a new ride, ensure slug uniqueness within the park park_changed = original_ride and original_ride.park.id != self.park.id @@ -805,13 +807,13 @@ class Ride(StateMachineMixin, TrackedModel): # Build comprehensive search text search_parts = [] - + # Basic ride info if self.name: search_parts.append(self.name) if self.description: search_parts.append(self.description) - + # Park info if self.park: search_parts.append(self.park.name) @@ -822,39 +824,39 @@ class Ride(StateMachineMixin, TrackedModel): search_parts.append(self.park.location.state) if self.park.location.country: search_parts.append(self.park.location.country) - + # Park area if self.park_area: search_parts.append(self.park_area.name) - + # Category if self.category: category_choice = self.get_category_rich_choice() if category_choice: search_parts.append(category_choice.label) - + # Status if self.status: status_choice = self.get_status_rich_choice() if status_choice: search_parts.append(status_choice.label) - + # Companies if self.manufacturer: search_parts.append(self.manufacturer.name) if self.designer: search_parts.append(self.designer.name) - + # Ride model if self.ride_model: search_parts.append(self.ride_model.name) if self.ride_model.manufacturer: search_parts.append(self.ride_model.manufacturer.name) - + # Roller coaster stats if available try: - if hasattr(self, 'coaster_stats') and self.coaster_stats: - stats = self.coaster_stats + if hasattr(self, 'coaster_stats') and self.coaster_stats: + stats = self.coaster_stats if stats.track_type: search_parts.append(stats.track_type) if stats.track_material: @@ -874,7 +876,7 @@ class Ride(StateMachineMixin, TrackedModel): except Exception: # Ignore if coaster_stats doesn't exist or has issues pass - + self.search_text = ' '.join(filter(None, search_parts)).lower() def _ensure_unique_slug_in_park(self) -> None: @@ -947,6 +949,7 @@ class Ride(StateMachineMixin, TrackedModel): def get_by_slug(cls, slug: str, park=None) -> tuple["Ride", bool]: """Get ride by current or historical slug, optionally within a specific park""" from django.contrib.contenttypes.models import ContentType + from apps.core.history import HistoricalSlug # Build base query @@ -975,7 +978,7 @@ class Ride(StateMachineMixin, TrackedModel): event_model = getattr(cls, "event_model", None) if event_model: historical_events = event_model.objects.filter(slug=slug).order_by("-pgh_created_at") - + for historical_event in historical_events: try: ride = base_query.get(pk=historical_event.pgh_obj_id) diff --git a/backend/apps/rides/models/stats.py b/backend/apps/rides/models/stats.py new file mode 100644 index 00000000..6b12acbc --- /dev/null +++ b/backend/apps/rides/models/stats.py @@ -0,0 +1,226 @@ +""" +Ride-Specific Statistics Models + +This module contains specialized statistics models for different ride categories: +- WaterRideStats: For water rides (WR) +- DarkRideStats: For dark rides (DR) +- FlatRideStats: For flat rides (FR) + +These complement the existing RollerCoasterStats model in rides.py. +""" + +import pghistory +from django.db import models + +from apps.core.history import TrackedModel + +# Wetness Level Choices for Water Rides +WETNESS_LEVELS = [ + ("DRY", "Dry - No water contact"), + ("MILD", "Mild - Light misting"), + ("MODERATE", "Moderate - Some splashing"), + ("SOAKING", "Soaking - Prepare to get drenched"), +] + +# Motion Type Choices for Flat Rides +MOTION_TYPES = [ + ("SPINNING", "Spinning"), + ("SWINGING", "Swinging"), + ("BOUNCING", "Bouncing"), + ("ROTATING", "Rotating"), + ("DROPPING", "Dropping"), + ("MIXED", "Mixed Motion"), +] + + +@pghistory.track() +class WaterRideStats(TrackedModel): + """ + Statistics specific to water rides (category=WR). + + Tracks water-related attributes like wetness level and splash characteristics. + """ + + ride = models.OneToOneField( + "rides.Ride", + on_delete=models.CASCADE, + related_name="water_stats", + help_text="Ride these water statistics belong to", + ) + + wetness_level = models.CharField( + max_length=10, + choices=WETNESS_LEVELS, + default="MODERATE", + help_text="How wet riders typically get", + ) + + splash_height_ft = models.DecimalField( + max_digits=5, + decimal_places=2, + null=True, + blank=True, + help_text="Maximum splash height in feet", + ) + + has_splash_zone = models.BooleanField( + default=False, + help_text="Whether there is a designated splash zone for spectators", + ) + + boat_capacity = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Number of riders per boat/raft", + ) + + uses_flume = models.BooleanField( + default=False, + help_text="Whether the ride uses a flume/log system", + ) + + rapids_sections = models.PositiveIntegerField( + default=0, + help_text="Number of rapids/whitewater sections", + ) + + class Meta(TrackedModel.Meta): + verbose_name = "Water Ride Statistics" + verbose_name_plural = "Water Ride Statistics" + ordering = ["ride"] + + def __str__(self) -> str: + return f"Water Stats for {self.ride.name}" + + +@pghistory.track() +class DarkRideStats(TrackedModel): + """ + Statistics specific to dark rides (category=DR). + + Tracks theming elements like scenes, animatronics, and technology. + """ + + ride = models.OneToOneField( + "rides.Ride", + on_delete=models.CASCADE, + related_name="dark_stats", + help_text="Ride these dark ride statistics belong to", + ) + + scene_count = models.PositiveIntegerField( + default=0, + help_text="Number of themed scenes", + ) + + animatronic_count = models.PositiveIntegerField( + default=0, + help_text="Number of animatronic figures", + ) + + has_projection_technology = models.BooleanField( + default=False, + help_text="Whether the ride uses projection mapping or screens", + ) + + is_interactive = models.BooleanField( + default=False, + help_text="Whether riders can interact with elements (shooting, etc.)", + ) + + ride_system = models.CharField( + max_length=100, + blank=True, + help_text="Type of ride system (Omnimover, Trackless, Classic Track, etc.)", + ) + + uses_practical_effects = models.BooleanField( + default=True, + help_text="Whether the ride uses practical/physical effects", + ) + + uses_motion_base = models.BooleanField( + default=False, + help_text="Whether vehicles have motion simulation capability", + ) + + class Meta(TrackedModel.Meta): + verbose_name = "Dark Ride Statistics" + verbose_name_plural = "Dark Ride Statistics" + ordering = ["ride"] + + def __str__(self) -> str: + return f"Dark Ride Stats for {self.ride.name}" + + +@pghistory.track() +class FlatRideStats(TrackedModel): + """ + Statistics specific to flat rides (category=FR). + + Tracks motion characteristics like rotation, swing angles, and height. + """ + + ride = models.OneToOneField( + "rides.Ride", + on_delete=models.CASCADE, + related_name="flat_stats", + help_text="Ride these flat ride statistics belong to", + ) + + max_height_ft = models.DecimalField( + max_digits=6, + decimal_places=2, + null=True, + blank=True, + help_text="Maximum ride height in feet", + ) + + rotation_speed_rpm = models.DecimalField( + max_digits=5, + decimal_places=2, + null=True, + blank=True, + help_text="Maximum rotation speed in RPM", + ) + + swing_angle_degrees = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Maximum swing angle in degrees (for swinging rides)", + ) + + motion_type = models.CharField( + max_length=20, + choices=MOTION_TYPES, + default="SPINNING", + help_text="Primary type of motion", + ) + + arm_count = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Number of arms/gondolas", + ) + + seats_per_gondola = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Number of seats per gondola/arm", + ) + + max_g_force = models.DecimalField( + max_digits=4, + decimal_places=2, + null=True, + blank=True, + help_text="Maximum G-force experienced", + ) + + class Meta(TrackedModel.Meta): + verbose_name = "Flat Ride Statistics" + verbose_name_plural = "Flat Ride Statistics" + ordering = ["ride"] + + def __str__(self) -> str: + return f"Flat Ride Stats for {self.ride.name}" diff --git a/backend/apps/rides/park_urls.py b/backend/apps/rides/park_urls.py index 92fb3821..48a780d3 100644 --- a/backend/apps/rides/park_urls.py +++ b/backend/apps/rides/park_urls.py @@ -1,4 +1,5 @@ from django.urls import path + from . import views app_name = "rides" diff --git a/backend/apps/rides/selectors.py b/backend/apps/rides/selectors.py index f2519841..1e778a31 100644 --- a/backend/apps/rides/selectors.py +++ b/backend/apps/rides/selectors.py @@ -3,17 +3,18 @@ Selectors for ride-related data retrieval. Following Django styleguide pattern for separating data access from business logic. """ -from typing import Optional, Dict, Any -from django.db.models import QuerySet, Q, Count, Avg, Prefetch +from typing import Any + from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance +from django.db.models import Avg, Count, Prefetch, Q, QuerySet -from .models import Ride, RideModel, RideReview from .choices import RIDE_CATEGORIES +from .models import Ride, RideModel, RideReview def ride_list_for_display( - *, filters: Optional[Dict[str, Any]] = None + *, filters: dict[str, Any] | None = None ) -> QuerySet[Ride]: """ Get rides optimized for list display with related data. @@ -246,9 +247,10 @@ def rides_with_recent_reviews(*, days: int = 30) -> QuerySet[Ride]: Returns: QuerySet of rides with recent reviews """ - from django.utils import timezone from datetime import timedelta + from django.utils import timezone + cutoff_date = timezone.now() - timedelta(days=days) return ( @@ -267,7 +269,7 @@ def rides_with_recent_reviews(*, days: int = 30) -> QuerySet[Ride]: ) -def ride_statistics_by_category() -> Dict[str, Any]: +def ride_statistics_by_category() -> dict[str, Any]: """ Get ride statistics grouped by category. diff --git a/backend/apps/rides/services/__init__.py b/backend/apps/rides/services/__init__.py index 3cb7b0d8..fcae25c3 100644 --- a/backend/apps/rides/services/__init__.py +++ b/backend/apps/rides/services/__init__.py @@ -1,7 +1,7 @@ -from .location_service import RideLocationService -from .media_service import RideMediaService # Import from the services_core module (was services.py, renamed to avoid package collision) from ..services_core import RideService +from .location_service import RideLocationService +from .media_service import RideMediaService __all__ = ["RideLocationService", "RideMediaService", "RideService"] diff --git a/backend/apps/rides/services/hybrid_loader.py b/backend/apps/rides/services/hybrid_loader.py index 2dc9a974..86147ba5 100644 --- a/backend/apps/rides/services/hybrid_loader.py +++ b/backend/apps/rides/services/hybrid_loader.py @@ -17,11 +17,12 @@ Architecture: - Hybrid: Combine both approaches based on data characteristics """ -from typing import Dict, List, Any, Optional +import logging +from typing import Any + from django.core.cache import cache from django.db import models -from django.db.models import Q, Min, Max -import logging +from django.db.models import Max, Min, Q logger = logging.getLogger(__name__) @@ -29,34 +30,34 @@ logger = logging.getLogger(__name__) class SmartRideLoader: """ Intelligent ride data loader that chooses optimal filtering strategy. - + Strategy Selection: - ≤200 total records: Client-side filtering (load all data) - >200 total records: Server-side filtering (database filtering + pagination) - + Features: - Progressive loading for large datasets - 5-minute intelligent caching - Comprehensive filter metadata - Optimized queries with prefetch_related """ - + # Configuration constants INITIAL_LOAD_SIZE = 50 PROGRESSIVE_LOAD_SIZE = 25 MAX_CLIENT_SIDE_RECORDS = 200 CACHE_TIMEOUT = 300 # 5 minutes - + def __init__(self): self.cache_prefix = "rides_hybrid_" - - def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + + def get_initial_load(self, filters: dict[str, Any] | None = None) -> dict[str, Any]: """ Get initial data load with automatic strategy selection. - + Args: filters: Optional filter parameters - + Returns: Dict containing: - strategy: 'client_side' or 'server_side' @@ -65,63 +66,63 @@ class SmartRideLoader: - has_more: Whether more data is available - filter_metadata: Available filter options """ - + # Get total count for strategy decision total_count = self._get_total_count(filters) - + # Choose strategy based on total count if total_count <= self.MAX_CLIENT_SIDE_RECORDS: return self._get_client_side_data(filters, total_count) else: return self._get_server_side_data(filters, total_count) - - def get_progressive_load(self, offset: int, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + + def get_progressive_load(self, offset: int, filters: dict[str, Any] | None = None) -> dict[str, Any]: """ Get additional data for progressive loading (server-side strategy only). - + Args: offset: Number of records to skip filters: Filter parameters - + Returns: Dict containing additional ride records """ - + # Build queryset with filters queryset = self._build_filtered_queryset(filters) - + # Get total count for this filtered set total_count = queryset.count() - + # Get progressive batch rides = list(queryset[offset:offset + self.PROGRESSIVE_LOAD_SIZE]) - + return { 'rides': self._serialize_rides(rides), 'total_count': total_count, 'has_more': len(rides) == self.PROGRESSIVE_LOAD_SIZE, 'next_offset': offset + len(rides) if len(rides) == self.PROGRESSIVE_LOAD_SIZE else None } - - def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + + def get_filter_metadata(self, filters: dict[str, Any] | None = None) -> dict[str, Any]: """ Get comprehensive filter metadata for dynamic filter generation. - + Args: filters: Optional filters to scope the metadata - + Returns: Dict containing all available filter options and ranges """ cache_key = f"{self.cache_prefix}filter_metadata_{hash(str(filters))}" metadata = cache.get(cache_key) - + if metadata is None: metadata = self._generate_filter_metadata(filters) cache.set(cache_key, metadata, self.CACHE_TIMEOUT) - + return metadata - + def invalidate_cache(self) -> None: """Invalidate all cached data for rides.""" # Note: In production, you might want to use cache versioning @@ -131,31 +132,31 @@ class SmartRideLoader: f"{self.cache_prefix}filter_metadata", f"{self.cache_prefix}total_count", ] - + for key in cache_keys: cache.delete(key) - - def _get_total_count(self, filters: Optional[Dict[str, Any]] = None) -> int: + + def _get_total_count(self, filters: dict[str, Any] | None = None) -> int: """Get total count of rides matching filters.""" cache_key = f"{self.cache_prefix}total_count_{hash(str(filters))}" count = cache.get(cache_key) - + if count is None: queryset = self._build_filtered_queryset(filters) count = queryset.count() cache.set(cache_key, count, self.CACHE_TIMEOUT) - + return count - - def _get_client_side_data(self, filters: Optional[Dict[str, Any]], - total_count: int) -> Dict[str, Any]: + + def _get_client_side_data(self, filters: dict[str, Any] | None, + total_count: int) -> dict[str, Any]: """Get all data for client-side filtering.""" cache_key = f"{self.cache_prefix}client_side_all" cached_data = cache.get(cache_key) - + if cached_data is None: from apps.rides.models import Ride - + # Load all rides with optimized query queryset = Ride.objects.select_related( 'park', @@ -168,11 +169,11 @@ class SmartRideLoader: ).prefetch_related( 'coaster_stats' ).order_by('name') - + rides = list(queryset) cached_data = self._serialize_rides(rides) cache.set(cache_key, cached_data, self.CACHE_TIMEOUT) - + return { 'strategy': 'client_side', 'rides': cached_data, @@ -180,16 +181,16 @@ class SmartRideLoader: 'has_more': False, 'filter_metadata': self.get_filter_metadata(filters) } - - def _get_server_side_data(self, filters: Optional[Dict[str, Any]], - total_count: int) -> Dict[str, Any]: + + def _get_server_side_data(self, filters: dict[str, Any] | None, + total_count: int) -> dict[str, Any]: """Get initial batch for server-side filtering.""" # Build filtered queryset queryset = self._build_filtered_queryset(filters) - + # Get initial batch rides = list(queryset[:self.INITIAL_LOAD_SIZE]) - + return { 'strategy': 'server_side', 'rides': self._serialize_rides(rides), @@ -197,11 +198,11 @@ class SmartRideLoader: 'has_more': len(rides) == self.INITIAL_LOAD_SIZE, 'next_offset': len(rides) if len(rides) == self.INITIAL_LOAD_SIZE else None } - - def _build_filtered_queryset(self, filters: Optional[Dict[str, Any]]): + + def _build_filtered_queryset(self, filters: dict[str, Any] | None): """Build Django queryset with applied filters.""" from apps.rides.models import Ride - + # Start with optimized base queryset queryset = Ride.objects.select_related( 'park', @@ -214,115 +215,115 @@ class SmartRideLoader: ).prefetch_related( 'coaster_stats' ) - + if not filters: return queryset.order_by('name') - + # Apply filters q_objects = Q() - + # Text search using computed search_text field if 'search' in filters and filters['search']: search_term = filters['search'].lower() q_objects &= Q(search_text__icontains=search_term) - + # Park filters if 'park_slug' in filters and filters['park_slug']: q_objects &= Q(park__slug=filters['park_slug']) - + if 'park_id' in filters and filters['park_id']: q_objects &= Q(park_id=filters['park_id']) - + # Category filters if 'category' in filters and filters['category']: q_objects &= Q(category__in=filters['category']) - + # Status filters if 'status' in filters and filters['status']: q_objects &= Q(status__in=filters['status']) - + # Company filters if 'manufacturer_ids' in filters and filters['manufacturer_ids']: q_objects &= Q(manufacturer_id__in=filters['manufacturer_ids']) - + if 'designer_ids' in filters and filters['designer_ids']: q_objects &= Q(designer_id__in=filters['designer_ids']) - + # Ride model filters if 'ride_model_ids' in filters and filters['ride_model_ids']: q_objects &= Q(ride_model_id__in=filters['ride_model_ids']) - + # Opening year filters using computed opening_year field if 'opening_year' in filters and filters['opening_year']: q_objects &= Q(opening_year=filters['opening_year']) - + if 'min_opening_year' in filters and filters['min_opening_year']: q_objects &= Q(opening_year__gte=filters['min_opening_year']) - + if 'max_opening_year' in filters and filters['max_opening_year']: q_objects &= Q(opening_year__lte=filters['max_opening_year']) - + # Rating filters if 'min_rating' in filters and filters['min_rating']: q_objects &= Q(average_rating__gte=filters['min_rating']) - + if 'max_rating' in filters and filters['max_rating']: q_objects &= Q(average_rating__lte=filters['max_rating']) - + # Height requirement filters if 'min_height_requirement' in filters and filters['min_height_requirement']: q_objects &= Q(min_height_in__gte=filters['min_height_requirement']) - + if 'max_height_requirement' in filters and filters['max_height_requirement']: q_objects &= Q(max_height_in__lte=filters['max_height_requirement']) - + # Capacity filters if 'min_capacity' in filters and filters['min_capacity']: q_objects &= Q(capacity_per_hour__gte=filters['min_capacity']) - + if 'max_capacity' in filters and filters['max_capacity']: q_objects &= Q(capacity_per_hour__lte=filters['max_capacity']) - + # Roller coaster specific filters if 'roller_coaster_type' in filters and filters['roller_coaster_type']: q_objects &= Q(coaster_stats__roller_coaster_type__in=filters['roller_coaster_type']) - + if 'track_material' in filters and filters['track_material']: q_objects &= Q(coaster_stats__track_material__in=filters['track_material']) - + if 'propulsion_system' in filters and filters['propulsion_system']: q_objects &= Q(coaster_stats__propulsion_system__in=filters['propulsion_system']) - + # Roller coaster height filters if 'min_height_ft' in filters and filters['min_height_ft']: q_objects &= Q(coaster_stats__height_ft__gte=filters['min_height_ft']) - + if 'max_height_ft' in filters and filters['max_height_ft']: q_objects &= Q(coaster_stats__height_ft__lte=filters['max_height_ft']) - + # Roller coaster speed filters if 'min_speed_mph' in filters and filters['min_speed_mph']: q_objects &= Q(coaster_stats__speed_mph__gte=filters['min_speed_mph']) - + if 'max_speed_mph' in filters and filters['max_speed_mph']: q_objects &= Q(coaster_stats__speed_mph__lte=filters['max_speed_mph']) - + # Inversion filters if 'min_inversions' in filters and filters['min_inversions']: q_objects &= Q(coaster_stats__inversions__gte=filters['min_inversions']) - + if 'max_inversions' in filters and filters['max_inversions']: q_objects &= Q(coaster_stats__inversions__lte=filters['max_inversions']) - + if 'has_inversions' in filters and filters['has_inversions'] is not None: if filters['has_inversions']: q_objects &= Q(coaster_stats__inversions__gt=0) else: q_objects &= Q(coaster_stats__inversions=0) - + # Apply filters and ordering queryset = queryset.filter(q_objects) - + # Apply ordering ordering = filters.get('ordering', 'name') if ordering in ['height_ft', '-height_ft', 'speed_mph', '-speed_mph']: @@ -331,13 +332,13 @@ class SmartRideLoader: queryset = queryset.order_by(ordering_field) else: queryset = queryset.order_by(ordering) - + return queryset - - def _serialize_rides(self, rides: List) -> List[Dict[str, Any]]: + + def _serialize_rides(self, rides: list) -> list[dict[str, Any]]: """Serialize ride objects to dictionaries.""" serialized = [] - + for ride in rides: # Basic ride data ride_data = { @@ -360,7 +361,7 @@ class SmartRideLoader: 'created_at': ride.created_at.isoformat(), 'updated_at': ride.updated_at.isoformat(), } - + # Park data if ride.park: ride_data['park'] = { @@ -368,7 +369,7 @@ class SmartRideLoader: 'name': ride.park.name, 'slug': ride.park.slug, } - + # Park location data if hasattr(ride.park, 'location') and ride.park.location: ride_data['park']['location'] = { @@ -376,7 +377,7 @@ class SmartRideLoader: 'state': ride.park.location.state, 'country': ride.park.location.country, } - + # Park area data if ride.park_area: ride_data['park_area'] = { @@ -384,7 +385,7 @@ class SmartRideLoader: 'name': ride.park_area.name, 'slug': ride.park_area.slug, } - + # Company data if ride.manufacturer: ride_data['manufacturer'] = { @@ -392,14 +393,14 @@ class SmartRideLoader: 'name': ride.manufacturer.name, 'slug': ride.manufacturer.slug, } - + if ride.designer: ride_data['designer'] = { 'id': ride.designer.id, 'name': ride.designer.name, 'slug': ride.designer.slug, } - + # Ride model data if ride.ride_model: ride_data['ride_model'] = { @@ -408,14 +409,14 @@ class SmartRideLoader: 'slug': ride.ride_model.slug, 'category': ride.ride_model.category, } - + if ride.ride_model.manufacturer: ride_data['ride_model']['manufacturer'] = { 'id': ride.ride_model.manufacturer.id, 'name': ride.ride_model.manufacturer.name, 'slug': ride.ride_model.manufacturer.slug, } - + # Roller coaster stats if hasattr(ride, 'coaster_stats') and ride.coaster_stats: stats = ride.coaster_stats @@ -435,70 +436,70 @@ class SmartRideLoader: 'cars_per_train': stats.cars_per_train, 'seats_per_car': stats.seats_per_car, } - + serialized.append(ride_data) - + return serialized - - def _generate_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + + def _generate_filter_metadata(self, filters: dict[str, Any] | None = None) -> dict[str, Any]: """Generate comprehensive filter metadata.""" from apps.rides.models import Ride, RideModel from apps.rides.models.company import Company from apps.rides.models.rides import RollerCoasterStats - + # Get unique values from database with counts parks_data = list(Ride.objects.exclude( park__isnull=True ).select_related('park').values( 'park__id', 'park__name', 'park__slug' ).annotate(count=models.Count('id')).distinct().order_by('park__name')) - + park_areas_data = list(Ride.objects.exclude( park_area__isnull=True ).select_related('park_area').values( 'park_area__id', 'park_area__name', 'park_area__slug' ).annotate(count=models.Count('id')).distinct().order_by('park_area__name')) - + manufacturers_data = list(Company.objects.filter( roles__contains=['MANUFACTURER'] ).values('id', 'name', 'slug').annotate( count=models.Count('manufactured_rides') ).order_by('name')) - + designers_data = list(Company.objects.filter( roles__contains=['DESIGNER'] ).values('id', 'name', 'slug').annotate( count=models.Count('designed_rides') ).order_by('name')) - + ride_models_data = list(RideModel.objects.select_related( 'manufacturer' ).values( 'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category' ).annotate(count=models.Count('rides')).order_by('manufacturer__name', 'name')) - + # Get categories and statuses with counts categories_data = list(Ride.objects.values('category').annotate( count=models.Count('id') ).order_by('category')) - + statuses_data = list(Ride.objects.values('status').annotate( count=models.Count('id') ).order_by('status')) - + # Get roller coaster specific data with counts rc_types_data = list(RollerCoasterStats.objects.values('roller_coaster_type').annotate( count=models.Count('ride') ).exclude(roller_coaster_type__isnull=True).order_by('roller_coaster_type')) - + track_materials_data = list(RollerCoasterStats.objects.values('track_material').annotate( count=models.Count('ride') ).exclude(track_material__isnull=True).order_by('track_material')) - + propulsion_systems_data = list(RollerCoasterStats.objects.values('propulsion_system').annotate( count=models.Count('ride') ).exclude(propulsion_system__isnull=True).order_by('propulsion_system')) - + # Convert to frontend-expected format with value/label/count categories = [ { @@ -508,7 +509,7 @@ class SmartRideLoader: } for item in categories_data ] - + statuses = [ { 'value': item['status'], @@ -517,7 +518,7 @@ class SmartRideLoader: } for item in statuses_data ] - + roller_coaster_types = [ { 'value': item['roller_coaster_type'], @@ -526,7 +527,7 @@ class SmartRideLoader: } for item in rc_types_data ] - + track_materials = [ { 'value': item['track_material'], @@ -535,7 +536,7 @@ class SmartRideLoader: } for item in track_materials_data ] - + propulsion_systems = [ { 'value': item['propulsion_system'], @@ -544,7 +545,7 @@ class SmartRideLoader: } for item in propulsion_systems_data ] - + # Convert other data to expected format parks = [ { @@ -554,7 +555,7 @@ class SmartRideLoader: } for item in parks_data ] - + park_areas = [ { 'value': str(item['park_area__id']), @@ -563,7 +564,7 @@ class SmartRideLoader: } for item in park_areas_data ] - + manufacturers = [ { 'value': str(item['id']), @@ -572,7 +573,7 @@ class SmartRideLoader: } for item in manufacturers_data ] - + designers = [ { 'value': str(item['id']), @@ -581,7 +582,7 @@ class SmartRideLoader: } for item in designers_data ] - + ride_models = [ { 'value': str(item['id']), @@ -590,7 +591,7 @@ class SmartRideLoader: } for item in ride_models_data ] - + # Calculate ranges from actual data ride_stats = Ride.objects.aggregate( min_rating=Min('average_rating'), @@ -604,7 +605,7 @@ class SmartRideLoader: min_year=Min('opening_year'), max_year=Max('opening_year'), ) - + # Calculate roller coaster specific ranges coaster_stats = RollerCoasterStats.objects.aggregate( min_height_ft=Min('height_ft'), @@ -626,7 +627,7 @@ class SmartRideLoader: min_seats=Min('seats_per_car'), max_seats=Max('seats_per_car'), ) - + return { 'categorical': { 'categories': categories, @@ -698,7 +699,7 @@ class SmartRideLoader: }, 'total_count': Ride.objects.count(), } - + def _get_category_label(self, category: str) -> str: """Convert category code to human-readable label.""" category_labels = { @@ -713,7 +714,7 @@ class SmartRideLoader: return category_labels[category] else: raise ValueError(f"Unknown ride category: {category}") - + def _get_status_label(self, status: str) -> str: """Convert status code to human-readable label.""" status_labels = { @@ -730,7 +731,7 @@ class SmartRideLoader: return status_labels[status] else: raise ValueError(f"Unknown ride status: {status}") - + def _get_rc_type_label(self, rc_type: str) -> str: """Convert roller coaster type to human-readable label.""" rc_type_labels = { @@ -752,7 +753,7 @@ class SmartRideLoader: return rc_type_labels[rc_type] else: raise ValueError(f"Unknown roller coaster type: {rc_type}") - + def _get_track_material_label(self, material: str) -> str: """Convert track material to human-readable label.""" material_labels = { @@ -764,7 +765,7 @@ class SmartRideLoader: return material_labels[material] else: raise ValueError(f"Unknown track material: {material}") - + def _get_propulsion_system_label(self, propulsion_system: str) -> str: """Convert propulsion system to human-readable label.""" propulsion_labels = { diff --git a/backend/apps/rides/services/location_service.py b/backend/apps/rides/services/location_service.py index 9b1e0714..745cd0bb 100644 --- a/backend/apps/rides/services/location_service.py +++ b/backend/apps/rides/services/location_service.py @@ -3,10 +3,11 @@ Rides-specific location services with OpenStreetMap integration. Handles location management for individual rides within parks. """ -import requests -from typing import List, Dict, Any, Optional -from django.db import transaction import logging +from typing import Any + +import requests +from django.db import transaction from ..models import RideLocation @@ -27,8 +28,8 @@ class RideLocationService: cls, *, ride, - latitude: Optional[float] = None, - longitude: Optional[float] = None, + latitude: float | None = None, + longitude: float | None = None, park_area: str = "", notes: str = "", entrance_notes: str = "", @@ -101,7 +102,7 @@ class RideLocationService: return ride_location @classmethod - def find_rides_in_area(cls, park, park_area: str) -> List[RideLocation]: + def find_rides_in_area(cls, park, park_area: str) -> list[RideLocation]: """ Find all rides in a specific park area. @@ -121,7 +122,7 @@ class RideLocationService: @classmethod def find_nearby_rides( cls, latitude: float, longitude: float, park=None, radius_meters: float = 500 - ) -> List[RideLocation]: + ) -> list[RideLocation]: """ Find rides near given coordinates using PostGIS. Useful for finding rides near a specific location within a park. @@ -153,7 +154,7 @@ class RideLocationService: ) @classmethod - def get_ride_navigation_info(cls, ride_location: RideLocation) -> Dict[str, Any]: + def get_ride_navigation_info(cls, ride_location: RideLocation) -> dict[str, Any]: """ Get comprehensive navigation information for a ride. @@ -196,8 +197,8 @@ class RideLocationService: def estimate_ride_coordinates_from_park( cls, ride_location: RideLocation, - area_offset_meters: Optional[Dict[str, List[float]]] = None, - ) -> Optional[List[float]]: + area_offset_meters: dict[str, list[float]] | None = None, + ) -> list[float] | None: """ Estimate ride coordinates based on park location and area. Useful when exact ride coordinates are not available. @@ -332,7 +333,7 @@ class RideLocationService: return updated_count @classmethod - def generate_park_area_map(cls, park) -> Dict[str, List[str]]: + def generate_park_area_map(cls, park) -> dict[str, list[str]]: """ Generate a map of park areas and the rides in each area. diff --git a/backend/apps/rides/services/media_service.py b/backend/apps/rides/services/media_service.py index aaec46ef..ef61a823 100644 --- a/backend/apps/rides/services/media_service.py +++ b/backend/apps/rides/services/media_service.py @@ -5,11 +5,14 @@ This module provides media management functionality specific to rides. """ import logging -from typing import List, Optional, Dict, Any +from typing import Any + +from django.contrib.auth import get_user_model from django.core.files.uploadedfile import UploadedFile from django.db import transaction -from django.contrib.auth import get_user_model + from apps.core.services.media_service import MediaService + from ..models import Ride, RidePhoto User = get_user_model() @@ -83,8 +86,8 @@ class RideMediaService: ride: Ride, approved_only: bool = True, primary_first: bool = True, - photo_type: Optional[str] = None, - ) -> List[RidePhoto]: + photo_type: str | None = None, + ) -> list[RidePhoto]: """ Get photos for a ride. @@ -113,7 +116,7 @@ class RideMediaService: return list(queryset) @staticmethod - def get_primary_photo(ride: Ride) -> Optional[RidePhoto]: + def get_primary_photo(ride: Ride) -> RidePhoto | None: """ Get the primary photo for a ride. @@ -129,7 +132,7 @@ class RideMediaService: return None @staticmethod - def get_photos_by_type(ride: Ride, photo_type: str) -> List[RidePhoto]: + def get_photos_by_type(ride: Ride, photo_type: str) -> list[RidePhoto]: """ Get photos of a specific type for a ride. @@ -224,7 +227,7 @@ class RideMediaService: return False @staticmethod - def get_photo_stats(ride: Ride) -> Dict[str, Any]: + def get_photo_stats(ride: Ride) -> dict[str, Any]: """ Get photo statistics for a ride. @@ -251,7 +254,7 @@ class RideMediaService: } @staticmethod - def bulk_approve_photos(photos: List[RidePhoto], approved_by: User) -> int: + def bulk_approve_photos(photos: list[RidePhoto], approved_by: User) -> int: """ Bulk approve multiple photos. @@ -275,7 +278,7 @@ class RideMediaService: return approved_count @staticmethod - def get_construction_timeline(ride: Ride) -> List[RidePhoto]: + def get_construction_timeline(ride: Ride) -> list[RidePhoto]: """ Get construction photos ordered chronologically. @@ -292,7 +295,7 @@ class RideMediaService: ) @staticmethod - def get_onride_photos(ride: Ride) -> List[RidePhoto]: + def get_onride_photos(ride: Ride) -> list[RidePhoto]: """ Get on-ride photos for a ride. diff --git a/backend/apps/rides/services/ranking_service.py b/backend/apps/rides/services/ranking_service.py index 1c971569..9dba45b0 100644 --- a/backend/apps/rides/services/ranking_service.py +++ b/backend/apps/rides/services/ranking_service.py @@ -7,23 +7,21 @@ Rankings are determined by winning percentage in these comparisons. """ import logging -from typing import Dict, List, Optional -from decimal import Decimal from datetime import date +from decimal import Decimal from django.db import transaction from django.db.models import Avg, Count, Q from django.utils import timezone from apps.rides.models import ( - Ride, - RideReview, - RideRanking, - RidePairComparison, RankingSnapshot, + Ride, + RidePairComparison, + RideRanking, + RideReview, ) - logger = logging.getLogger(__name__) @@ -43,7 +41,7 @@ class RideRankingService: self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") self.calculation_version = "1.0" - def update_all_rankings(self, category: Optional[str] = None) -> Dict[str, any]: + def update_all_rankings(self, category: str | None = None) -> dict[str, any]: """ Main entry point to update all ride rankings. @@ -105,7 +103,7 @@ class RideRankingService: self.logger.error(f"Error updating rankings: {e}", exc_info=True) raise - def _get_eligible_rides(self, category: Optional[str] = None) -> List[Ride]: + def _get_eligible_rides(self, category: str | None = None) -> list[Ride]: """ Get rides that are eligible for ranking. @@ -127,8 +125,8 @@ class RideRankingService: return list(queryset.distinct()) def _calculate_all_comparisons( - self, rides: List[Ride] - ) -> Dict[tuple[int, int], RidePairComparison]: + self, rides: list[Ride] + ) -> dict[tuple[int, int], RidePairComparison]: """ Calculate pairwise comparisons for all ride pairs. @@ -156,7 +154,7 @@ class RideRankingService: def _calculate_pairwise_comparison( self, ride_a: Ride, ride_b: Ride - ) -> Optional[RidePairComparison]: + ) -> RidePairComparison | None: """ Calculate the pairwise comparison between two rides. @@ -246,8 +244,8 @@ class RideRankingService: return comparison def _calculate_rankings_from_comparisons( - self, rides: List[Ride], comparisons: Dict[tuple[int, int], RidePairComparison] - ) -> List[Dict]: + self, rides: list[Ride], comparisons: dict[tuple[int, int], RidePairComparison] + ) -> list[dict]: """ Calculate final rankings from pairwise comparisons. @@ -343,9 +341,9 @@ class RideRankingService: def _apply_tiebreakers( self, - rankings: List[Dict], - comparisons: Dict[tuple[int, int], RidePairComparison], - ) -> List[Dict]: + rankings: list[dict], + comparisons: dict[tuple[int, int], RidePairComparison], + ) -> list[dict]: """ Apply head-to-head tiebreaker for rides with identical winning percentages. @@ -379,9 +377,9 @@ class RideRankingService: def _sort_tied_group( self, - tied_group: List[Dict], - comparisons: Dict[tuple[int, int], RidePairComparison], - ) -> List[Dict]: + tied_group: list[dict], + comparisons: dict[tuple[int, int], RidePairComparison], + ) -> list[dict]: """ Sort a group of tied rides using head-to-head comparisons. """ @@ -426,7 +424,7 @@ class RideRankingService: return tied_group - def _save_rankings(self, rankings: List[Dict]): + def _save_rankings(self, rankings: list[dict]): """Save calculated rankings to the database.""" for ranking_data in rankings: RideRanking.objects.update_or_create( @@ -445,7 +443,7 @@ class RideRankingService: }, ) - def _save_ranking_snapshots(self, rankings: List[Dict]): + def _save_ranking_snapshots(self, rankings: list[dict]): """Save ranking snapshots for historical tracking.""" today = date.today() @@ -471,7 +469,7 @@ class RideRankingService: if deleted_snapshots[0] > 0: self.logger.info(f"Deleted {deleted_snapshots[0]} old ranking snapshots") - def get_ride_ranking_details(self, ride: Ride) -> Optional[Dict]: + def get_ride_ranking_details(self, ride: Ride) -> dict | None: """ Get detailed ranking information for a specific ride. diff --git a/backend/apps/rides/services/search.py b/backend/apps/rides/services/search.py index 82ece74b..b8076881 100644 --- a/backend/apps/rides/services/search.py +++ b/backend/apps/rides/services/search.py @@ -9,19 +9,20 @@ This service implements the filtering design specified in: backend/docs/ride_filtering_design.md """ +from typing import Any + from django.contrib.postgres.search import ( - SearchVector, SearchQuery, SearchRank, + SearchVector, TrigramSimilarity, ) from django.db import models -from django.db.models import Q, F, Value +from django.db.models import F, Q, Value from django.db.models.functions import Greatest -from typing import Dict, List, Optional, Any -from apps.rides.models import Ride from apps.parks.models import Park +from apps.rides.models import Ride from apps.rides.models.company import Company @@ -104,11 +105,11 @@ class RideSearchService: def search_and_filter( self, - filters: Dict[str, Any], + filters: dict[str, Any], sort_by: str = "relevance", page: int = 1, page_size: int = 20, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Main search and filter method that combines all capabilities. @@ -219,7 +220,7 @@ class RideSearchService: return queryset, final_rank def _apply_basic_info_filters( - self, queryset, filters: Dict[str, Any] + self, queryset, filters: dict[str, Any] ) -> models.QuerySet: """Apply basic information filters.""" @@ -267,7 +268,7 @@ class RideSearchService: return queryset - def _apply_date_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet: + def _apply_date_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet: """Apply date range filters.""" # Opening date range @@ -297,7 +298,7 @@ class RideSearchService: return queryset def _apply_height_safety_filters( - self, queryset, filters: Dict[str, Any] + self, queryset, filters: dict[str, Any] ) -> models.QuerySet: """Apply height and safety requirement filters.""" @@ -320,7 +321,7 @@ class RideSearchService: return queryset def _apply_performance_filters( - self, queryset, filters: Dict[str, Any] + self, queryset, filters: dict[str, Any] ) -> models.QuerySet: """Apply performance metric filters.""" @@ -355,7 +356,7 @@ class RideSearchService: return queryset def _apply_relationship_filters( - self, queryset, filters: Dict[str, Any] + self, queryset, filters: dict[str, Any] ) -> models.QuerySet: """Apply relationship filters (manufacturer, designer, ride model).""" @@ -398,7 +399,7 @@ class RideSearchService: return queryset def _apply_roller_coaster_filters( - self, queryset, filters: Dict[str, Any] + self, queryset, filters: dict[str, Any] ) -> models.QuerySet: """Apply roller coaster specific filters.""" queryset = self._apply_numeric_range_filter( @@ -448,7 +449,7 @@ class RideSearchService: def _apply_numeric_range_filter( self, queryset, - filters: Dict[str, Any], + filters: dict[str, Any], filter_key: str, field_name: str, ) -> models.QuerySet: @@ -466,7 +467,7 @@ class RideSearchService: return queryset def _apply_company_filters( - self, queryset, filters: Dict[str, Any] + self, queryset, filters: dict[str, Any] ) -> models.QuerySet: """Apply company-related filters.""" @@ -522,8 +523,8 @@ class RideSearchService: ) # Always add name as secondary sort def _add_search_highlights( - self, results: List[Ride], search_term: str - ) -> List[Ride]: + self, results: list[Ride], search_term: str + ) -> list[Ride]: """Add search highlights to results using SearchHeadline.""" if not search_term or not results: @@ -536,12 +537,12 @@ class RideSearchService: # (note: highlights would need to be processed at query time) for ride in results: # Store highlighted versions as dynamic attributes (for template use) - setattr(ride, "highlighted_name", ride.name) - setattr(ride, "highlighted_description", ride.description) + ride.highlighted_name = ride.name + ride.highlighted_description = ride.description return results - def _get_applied_filters_summary(self, filters: Dict[str, Any]) -> Dict[str, Any]: + def _get_applied_filters_summary(self, filters: dict[str, Any]) -> dict[str, Any]: """Generate a summary of applied filters for the frontend.""" applied = {} @@ -602,7 +603,7 @@ class RideSearchService: def get_search_suggestions( self, query: str, limit: int = 10 - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Get search suggestions for autocomplete functionality. """ @@ -672,8 +673,8 @@ class RideSearchService: return suggestions[:limit] def get_filter_options( - self, filter_type: str, context_filters: Optional[Dict[str, Any]] = None - ) -> List[Dict[str, Any]]: + self, filter_type: str, context_filters: dict[str, Any] | None = None + ) -> list[dict[str, Any]]: """ Get available options for a specific filter type. Optionally filter options based on current context. @@ -716,7 +717,7 @@ class RideSearchService: # Add more filter options as needed return [] - def _apply_all_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet: + def _apply_all_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet: """Apply all filters except search ranking.""" queryset = self._apply_basic_info_filters(queryset, filters) diff --git a/backend/apps/rides/services/status_service.py b/backend/apps/rides/services/status_service.py index 75dc7ac2..2a299047 100644 --- a/backend/apps/rides/services/status_service.py +++ b/backend/apps/rides/services/status_service.py @@ -3,9 +3,9 @@ Services for ride status transitions and management. Following Django styleguide pattern for business logic encapsulation. """ -from typing import Optional -from django.db import transaction + from django.contrib.auth.models import AbstractBaseUser +from django.db import transaction from apps.rides.models import Ride @@ -14,7 +14,7 @@ class RideStatusService: """Service for managing ride status transitions using FSM.""" @staticmethod - def open_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride: + def open_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride: """ Open a ride for operation. @@ -35,7 +35,7 @@ class RideStatusService: @staticmethod def close_ride_temporarily( - *, ride_id: int, user: Optional[AbstractBaseUser] = None + *, ride_id: int, user: AbstractBaseUser | None = None ) -> Ride: """ Temporarily close a ride. @@ -57,7 +57,7 @@ class RideStatusService: @staticmethod def mark_ride_sbno( - *, ride_id: int, user: Optional[AbstractBaseUser] = None + *, ride_id: int, user: AbstractBaseUser | None = None ) -> Ride: """ Mark a ride as SBNO (Standing But Not Operating). @@ -83,7 +83,7 @@ class RideStatusService: ride_id: int, closing_date, post_closing_status: str, - user: Optional[AbstractBaseUser] = None, + user: AbstractBaseUser | None = None, ) -> Ride: """ Mark a ride as closing with a specific date and post-closing status. @@ -112,7 +112,7 @@ class RideStatusService: @staticmethod def close_ride_permanently( - *, ride_id: int, user: Optional[AbstractBaseUser] = None + *, ride_id: int, user: AbstractBaseUser | None = None ) -> Ride: """ Permanently close a ride. @@ -133,7 +133,7 @@ class RideStatusService: return ride @staticmethod - def demolish_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride: + def demolish_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride: """ Mark a ride as demolished. @@ -153,7 +153,7 @@ class RideStatusService: return ride @staticmethod - def relocate_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride: + def relocate_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride: """ Mark a ride as relocated. diff --git a/backend/apps/rides/services_core.py b/backend/apps/rides/services_core.py index 1bc84f94..fb3e7ce1 100644 --- a/backend/apps/rides/services_core.py +++ b/backend/apps/rides/services_core.py @@ -3,10 +3,11 @@ Services for ride-related business logic. Following Django styleguide pattern for business logic encapsulation. """ -from typing import Optional, Dict, Any -from django.db import transaction +from typing import Any + from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractBaseUser +from django.db import transaction from apps.rides.models import Ride @@ -26,13 +27,13 @@ class RideService: description: str = "", status: str = "OPERATING", category: str = "", - manufacturer_id: Optional[int] = None, - designer_id: Optional[int] = None, - ride_model_id: Optional[int] = None, - park_area_id: Optional[int] = None, - opening_date: Optional[str] = None, - closing_date: Optional[str] = None, - created_by: Optional[UserType] = None, + manufacturer_id: int | None = None, + designer_id: int | None = None, + ride_model_id: int | None = None, + park_area_id: int | None = None, + opening_date: str | None = None, + closing_date: str | None = None, + created_by: UserType | None = None, ) -> Ride: """ Create a new ride with validation. @@ -105,8 +106,8 @@ class RideService: def update_ride( *, ride_id: int, - updates: Dict[str, Any], - updated_by: Optional[UserType] = None, + updates: dict[str, Any], + updated_by: UserType | None = None, ) -> Ride: """ Update an existing ride with validation. @@ -139,7 +140,7 @@ class RideService: @staticmethod def close_ride_temporarily( - *, ride_id: int, user: Optional[UserType] = None + *, ride_id: int, user: UserType | None = None ) -> Ride: """ Temporarily close a ride. @@ -161,7 +162,7 @@ class RideService: @staticmethod def mark_ride_sbno( - *, ride_id: int, user: Optional[UserType] = None + *, ride_id: int, user: UserType | None = None ) -> Ride: """ Mark a ride as SBNO (Standing But Not Operating). @@ -187,7 +188,7 @@ class RideService: ride_id: int, closing_date, post_closing_status: str, - user: Optional[UserType] = None, + user: UserType | None = None, ) -> Ride: """ Schedule a ride to close on a specific date with a post-closing status. @@ -216,7 +217,7 @@ class RideService: @staticmethod def close_ride_permanently( - *, ride_id: int, user: Optional[UserType] = None + *, ride_id: int, user: UserType | None = None ) -> Ride: """ Permanently close a ride. @@ -237,7 +238,7 @@ class RideService: return ride @staticmethod - def demolish_ride(*, ride_id: int, user: Optional[UserType] = None) -> Ride: + def demolish_ride(*, ride_id: int, user: UserType | None = None) -> Ride: """ Mark a ride as demolished. @@ -258,7 +259,7 @@ class RideService: @staticmethod def relocate_ride( - *, ride_id: int, new_park_id: int, user: Optional[UserType] = None + *, ride_id: int, new_park_id: int, user: UserType | None = None ) -> Ride: """ Relocate a ride to a new park. @@ -289,7 +290,7 @@ class RideService: return ride @staticmethod - def reopen_ride(*, ride_id: int, user: Optional[UserType] = None) -> Ride: + def reopen_ride(*, ride_id: int, user: UserType | None = None) -> Ride: """ Reopen a ride for operation. @@ -311,9 +312,9 @@ class RideService: @staticmethod def handle_new_entity_suggestions( *, - form_data: Dict[str, Any], + form_data: dict[str, Any], submitter: UserType, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Handle suggestions for new manufacturers, designers, and ride models. diff --git a/backend/apps/rides/signals.py b/backend/apps/rides/signals.py index d79e077d..17c58d2c 100644 --- a/backend/apps/rides/signals.py +++ b/backend/apps/rides/signals.py @@ -1,12 +1,11 @@ import logging -from django.db.models.signals import pre_save, post_save +from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.utils import timezone from .models import Ride - logger = logging.getLogger(__name__) @@ -59,16 +58,15 @@ def handle_ride_status(sender, instance, **kwargs): # Check if transition is allowed before attempting if hasattr(instance, 'can_proceed'): can_proceed = getattr(instance, f'can_transition_to_{target_status.lower()}', None) - if can_proceed and callable(can_proceed): - if not can_proceed(): - logger.warning( - f"FSM transition to {target_status} not allowed " - f"for ride {instance.pk}" - ) - # Fall back to direct status change - instance.status = target_status - instance.status_since = instance.closing_date - return + if can_proceed and callable(can_proceed) and not can_proceed(): + logger.warning( + f"FSM transition to {target_status} not allowed " + f"for ride {instance.pk}" + ) + # Fall back to direct status change + instance.status = target_status + instance.status_since = instance.closing_date + return try: method = getattr(instance, transition_method_name) diff --git a/backend/apps/rides/templatetags/ride_tags.py b/backend/apps/rides/templatetags/ride_tags.py index 686f2862..f06447cd 100644 --- a/backend/apps/rides/templatetags/ride_tags.py +++ b/backend/apps/rides/templatetags/ride_tags.py @@ -1,5 +1,6 @@ from django import template from django.templatetags.static import static + from ..choices import RIDE_CATEGORIES register = template.Library() diff --git a/backend/apps/rides/tests.py b/backend/apps/rides/tests.py index de373469..d2857f72 100644 --- a/backend/apps/rides/tests.py +++ b/backend/apps/rides/tests.py @@ -9,15 +9,18 @@ This module contains tests for: - Related model updates during transitions """ -from django.test import TestCase +from datetime import date, timedelta + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.utils import timezone +from django.test import TestCase from django_fsm import TransitionNotAllowed -from .models import Ride, RideModel, Company -from apps.parks.models import Park, Company as ParkCompany -from datetime import date, timedelta + +from apps.parks.models import Company as ParkCompany +from apps.parks.models import Park + +from .models import Company, Ride User = get_user_model() @@ -50,7 +53,7 @@ class RideTransitionTests(TestCase): password='testpass123', role='ADMIN' ) - + # Create operator and park self.operator = ParkCompany.objects.create( name='Test Operator', @@ -64,7 +67,7 @@ class RideTransitionTests(TestCase): operator=self.operator, timezone='America/New_York' ) - + # Create manufacturer self.manufacturer = Company.objects.create( name='Test Manufacturer', @@ -92,30 +95,30 @@ class RideTransitionTests(TestCase): """Test transition from OPERATING to CLOSED_TEMP.""" ride = self._create_ride(status='OPERATING') self.assertEqual(ride.status, 'OPERATING') - + ride.transition_to_closed_temp(user=self.user) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_TEMP') def test_operating_to_sbno_transition(self): """Test transition from OPERATING to SBNO.""" ride = self._create_ride(status='OPERATING') - + ride.transition_to_sbno(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') def test_operating_to_closing_transition(self): """Test transition from OPERATING to CLOSING.""" ride = self._create_ride(status='OPERATING') - + ride.transition_to_closing(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSING') @@ -127,10 +130,10 @@ class RideTransitionTests(TestCase): """Test transition from UNDER_CONSTRUCTION to OPERATING.""" ride = self._create_ride(status='UNDER_CONSTRUCTION') self.assertEqual(ride.status, 'UNDER_CONSTRUCTION') - + ride.transition_to_operating(user=self.user) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'OPERATING') @@ -141,31 +144,31 @@ class RideTransitionTests(TestCase): def test_closed_temp_to_operating_transition(self): """Test transition from CLOSED_TEMP to OPERATING (reopen).""" ride = self._create_ride(status='CLOSED_TEMP') - + ride.transition_to_operating(user=self.user) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'OPERATING') def test_closed_temp_to_sbno_transition(self): """Test transition from CLOSED_TEMP to SBNO.""" ride = self._create_ride(status='CLOSED_TEMP') - + ride.transition_to_sbno(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') def test_closed_temp_to_closed_perm_transition(self): """Test transition from CLOSED_TEMP to CLOSED_PERM.""" ride = self._create_ride(status='CLOSED_TEMP') - + ride.transition_to_closed_perm(user=self.moderator) ride.closing_date = date.today() ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_PERM') @@ -176,20 +179,20 @@ class RideTransitionTests(TestCase): def test_sbno_to_operating_transition(self): """Test transition from SBNO to OPERATING (revival).""" ride = self._create_ride(status='SBNO') - + ride.transition_to_operating(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'OPERATING') def test_sbno_to_closed_perm_transition(self): """Test transition from SBNO to CLOSED_PERM.""" ride = self._create_ride(status='SBNO') - + ride.transition_to_closed_perm(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_PERM') @@ -200,20 +203,20 @@ class RideTransitionTests(TestCase): def test_closing_to_closed_perm_transition(self): """Test transition from CLOSING to CLOSED_PERM.""" ride = self._create_ride(status='CLOSING') - + ride.transition_to_closed_perm(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_PERM') def test_closing_to_sbno_transition(self): """Test transition from CLOSING to SBNO.""" ride = self._create_ride(status='CLOSING') - + ride.transition_to_sbno(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') @@ -224,20 +227,20 @@ class RideTransitionTests(TestCase): def test_closed_perm_to_demolished_transition(self): """Test transition from CLOSED_PERM to DEMOLISHED.""" ride = self._create_ride(status='CLOSED_PERM') - + ride.transition_to_demolished(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'DEMOLISHED') def test_closed_perm_to_relocated_transition(self): """Test transition from CLOSED_PERM to RELOCATED.""" ride = self._create_ride(status='CLOSED_PERM') - + ride.transition_to_relocated(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'RELOCATED') @@ -248,28 +251,28 @@ class RideTransitionTests(TestCase): def test_demolished_cannot_transition(self): """Test that DEMOLISHED state cannot transition further.""" ride = self._create_ride(status='DEMOLISHED') - + with self.assertRaises(TransitionNotAllowed): ride.transition_to_operating(user=self.moderator) def test_relocated_cannot_transition(self): """Test that RELOCATED state cannot transition further.""" ride = self._create_ride(status='RELOCATED') - + with self.assertRaises(TransitionNotAllowed): ride.transition_to_operating(user=self.moderator) def test_operating_cannot_directly_demolish(self): """Test that OPERATING cannot directly transition to DEMOLISHED.""" ride = self._create_ride(status='OPERATING') - + with self.assertRaises(TransitionNotAllowed): ride.transition_to_demolished(user=self.moderator) def test_operating_cannot_directly_relocate(self): """Test that OPERATING cannot directly transition to RELOCATED.""" ride = self._create_ride(status='OPERATING') - + with self.assertRaises(TransitionNotAllowed): ride.transition_to_relocated(user=self.moderator) @@ -280,27 +283,27 @@ class RideTransitionTests(TestCase): def test_open_wrapper_method(self): """Test the open() wrapper method.""" ride = self._create_ride(status='CLOSED_TEMP') - + ride.open(user=self.user) - + ride.refresh_from_db() self.assertEqual(ride.status, 'OPERATING') def test_close_temporarily_wrapper_method(self): """Test the close_temporarily() wrapper method.""" ride = self._create_ride(status='OPERATING') - + ride.close_temporarily(user=self.user) - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_TEMP') def test_mark_sbno_wrapper_method(self): """Test the mark_sbno() wrapper method.""" ride = self._create_ride(status='OPERATING') - + ride.mark_sbno(user=self.moderator) - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') @@ -308,13 +311,13 @@ class RideTransitionTests(TestCase): """Test the mark_closing() wrapper method.""" ride = self._create_ride(status='OPERATING') closing = date(2025, 12, 31) - + ride.mark_closing( closing_date=closing, post_closing_status='DEMOLISHED', user=self.moderator ) - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSING') self.assertEqual(ride.closing_date, closing) @@ -323,7 +326,7 @@ class RideTransitionTests(TestCase): def test_mark_closing_requires_post_closing_status(self): """Test that mark_closing() requires post_closing_status.""" ride = self._create_ride(status='OPERATING') - + with self.assertRaises(ValidationError): ride.mark_closing( closing_date=date(2025, 12, 31), @@ -334,27 +337,27 @@ class RideTransitionTests(TestCase): def test_close_permanently_wrapper_method(self): """Test the close_permanently() wrapper method.""" ride = self._create_ride(status='SBNO') - + ride.close_permanently(user=self.moderator) - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_PERM') def test_demolish_wrapper_method(self): """Test the demolish() wrapper method.""" ride = self._create_ride(status='CLOSED_PERM') - + ride.demolish(user=self.moderator) - + ride.refresh_from_db() self.assertEqual(ride.status, 'DEMOLISHED') def test_relocate_wrapper_method(self): """Test the relocate() wrapper method.""" ride = self._create_ride(status='CLOSED_PERM') - + ride.relocate(user=self.moderator) - + ride.refresh_from_db() self.assertEqual(ride.status, 'RELOCATED') @@ -413,9 +416,9 @@ class RidePostClosingTests(TestCase): closing_date=yesterday, post_closing_status='DEMOLISHED' ) - + ride.apply_post_closing_status(user=self.moderator) - + ride.refresh_from_db() self.assertEqual(ride.status, 'DEMOLISHED') @@ -427,9 +430,9 @@ class RidePostClosingTests(TestCase): closing_date=yesterday, post_closing_status='RELOCATED' ) - + ride.apply_post_closing_status(user=self.moderator) - + ride.refresh_from_db() self.assertEqual(ride.status, 'RELOCATED') @@ -441,9 +444,9 @@ class RidePostClosingTests(TestCase): closing_date=yesterday, post_closing_status='SBNO' ) - + ride.apply_post_closing_status(user=self.moderator) - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') @@ -455,9 +458,9 @@ class RidePostClosingTests(TestCase): closing_date=yesterday, post_closing_status='CLOSED_PERM' ) - + ride.apply_post_closing_status(user=self.moderator) - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_PERM') @@ -469,9 +472,9 @@ class RidePostClosingTests(TestCase): closing_date=tomorrow, post_closing_status='DEMOLISHED' ) - + ride.apply_post_closing_status(user=self.moderator) - + ride.refresh_from_db() # Status should remain CLOSING since date hasn't been reached self.assertEqual(ride.status, 'CLOSING') @@ -479,10 +482,10 @@ class RidePostClosingTests(TestCase): def test_apply_post_closing_status_requires_closing_status(self): """Test apply_post_closing_status requires CLOSING status.""" ride = self._create_ride(status='OPERATING') - + with self.assertRaises(ValidationError) as ctx: ride.apply_post_closing_status(user=self.moderator) - + self.assertIn('CLOSING', str(ctx.exception)) def test_apply_post_closing_status_requires_closing_date(self): @@ -493,10 +496,10 @@ class RidePostClosingTests(TestCase): ) ride.closing_date = None ride.save() - + with self.assertRaises(ValidationError) as ctx: ride.apply_post_closing_status(user=self.moderator) - + self.assertIn('closing_date', str(ctx.exception)) def test_apply_post_closing_status_requires_post_closing_status(self): @@ -508,10 +511,10 @@ class RidePostClosingTests(TestCase): ) ride.post_closing_status = None ride.save() - + with self.assertRaises(ValidationError) as ctx: ride.apply_post_closing_status(user=self.moderator) - + self.assertIn('post_closing_status', str(ctx.exception)) @@ -563,18 +566,18 @@ class RideTransitionHistoryTests(TestCase): def test_transition_creates_state_log(self): """Test that transitions create StateLog entries.""" from django_fsm_log.models import StateLog - + ride = self._create_ride(status='OPERATING') - + ride.transition_to_closed_temp(user=self.moderator) ride.save() - + ride_ct = ContentType.objects.get_for_model(ride) log = StateLog.objects.filter( content_type=ride_ct, object_id=ride.id ).first() - + self.assertIsNotNone(log) self.assertEqual(log.state, 'CLOSED_TEMP') self.assertEqual(log.by, self.moderator) @@ -582,23 +585,23 @@ class RideTransitionHistoryTests(TestCase): def test_multiple_transitions_create_multiple_logs(self): """Test that multiple transitions create multiple log entries.""" from django_fsm_log.models import StateLog - + ride = self._create_ride(status='OPERATING') ride_ct = ContentType.objects.get_for_model(ride) - + # First transition ride.transition_to_closed_temp(user=self.moderator) ride.save() - + # Second transition ride.transition_to_operating(user=self.moderator) ride.save() - + logs = StateLog.objects.filter( content_type=ride_ct, object_id=ride.id ).order_by('timestamp') - + self.assertEqual(logs.count(), 2) self.assertEqual(logs[0].state, 'CLOSED_TEMP') self.assertEqual(logs[1].state, 'OPERATING') @@ -606,39 +609,39 @@ class RideTransitionHistoryTests(TestCase): def test_transition_log_includes_user(self): """Test that transition logs include the user who made the change.""" from django_fsm_log.models import StateLog - + ride = self._create_ride(status='OPERATING') - + ride.transition_to_sbno(user=self.moderator) ride.save() - + ride_ct = ContentType.objects.get_for_model(ride) log = StateLog.objects.filter( content_type=ride_ct, object_id=ride.id ).first() - + self.assertEqual(log.by, self.moderator) def test_post_closing_transition_logged(self): """Test that post_closing_status transitions are logged.""" from django_fsm_log.models import StateLog - + yesterday = date.today() - timedelta(days=1) ride = self._create_ride(status='CLOSING') ride.closing_date = yesterday ride.post_closing_status = 'DEMOLISHED' ride.save() - + ride.apply_post_closing_status(user=self.moderator) - + ride_ct = ContentType.objects.get_for_model(ride) log = StateLog.objects.filter( content_type=ride_ct, object_id=ride.id, state='DEMOLISHED' ).first() - + self.assertIsNotNone(log) self.assertEqual(log.by, self.moderator) @@ -680,7 +683,7 @@ class RideBusinessLogicTests(TestCase): park=self.park, manufacturer=self.manufacturer ) - + self.assertEqual(ride.park, self.park) def test_ride_slug_auto_generated(self): @@ -691,7 +694,7 @@ class RideBusinessLogicTests(TestCase): park=self.park, manufacturer=self.manufacturer ) - + self.assertEqual(ride.slug, 'my-amazing-roller-coaster') def test_ride_url_generated(self): @@ -703,7 +706,7 @@ class RideBusinessLogicTests(TestCase): park=self.park, manufacturer=self.manufacturer ) - + self.assertIn('test-park', ride.url) self.assertIn('test-ride', ride.url) @@ -717,7 +720,7 @@ class RideBusinessLogicTests(TestCase): manufacturer=self.manufacturer, opening_date=date(2020, 6, 15) ) - + self.assertEqual(ride.opening_year, 2020) def test_search_text_populated(self): @@ -729,7 +732,7 @@ class RideBusinessLogicTests(TestCase): park=self.park, manufacturer=self.manufacturer ) - + self.assertIn('test ride', ride.search_text) self.assertIn('thrilling roller coaster', ride.search_text) self.assertIn('test park', ride.search_text) @@ -744,7 +747,7 @@ class RideBusinessLogicTests(TestCase): park=self.park, manufacturer=self.manufacturer ) - + # Creating another ride with same name should get different slug ride2 = Ride.objects.create( name='Test Ride', @@ -752,7 +755,7 @@ class RideBusinessLogicTests(TestCase): park=self.park, manufacturer=self.manufacturer ) - + self.assertNotEqual(ride2.slug, 'test-ride') self.assertTrue(ride2.slug.startswith('test-ride')) @@ -801,9 +804,9 @@ class RideMoveTests(TestCase): park=self.park1, manufacturer=self.manufacturer ) - + changes = ride.move_to_park(self.park2) - + ride.refresh_from_db() self.assertEqual(ride.park, self.park2) self.assertEqual(changes['old_park']['id'], self.park1.id) @@ -819,9 +822,9 @@ class RideMoveTests(TestCase): manufacturer=self.manufacturer ) old_url = ride.url - + changes = ride.move_to_park(self.park2) - + ride.refresh_from_db() self.assertNotEqual(ride.url, old_url) self.assertIn('park-two', ride.url) @@ -837,7 +840,7 @@ class RideMoveTests(TestCase): park=self.park1, manufacturer=self.manufacturer ) - + # Create ride with same slug in park2 Ride.objects.create( name='Test Ride', @@ -846,10 +849,10 @@ class RideMoveTests(TestCase): park=self.park2, manufacturer=self.manufacturer ) - + # Move ride1 to park2 changes = ride1.move_to_park(self.park2) - + ride1.refresh_from_db() self.assertEqual(ride1.park, self.park2) # Slug should have been modified to avoid conflict @@ -894,9 +897,9 @@ class RideSlugHistoryTests(TestCase): park=self.park, manufacturer=self.manufacturer ) - + found_ride, is_historical = Ride.get_by_slug('test-ride', park=self.park) - + self.assertEqual(found_ride, ride) self.assertFalse(is_historical) @@ -909,11 +912,11 @@ class RideSlugHistoryTests(TestCase): park=self.park, manufacturer=self.manufacturer ) - + # Should find ride in correct park found_ride, is_historical = Ride.get_by_slug('test-ride', park=self.park) self.assertEqual(found_ride, ride) - + # Should not find ride in different park other_park = Park.objects.create( name='Other Park', diff --git a/backend/apps/rides/tests/test_admin.py b/backend/apps/rides/tests/test_admin.py index 3549d9e6..e82347cb 100644 --- a/backend/apps/rides/tests/test_admin.py +++ b/backend/apps/rides/tests/test_admin.py @@ -5,7 +5,6 @@ These tests verify the functionality of ride, model, stats, company, review, and ranking admin classes including query optimization and custom actions. """ -import pytest from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase @@ -14,7 +13,6 @@ from apps.rides.admin import ( CompanyAdmin, RankingSnapshotAdmin, RideAdmin, - RideLocationAdmin, RideModelAdmin, RidePairComparisonAdmin, RideRankingAdmin, @@ -22,7 +20,6 @@ from apps.rides.admin import ( RollerCoasterStatsAdmin, ) from apps.rides.models.company import Company -from apps.rides.models.location import RideLocation from apps.rides.models.rankings import RankingSnapshot, RidePairComparison, RideRanking from apps.rides.models.reviews import RideReview from apps.rides.models.rides import Ride, RideModel, RollerCoasterStats diff --git a/backend/apps/rides/tests/test_ride_workflows.py b/backend/apps/rides/tests/test_ride_workflows.py index b26a4237..b6024fba 100644 --- a/backend/apps/rides/tests/test_ride_workflows.py +++ b/backend/apps/rides/tests/test_ride_workflows.py @@ -10,17 +10,18 @@ This module tests end-to-end ride lifecycle workflows including: - Ride relocation workflow """ -from django.test import TestCase -from django.contrib.auth import get_user_model -from django.utils import timezone from datetime import timedelta +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + User = get_user_model() class RideOpeningWorkflowTests(TestCase): """Tests for ride opening workflow.""" - + @classmethod def setUpTestData(cls): cls.user = User.objects.create_user( @@ -29,18 +30,18 @@ class RideOpeningWorkflowTests(TestCase): password='testpass123', role='USER' ) - + def _create_ride(self, status='OPERATING', **kwargs): """Helper to create a ride with park.""" + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company - + # Create manufacturer manufacturer = Company.objects.create( name=f'Manufacturer {timezone.now().timestamp()}', roles=['MANUFACTURER'] ) - + # Create park with operator operator = Company.objects.create( name=f'Operator {timezone.now().timestamp()}', @@ -53,7 +54,7 @@ class RideOpeningWorkflowTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + defaults = { 'name': f'Test Ride {timezone.now().timestamp()}', 'slug': f'test-ride-{timezone.now().timestamp()}', @@ -63,28 +64,28 @@ class RideOpeningWorkflowTests(TestCase): } defaults.update(kwargs) return Ride.objects.create(**defaults) - + def test_ride_opens_from_under_construction(self): """ Test ride opening from under construction state. - + Flow: UNDER_CONSTRUCTION → OPERATING """ ride = self._create_ride(status='UNDER_CONSTRUCTION') - + self.assertEqual(ride.status, 'UNDER_CONSTRUCTION') - + # Ride opens ride.transition_to_operating(user=self.user) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'OPERATING') class RideMaintenanceWorkflowTests(TestCase): """Tests for ride maintenance (temporary closure) workflow.""" - + @classmethod def setUpTestData(cls): cls.user = User.objects.create_user( @@ -93,11 +94,11 @@ class RideMaintenanceWorkflowTests(TestCase): password='testpass123', role='USER' ) - + def _create_ride(self, status='OPERATING', **kwargs): + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company - + manufacturer = Company.objects.create( name=f'Mfr Maint {timezone.now().timestamp()}', roles=['MANUFACTURER'] @@ -113,7 +114,7 @@ class RideMaintenanceWorkflowTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + defaults = { 'name': f'Ride Maint {timezone.now().timestamp()}', 'slug': f'ride-maint-{timezone.now().timestamp()}', @@ -123,33 +124,33 @@ class RideMaintenanceWorkflowTests(TestCase): } defaults.update(kwargs) return Ride.objects.create(**defaults) - + def test_ride_maintenance_and_reopen(self): """ Test ride maintenance and reopening. - + Flow: OPERATING → CLOSED_TEMP → OPERATING """ ride = self._create_ride(status='OPERATING') - + # Close for maintenance ride.transition_to_closed_temp(user=self.user) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_TEMP') - + # Reopen after maintenance ride.transition_to_operating(user=self.user) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'OPERATING') class RideSBNOWorkflowTests(TestCase): """Tests for ride SBNO (Standing But Not Operating) workflow.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -158,11 +159,11 @@ class RideSBNOWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_ride(self, status='OPERATING', **kwargs): + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company - + manufacturer = Company.objects.create( name=f'Mfr SBNO {timezone.now().timestamp()}', roles=['MANUFACTURER'] @@ -178,7 +179,7 @@ class RideSBNOWorkflowTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + defaults = { 'name': f'Ride SBNO {timezone.now().timestamp()}', 'slug': f'ride-sbno-{timezone.now().timestamp()}', @@ -188,71 +189,71 @@ class RideSBNOWorkflowTests(TestCase): } defaults.update(kwargs) return Ride.objects.create(**defaults) - + def test_ride_sbno_from_operating(self): """ Test ride becomes SBNO from operating. - + Flow: OPERATING → SBNO """ ride = self._create_ride(status='OPERATING') - + # Mark as SBNO ride.transition_to_sbno(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') - + def test_ride_sbno_from_closed_temp(self): """ Test ride becomes SBNO from temporary closure. - + Flow: OPERATING → CLOSED_TEMP → SBNO """ ride = self._create_ride(status='CLOSED_TEMP') - + # Extended to SBNO ride.transition_to_sbno(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') - + def test_ride_revival_from_sbno(self): """ Test ride revival from SBNO state. - + Flow: SBNO → OPERATING """ ride = self._create_ride(status='SBNO') - + # Revive the ride ride.transition_to_operating(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'OPERATING') - + def test_sbno_to_closed_perm(self): """ Test ride permanently closes from SBNO. - + Flow: SBNO → CLOSED_PERM """ ride = self._create_ride(status='SBNO') - + # Confirm permanent closure ride.transition_to_closed_perm(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_PERM') class RideScheduledClosureWorkflowTests(TestCase): """Tests for ride scheduled closure (CLOSING state) workflow.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -261,11 +262,11 @@ class RideScheduledClosureWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_ride(self, status='OPERATING', **kwargs): + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company - + manufacturer = Company.objects.create( name=f'Mfr Closing {timezone.now().timestamp()}', roles=['MANUFACTURER'] @@ -281,7 +282,7 @@ class RideScheduledClosureWorkflowTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + defaults = { 'name': f'Ride Closing {timezone.now().timestamp()}', 'slug': f'ride-closing-{timezone.now().timestamp()}', @@ -291,67 +292,67 @@ class RideScheduledClosureWorkflowTests(TestCase): } defaults.update(kwargs) return Ride.objects.create(**defaults) - + def test_ride_mark_closing_with_date(self): """ Test ride marked as closing with scheduled date. - + Flow: OPERATING → CLOSING (with closing_date and post_closing_status) """ ride = self._create_ride(status='OPERATING') closing_date = (timezone.now() + timedelta(days=30)).date() - + # Mark as closing ride.transition_to_closing(user=self.moderator) ride.closing_date = closing_date ride.post_closing_status = 'DEMOLISHED' ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSING') self.assertEqual(ride.closing_date, closing_date) self.assertEqual(ride.post_closing_status, 'DEMOLISHED') - + def test_closing_to_closed_perm(self): """ Test ride transitions from CLOSING to CLOSED_PERM when date reached. - + Flow: CLOSING → CLOSED_PERM """ ride = self._create_ride(status='CLOSING') ride.closing_date = timezone.now().date() ride.post_closing_status = 'CLOSED_PERM' ride.save() - + # Transition when closing date reached ride.transition_to_closed_perm(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_PERM') - + def test_closing_to_sbno(self): """ Test ride transitions from CLOSING to SBNO. - + Flow: CLOSING → SBNO """ ride = self._create_ride(status='CLOSING') ride.closing_date = timezone.now().date() ride.post_closing_status = 'SBNO' ride.save() - + # Transition to SBNO ride.transition_to_sbno(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') class RideDemolitionWorkflowTests(TestCase): """Tests for ride demolition workflow.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -360,11 +361,11 @@ class RideDemolitionWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_ride(self, status='CLOSED_PERM', **kwargs): + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company - + manufacturer = Company.objects.create( name=f'Mfr Demo {timezone.now().timestamp()}', roles=['MANUFACTURER'] @@ -380,7 +381,7 @@ class RideDemolitionWorkflowTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + defaults = { 'name': f'Ride Demo {timezone.now().timestamp()}', 'slug': f'ride-demo-{timezone.now().timestamp()}', @@ -390,28 +391,28 @@ class RideDemolitionWorkflowTests(TestCase): } defaults.update(kwargs) return Ride.objects.create(**defaults) - + def test_ride_demolition(self): """ Test ride demolition from permanently closed. - + Flow: CLOSED_PERM → DEMOLISHED """ ride = self._create_ride(status='CLOSED_PERM') - + # Demolish ride.transition_to_demolished(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'DEMOLISHED') - + def test_demolished_is_final_state(self): """Test that demolished rides cannot transition further.""" from django_fsm import TransitionNotAllowed - + ride = self._create_ride(status='DEMOLISHED') - + # Cannot transition from demolished with self.assertRaises(TransitionNotAllowed): ride.transition_to_operating(user=self.moderator) @@ -419,7 +420,7 @@ class RideDemolitionWorkflowTests(TestCase): class RideRelocationWorkflowTests(TestCase): """Tests for ride relocation workflow.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -428,11 +429,11 @@ class RideRelocationWorkflowTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_ride(self, status='CLOSED_PERM', **kwargs): + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company - + manufacturer = Company.objects.create( name=f'Mfr Reloc {timezone.now().timestamp()}', roles=['MANUFACTURER'] @@ -448,7 +449,7 @@ class RideRelocationWorkflowTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + defaults = { 'name': f'Ride Reloc {timezone.now().timestamp()}', 'slug': f'ride-reloc-{timezone.now().timestamp()}', @@ -458,28 +459,28 @@ class RideRelocationWorkflowTests(TestCase): } defaults.update(kwargs) return Ride.objects.create(**defaults) - + def test_ride_relocation(self): """ Test ride relocation from permanently closed. - + Flow: CLOSED_PERM → RELOCATED """ ride = self._create_ride(status='CLOSED_PERM') - + # Relocate ride.transition_to_relocated(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'RELOCATED') - + def test_relocated_is_final_state(self): """Test that relocated rides cannot transition further.""" from django_fsm import TransitionNotAllowed - + ride = self._create_ride(status='RELOCATED') - + # Cannot transition from relocated with self.assertRaises(TransitionNotAllowed): ride.transition_to_operating(user=self.moderator) @@ -487,7 +488,7 @@ class RideRelocationWorkflowTests(TestCase): class RideWrapperMethodTests(TestCase): """Tests for ride wrapper methods.""" - + @classmethod def setUpTestData(cls): cls.user = User.objects.create_user( @@ -502,11 +503,11 @@ class RideWrapperMethodTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_ride(self, status='OPERATING', **kwargs): + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company - + manufacturer = Company.objects.create( name=f'Mfr Wrapper {timezone.now().timestamp()}', roles=['MANUFACTURER'] @@ -522,7 +523,7 @@ class RideWrapperMethodTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + defaults = { 'name': f'Ride Wrapper {timezone.now().timestamp()}', 'slug': f'ride-wrapper-{timezone.now().timestamp()}', @@ -532,38 +533,38 @@ class RideWrapperMethodTests(TestCase): } defaults.update(kwargs) return Ride.objects.create(**defaults) - + def test_close_temporarily_wrapper(self): """Test close_temporarily wrapper method.""" ride = self._create_ride(status='OPERATING') - + if hasattr(ride, 'close_temporarily'): ride.close_temporarily(user=self.user) else: ride.transition_to_closed_temp(user=self.user) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_TEMP') - + def test_mark_sbno_wrapper(self): """Test mark_sbno wrapper method.""" ride = self._create_ride(status='OPERATING') - + if hasattr(ride, 'mark_sbno'): ride.mark_sbno(user=self.moderator) else: ride.transition_to_sbno(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'SBNO') - + def test_mark_closing_wrapper(self): """Test mark_closing wrapper method.""" ride = self._create_ride(status='OPERATING') closing_date = (timezone.now() + timedelta(days=30)).date() - + if hasattr(ride, 'mark_closing'): ride.mark_closing( closing_date=closing_date, @@ -575,66 +576,66 @@ class RideWrapperMethodTests(TestCase): ride.closing_date = closing_date ride.post_closing_status = 'DEMOLISHED' ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSING') - + def test_open_wrapper(self): """Test open wrapper method.""" ride = self._create_ride(status='CLOSED_TEMP') - + if hasattr(ride, 'open'): ride.open(user=self.user) else: ride.transition_to_operating(user=self.user) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'OPERATING') - + def test_close_permanently_wrapper(self): """Test close_permanently wrapper method.""" ride = self._create_ride(status='SBNO') - + if hasattr(ride, 'close_permanently'): ride.close_permanently(user=self.moderator) else: ride.transition_to_closed_perm(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'CLOSED_PERM') - + def test_demolish_wrapper(self): """Test demolish wrapper method.""" ride = self._create_ride(status='CLOSED_PERM') - + if hasattr(ride, 'demolish'): ride.demolish(user=self.moderator) else: ride.transition_to_demolished(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'DEMOLISHED') - + def test_relocate_wrapper(self): """Test relocate wrapper method.""" ride = self._create_ride(status='CLOSED_PERM') - + if hasattr(ride, 'relocate'): ride.relocate(user=self.moderator) else: ride.transition_to_relocated(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'RELOCATED') class RidePostClosingStatusAutomationTests(TestCase): """Tests for post_closing_status automation logic.""" - + @classmethod def setUpTestData(cls): cls.moderator = User.objects.create_user( @@ -643,11 +644,11 @@ class RidePostClosingStatusAutomationTests(TestCase): password='testpass123', role='MODERATOR' ) - + def _create_ride(self, status='CLOSING', **kwargs): + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company - + manufacturer = Company.objects.create( name=f'Mfr Auto {timezone.now().timestamp()}', roles=['MANUFACTURER'] @@ -663,7 +664,7 @@ class RidePostClosingStatusAutomationTests(TestCase): status='OPERATING', timezone='America/New_York' ) - + defaults = { 'name': f'Ride Auto {timezone.now().timestamp()}', 'slug': f'ride-auto-{timezone.now().timestamp()}', @@ -673,40 +674,40 @@ class RidePostClosingStatusAutomationTests(TestCase): } defaults.update(kwargs) return Ride.objects.create(**defaults) - + def test_apply_post_closing_status_demolished(self): """Test apply_post_closing_status transitions to DEMOLISHED.""" ride = self._create_ride(status='CLOSING') ride.closing_date = timezone.now().date() ride.post_closing_status = 'DEMOLISHED' ride.save() - + # Apply post-closing status if method exists if hasattr(ride, 'apply_post_closing_status'): ride.apply_post_closing_status(user=self.moderator) else: ride.transition_to_demolished(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'DEMOLISHED') - + def test_apply_post_closing_status_relocated(self): """Test apply_post_closing_status transitions to RELOCATED.""" ride = self._create_ride(status='CLOSING') ride.closing_date = timezone.now().date() ride.post_closing_status = 'RELOCATED' ride.save() - + if hasattr(ride, 'apply_post_closing_status'): ride.apply_post_closing_status(user=self.moderator) else: ride.transition_to_relocated(user=self.moderator) ride.save() - + ride.refresh_from_db() self.assertEqual(ride.status, 'RELOCATED') - + def test_apply_post_closing_status_sbno(self): """Test apply_post_closing_status transitions to SBNO.""" ride = self._create_ride(status='CLOSING') @@ -743,8 +744,8 @@ class RideStateLogTests(TestCase): ) def _create_ride(self, status='OPERATING', **kwargs): + from apps.parks.models import Company, Park from apps.rides.models import Ride - from apps.parks.models import Park, Company manufacturer = Company.objects.create( name=f'Mfr Log {timezone.now().timestamp()}', @@ -774,8 +775,8 @@ class RideStateLogTests(TestCase): def test_transition_creates_state_log(self): """Test that ride transitions create StateLog entries.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog ride = self._create_ride(status='OPERATING') ride_ct = ContentType.objects.get_for_model(ride) @@ -796,8 +797,8 @@ class RideStateLogTests(TestCase): def test_multiple_transitions_logged(self): """Test that multiple ride transitions are all logged.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog ride = self._create_ride(status='OPERATING') ride_ct = ContentType.objects.get_for_model(ride) @@ -822,8 +823,8 @@ class RideStateLogTests(TestCase): def test_sbno_revival_workflow_logged(self): """Test that SBNO revival workflow is logged.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog ride = self._create_ride(status='SBNO') ride_ct = ContentType.objects.get_for_model(ride) @@ -844,8 +845,8 @@ class RideStateLogTests(TestCase): def test_full_lifecycle_logged(self): """Test complete ride lifecycle is logged.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog ride = self._create_ride(status='OPERATING') ride_ct = ContentType.objects.get_for_model(ride) @@ -875,8 +876,8 @@ class RideStateLogTests(TestCase): def test_scheduled_closing_workflow_logged(self): """Test that scheduled closing workflow creates logs.""" - from django_fsm_log.models import StateLog from django.contrib.contenttypes.models import ContentType + from django_fsm_log.models import StateLog ride = self._create_ride(status='OPERATING') ride_ct = ContentType.objects.get_for_model(ride) diff --git a/backend/apps/rides/urls.py b/backend/apps/rides/urls.py index e3f3daea..2dfb48cf 100644 --- a/backend/apps/rides/urls.py +++ b/backend/apps/rides/urls.py @@ -1,7 +1,9 @@ from django.urls import path -from . import views + from apps.core.views.views import FSMTransitionView +from . import views + app_name = "rides" urlpatterns = [ diff --git a/backend/apps/rides/views.py b/backend/apps/rides/views.py index ea19acc6..f8970f76 100644 --- a/backend/apps/rides/views.py +++ b/backend/apps/rides/views.py @@ -38,6 +38,8 @@ Code Quality: - Maximum complexity: 10 (McCabe) """ +import logging + from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Count, Q from django.http import Http404, HttpRequest, HttpResponse @@ -45,6 +47,7 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.views.generic import CreateView, DetailView, ListView, UpdateView +from apps.core.logging import log_business_event, log_exception from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin from apps.parks.models import Park @@ -56,10 +59,6 @@ from .models.rankings import RankingSnapshot, RideRanking from .models.rides import Ride, RideModel from .services.ranking_service import RideRankingService -import logging - -from apps.core.logging import log_exception, log_business_event - logger = logging.getLogger(__name__) @@ -750,6 +749,7 @@ def ranking_comparisons(request: HttpRequest, ride_slug: str) -> HttpResponse: # Get head-to-head comparisons from django.db.models import Q + from .models.rankings import RidePairComparison comparisons = ( diff --git a/backend/apps/support/apps.py b/backend/apps/support/apps.py index 7f023d02..7af6292a 100644 --- a/backend/apps/support/apps.py +++ b/backend/apps/support/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class SupportConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.support" diff --git a/backend/apps/support/models.py b/backend/apps/support/models.py index b97f985b..b2817fb4 100644 --- a/backend/apps/support/models.py +++ b/backend/apps/support/models.py @@ -1,12 +1,14 @@ -from django.db import models from django.conf import settings +from django.db import models + from apps.core.history import TrackedModel + class Ticket(TrackedModel): STATUS_OPEN = 'open' STATUS_IN_PROGRESS = 'in_progress' STATUS_CLOSED = 'closed' - + STATUS_CHOICES = [ (STATUS_OPEN, 'Open'), (STATUS_IN_PROGRESS, 'In Progress'), @@ -37,7 +39,7 @@ class Ticket(TrackedModel): related_name="tickets", help_text="User who submitted the ticket (optional)" ) - + category = models.CharField( max_length=20, choices=CATEGORY_CHOICES, @@ -48,14 +50,14 @@ class Ticket(TrackedModel): subject = models.CharField(max_length=255) message = models.TextField() email = models.EmailField(help_text="Contact email", blank=True) - + status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, + max_length=20, + choices=STATUS_CHOICES, default=STATUS_OPEN, db_index=True ) - + class Meta(TrackedModel.Meta): verbose_name = "Ticket" verbose_name_plural = "Tickets" diff --git a/backend/apps/support/serializers.py b/backend/apps/support/serializers.py index 2c270476..701c621a 100644 --- a/backend/apps/support/serializers.py +++ b/backend/apps/support/serializers.py @@ -1,12 +1,15 @@ from rest_framework import serializers -from .models import Ticket + from apps.accounts.serializers import UserSerializer +from .models import Ticket + + class TicketSerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) category_display = serializers.CharField(source='get_category_display', read_only=True) status_display = serializers.CharField(source='get_status_display', read_only=True) - + class Meta: model = Ticket fields = [ diff --git a/backend/apps/support/urls.py b/backend/apps/support/urls.py index ed98dbd0..65133e68 100644 --- a/backend/apps/support/urls.py +++ b/backend/apps/support/urls.py @@ -1,5 +1,6 @@ -from django.urls import path, include +from django.urls import include, path from rest_framework.routers import DefaultRouter + from .views import TicketViewSet router = DefaultRouter() diff --git a/backend/apps/support/views.py b/backend/apps/support/views.py index da2386a7..94d028da 100644 --- a/backend/apps/support/views.py +++ b/backend/apps/support/views.py @@ -1,8 +1,10 @@ -from rest_framework import viewsets, permissions, filters from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, permissions, viewsets + from .models import Ticket from .serializers import TicketSerializer + class TicketViewSet(viewsets.ModelViewSet): """ Standard users/guests can CREATE. diff --git a/backend/config/celery.py b/backend/config/celery.py index 519de640..f233d6d5 100644 --- a/backend/config/celery.py +++ b/backend/config/celery.py @@ -12,6 +12,7 @@ which can be configured via DJANGO_SETTINGS_MODULE environment variable. """ import os + from celery import Celery from decouple import config diff --git a/backend/config/django/base.py b/backend/config/django/base.py index c5a2c540..1fc59729 100644 --- a/backend/config/django/base.py +++ b/backend/config/django/base.py @@ -15,6 +15,7 @@ Structure: import sys from pathlib import Path + from decouple import config # ============================================================================= @@ -85,10 +86,11 @@ THIRD_PARTY_APPS = [ "django_fsm_log", # FSM transition logging "allauth", "allauth.account", + "allauth.mfa", # MFA/TOTP support "allauth.socialaccount", "allauth.socialaccount.providers.google", "allauth.socialaccount.providers.discord", - "django_turnstile", # Cloudflare Turnstile CAPTCHA + "turnstile", # Cloudflare Turnstile CAPTCHA (django-turnstile package) "django_cleanup", "django_filters", "django_htmx", @@ -239,13 +241,9 @@ TEST_RUNNER = "django.test.runner.DiscoverRunner" # These imports add/override settings defined above. # Database configuration (DATABASES, GDAL_LIBRARY_PATH, GEOS_LIBRARY_PATH) -from config.settings.database import * # noqa: F401,F403,E402 - # Cache configuration (CACHES, SESSION_*, CACHE_MIDDLEWARE_*) from config.settings.cache import * # noqa: F401,F403,E402 - -# Security configuration (SECURE_*, CSRF_*, SESSION_COOKIE_*, AUTH_PASSWORD_VALIDATORS) -from config.settings.security import * # noqa: F401,F403,E402 +from config.settings.database import * # noqa: F401,F403,E402 # Email configuration (EMAIL_*, FORWARD_EMAIL_*) from config.settings.email import * # noqa: F401,F403,E402 @@ -256,12 +254,15 @@ from config.settings.logging import * # noqa: F401,F403,E402 # REST Framework configuration (REST_FRAMEWORK, CORS_*, SIMPLE_JWT, REST_AUTH, SPECTACULAR_SETTINGS) from config.settings.rest_framework import * # noqa: F401,F403,E402 -# Third-party configuration (ACCOUNT_*, SOCIALACCOUNT_*, CLOUDFLARE_IMAGES, etc.) -from config.settings.third_party import * # noqa: F401,F403,E402 +# Security configuration (SECURE_*, CSRF_*, SESSION_COOKIE_*, AUTH_PASSWORD_VALIDATORS) +from config.settings.security import * # noqa: F401,F403,E402 # Storage configuration (STATIC_*, MEDIA_*, STORAGES, WHITENOISE_*, FILE_UPLOAD_*) from config.settings.storage import * # noqa: F401,F403,E402 +# Third-party configuration (ACCOUNT_*, SOCIALACCOUNT_*, CLOUDFLARE_IMAGES, etc.) +from config.settings.third_party import * # noqa: F401,F403,E402 + # ============================================================================= # Post-Import Overrides # ============================================================================= diff --git a/backend/config/django/local.py b/backend/config/django/local.py index 288bbc25..f13c838b 100644 --- a/backend/config/django/local.py +++ b/backend/config/django/local.py @@ -10,6 +10,7 @@ This module extends base.py with development-specific configurations: """ import logging + from .base import * # noqa: F401,F403 # ============================================================================= diff --git a/backend/config/django/production.py b/backend/config/django/production.py index f81b0853..9a0ba147 100644 --- a/backend/config/django/production.py +++ b/backend/config/django/production.py @@ -10,6 +10,7 @@ This module extends base.py with production-specific configurations: """ from decouple import config + from .base import * # noqa: F401,F403 # ============================================================================= @@ -244,8 +245,8 @@ SENTRY_DSN = config("SENTRY_DSN", default="") if SENTRY_DSN: import sentry_sdk - from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.celery import CeleryIntegration + from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration sentry_sdk.init( diff --git a/backend/config/settings/database.py b/backend/config/settings/database.py index b09a733f..a1d28c2e 100644 --- a/backend/config/settings/database.py +++ b/backend/config/settings/database.py @@ -18,8 +18,8 @@ Database URL Format: - SpatiaLite: spatialite:///path/to/db.sqlite3 """ -from decouple import config import dj_database_url +from decouple import config # ============================================================================= # Database Configuration diff --git a/backend/config/settings/logging.py b/backend/config/settings/logging.py index 996d046d..27001221 100644 --- a/backend/config/settings/logging.py +++ b/backend/config/settings/logging.py @@ -20,6 +20,7 @@ Log Levels (in order of severity): """ from pathlib import Path + from decouple import config # ============================================================================= diff --git a/backend/config/settings/rest_framework.py b/backend/config/settings/rest_framework.py index 6c5701d5..5e524718 100644 --- a/backend/config/settings/rest_framework.py +++ b/backend/config/settings/rest_framework.py @@ -12,6 +12,7 @@ Why python-decouple? """ from datetime import timedelta + from decouple import config # ============================================================================= diff --git a/backend/config/settings/secrets.py b/backend/config/settings/secrets.py index f59cc30e..8336680b 100644 --- a/backend/config/settings/secrets.py +++ b/backend/config/settings/secrets.py @@ -21,9 +21,8 @@ Why python-decouple? import logging import warnings -from datetime import datetime, timedelta -from typing import Optional -from decouple import config, UndefinedValueError + +from decouple import UndefinedValueError, config logger = logging.getLogger("security") @@ -171,10 +170,10 @@ def validate_secret_key(secret_key: str) -> bool: def get_secret( name: str, - default: Optional[str] = None, + default: str | None = None, required: bool = True, min_length: int = 0, -) -> Optional[str]: +) -> str | None: """ Safely retrieve a secret with validation. @@ -197,11 +196,10 @@ def get_secret( raise ValueError(f"Required secret '{name}' is not set") return default - if value and min_length > 0: - if not validate_secret_strength(name, value, min_length): - if required: - raise ValueError(f"Secret '{name}' does not meet requirements") - return default + if value and min_length > 0 and not validate_secret_strength(name, value, min_length): + if required: + raise ValueError(f"Secret '{name}' does not meet requirements") + return default return value @@ -284,7 +282,7 @@ class SecretProvider: - Azure Key Vault """ - def get_secret(self, name: str) -> Optional[str]: + def get_secret(self, name: str) -> str | None: """Retrieve a secret by name.""" raise NotImplementedError @@ -308,7 +306,7 @@ class EnvironmentSecretProvider(SecretProvider): This is the fallback provider for development and simple deployments. """ - def get_secret(self, name: str) -> Optional[str]: + def get_secret(self, name: str) -> str | None: """Retrieve a secret from environment variables.""" try: return config(name) @@ -370,7 +368,7 @@ def run_startup_validation() -> None: if errors: for error in errors: if debug_mode: - warnings.warn(f"Secret validation warning: {error}") + warnings.warn(f"Secret validation warning: {error}", stacklevel=2) else: logger.error(f"Secret validation error: {error}") @@ -383,9 +381,8 @@ def run_startup_validation() -> None: # Validate SECRET_KEY specifically try: secret_key = config("SECRET_KEY") - if not validate_secret_key(secret_key): - if not debug_mode: - raise ValueError("SECRET_KEY does not meet security requirements") + if not validate_secret_key(secret_key) and not debug_mode: + raise ValueError("SECRET_KEY does not meet security requirements") except UndefinedValueError: if not debug_mode: raise ValueError("SECRET_KEY is required in production") diff --git a/backend/config/settings/storage.py b/backend/config/settings/storage.py index 859b6d9e..dc33615d 100644 --- a/backend/config/settings/storage.py +++ b/backend/config/settings/storage.py @@ -12,6 +12,7 @@ Why python-decouple? """ from pathlib import Path + from decouple import config # ============================================================================= diff --git a/backend/config/settings/third_party.py b/backend/config/settings/third_party.py index 8464bb54..ee053b16 100644 --- a/backend/config/settings/third_party.py +++ b/backend/config/settings/third_party.py @@ -73,6 +73,38 @@ SOCIALACCOUNT_LOGIN_ON_GET = True SOCIALACCOUNT_AUTO_SIGNUP = False SOCIALACCOUNT_STORE_TOKENS = True +# ============================================================================= +# MFA (Multi-Factor Authentication) Configuration +# ============================================================================= +# https://docs.allauth.org/en/latest/mfa/index.html + +# Supported authenticator types +MFA_SUPPORTED_TYPES = ["totp"] + +# TOTP settings +MFA_TOTP_ISSUER = config("MFA_TOTP_ISSUER", default="ThrillWiki") + +# Number of digits for TOTP codes (default is 6) +MFA_TOTP_DIGITS = 6 + +# Interval in seconds for TOTP code generation (default 30) +MFA_TOTP_PERIOD = 30 + +# ============================================================================= +# Login By Code (Magic Link) Configuration +# ============================================================================= +# https://docs.allauth.org/en/latest/account/configuration.html#login-by-code + +# Enable magic link / login by code feature +ACCOUNT_LOGIN_BY_CODE_ENABLED = config("ACCOUNT_LOGIN_BY_CODE_ENABLED", default=True, cast=bool) + +# Maximum attempts to enter the code +ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = config("ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS", default=3, cast=int) + +# Code expiration timeout in seconds (5 minutes default) +ACCOUNT_LOGIN_BY_CODE_TIMEOUT = config("ACCOUNT_LOGIN_BY_CODE_TIMEOUT", default=300, cast=int) + + # ============================================================================= # Celery Configuration # ============================================================================= @@ -194,7 +226,7 @@ TURNSTILE_SECRET = config("TURNSTILE_SECRET", default="") # Skip Turnstile validation in development if keys not set TURNSTILE_SKIP_VALIDATION = config( - "TURNSTILE_SKIP_VALIDATION", + "TURNSTILE_SKIP_VALIDATION", default=not TURNSTILE_SECRET, # Skip if no secret cast=bool ) diff --git a/backend/config/settings/validation.py b/backend/config/settings/validation.py index 78a2648f..86a11ca7 100644 --- a/backend/config/settings/validation.py +++ b/backend/config/settings/validation.py @@ -18,10 +18,10 @@ Why python-decouple? import logging import re import warnings -from typing import Any, Callable, Optional +from typing import Any from urllib.parse import urlparse -from decouple import config, UndefinedValueError +from decouple import UndefinedValueError, config logger = logging.getLogger("thrillwiki") @@ -170,24 +170,20 @@ def validate_type(value: Any, expected_type: type) -> bool: def validate_range( value: Any, - min_value: Optional[Any] = None, - max_value: Optional[Any] = None + min_value: Any | None = None, + max_value: Any | None = None ) -> bool: """Validate that a value is within a specified range.""" if min_value is not None and value < min_value: return False - if max_value is not None and value > max_value: - return False - return True + return not (max_value is not None and value > max_value) def validate_length(value: str, min_length: int = 0, max_length: int = None) -> bool: """Validate that a string value meets length requirements.""" if len(value) < min_length: return False - if max_length is not None and len(value) > max_length: - return False - return True + return not (max_length is not None and len(value) > max_length) VALIDATORS = { @@ -217,7 +213,7 @@ def validate_variable(name: str, rules: dict) -> list[str]: try: # Get the value with appropriate type casting var_type = rules.get("type", str) - default = rules.get("default", None) + default = rules.get("default") if var_type == bool: value = config(name, default=default, cast=bool) @@ -263,9 +259,8 @@ def validate_variable(name: str, rules: dict) -> list[str]: # Custom validator validator_name = rules.get("validator") - if validator_name and validator_name in VALIDATORS: - if not VALIDATORS[validator_name](value): - errors.append(f"{name}: Failed {validator_name} validation") + if validator_name and validator_name in VALIDATORS and not VALIDATORS[validator_name](value): + errors.append(f"{name}: Failed {validator_name} validation") return errors @@ -375,7 +370,7 @@ def run_startup_validation() -> None: else: if debug_mode: for error in result["errors"]: - warnings.warn(f"Configuration error: {error}") + warnings.warn(f"Configuration error: {error}", stacklevel=2) else: raise ValueError( "Configuration validation failed. Check logs for details." diff --git a/backend/ensure_admin.py b/backend/ensure_admin.py index c628c880..6391f2ae 100644 --- a/backend/ensure_admin.py +++ b/backend/ensure_admin.py @@ -1,5 +1,6 @@ import os import sys + import django sys.path.append(os.path.join(os.path.dirname(__file__))) @@ -7,13 +8,14 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings") django.setup() from django.contrib.auth import get_user_model + User = get_user_model() def ensure_admin(): username = "admin" email = "admin@example.com" password = "adminpassword" - + if not User.objects.filter(username=username).exists(): print(f"Creating superuser {username}...") User.objects.create_superuser(username=username, email=email, password=password, role="ADMIN") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 24820f66..351c77e5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "python-dotenv>=1.0.1", "django-environ>=0.12.0", "python-decouple>=3.8", - # ============================================================================= # Django REST Framework # ============================================================================= @@ -21,7 +20,6 @@ dependencies = [ "drf-spectacular>=0.28.0", "django-cors-headers>=4.6.0", "django-filter>=24.3", - # ============================================================================= # Authentication & Security # ============================================================================= @@ -30,7 +28,6 @@ dependencies = [ "djangorestframework-simplejwt>=5.5.1", "pyjwt>=2.10.1", "cryptography>=44.0.0", - # ============================================================================= # Image Processing & Media # ============================================================================= @@ -38,7 +35,6 @@ dependencies = [ "django-cleanup>=8.1.0", "piexif>=1.1.3", "django-cloudflareimages-toolkit>=1.0.6", - # ============================================================================= # Frontend Integration (HTMX, Templates) # ============================================================================= @@ -49,7 +45,6 @@ dependencies = [ "whitenoise>=6.8.0", "rjsmin>=1.2.0", "rcssmin>=1.1.0", - # ============================================================================= # Task Queue & Caching # ============================================================================= @@ -59,14 +54,12 @@ dependencies = [ "redis>=5.2.0", "django-redis>=5.4.0", "hiredis>=3.1.0", - # ============================================================================= # Database & History Tracking # ============================================================================= "django-pghistory>=3.5.2", "django-fsm>=2.8.1", "django-fsm-log>=3.1.0", - # ============================================================================= # Monitoring & Observability # ============================================================================= @@ -75,7 +68,6 @@ dependencies = [ "python-json-logger>=2.0.7", "psutil>=7.0.0", "nplusone>=1.0.0", - # ============================================================================= # Utilities # ============================================================================= @@ -84,6 +76,9 @@ dependencies = [ "django-extensions>=4.1", "werkzeug>=3.1.3", "django-forwardemail>=1.0.0", + "django-turnstile>=0.1.2", + "fido2>=2.0.0", + "qrcode[pil]>=8.2", ] [dependency-groups] diff --git a/backend/scripts/benchmark_queries.py b/backend/scripts/benchmark_queries.py index 88d8f719..98f1e451 100644 --- a/backend/scripts/benchmark_queries.py +++ b/backend/scripts/benchmark_queries.py @@ -12,15 +12,16 @@ Or in Django shell: exec(open('scripts/benchmark_queries.py').read()) """ -import time +import contextlib import statistics +import time +from collections.abc import Callable from functools import wraps -from typing import Callable, Any, List, Dict +from typing import Any +from django.conf import settings from django.db import connection, reset_queries from django.test.utils import CaptureQueriesContext -from django.conf import settings - # Ensure debug mode for query logging if not settings.DEBUG: @@ -31,7 +32,7 @@ def benchmark(name: str, iterations: int = 5): """Decorator to benchmark a function.""" def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(*args, **kwargs) -> Dict[str, Any]: + def wrapper(*args, **kwargs) -> dict[str, Any]: times = [] query_counts = [] @@ -40,7 +41,7 @@ def benchmark(name: str, iterations: int = 5): with CaptureQueriesContext(connection) as context: start = time.perf_counter() - result = func(*args, **kwargs) + func(*args, **kwargs) end = time.perf_counter() times.append((end - start) * 1000) # Convert to ms @@ -61,7 +62,7 @@ def benchmark(name: str, iterations: int = 5): return decorator -def print_benchmark_result(result: Dict[str, Any]) -> None: +def print_benchmark_result(result: dict[str, Any]) -> None: """Pretty print benchmark results.""" print(f"\n{'='*60}") print(f"Benchmark: {result['name']}") @@ -72,9 +73,9 @@ def print_benchmark_result(result: Dict[str, Any]) -> None: print(f" Iterations: {result['iterations']}") -def run_benchmarks() -> List[Dict[str, Any]]: +def run_benchmarks() -> list[dict[str, Any]]: """Run all benchmarks and return results.""" - from apps.parks.models import Park, Company + from apps.parks.models import Company, Park from apps.rides.models import Ride results = [] @@ -129,10 +130,8 @@ def run_benchmarks() -> List[Dict[str, Any]]: rides = Ride.objects.with_coaster_stats()[:20] for ride in rides: _ = ride.park - try: + with contextlib.suppress(Exception): _ = ride.coaster_stats - except Exception: - pass return list(rides) results.append(bench_ride_with_coaster_stats()) @@ -166,7 +165,7 @@ def run_benchmarks() -> List[Dict[str, Any]]: return results -def print_summary(results: List[Dict[str, Any]]) -> None: +def print_summary(results: list[dict[str, Any]]) -> None: """Print a summary table of all benchmarks.""" print("\n" + "="*80) print("BENCHMARK SUMMARY") @@ -180,7 +179,7 @@ def print_summary(results: List[Dict[str, Any]]) -> None: print("="*80) -if __name__ == "__main__" or True: # Always run when executed +if True: # Always run when executed print("\n" + "="*80) print("THRILLWIKI QUERY PERFORMANCE BENCHMARKS") print("="*80) diff --git a/backend/stubs/environ.pyi b/backend/stubs/environ.pyi index 0a1ab4f1..1d8b44bd 100644 --- a/backend/stubs/environ.pyi +++ b/backend/stubs/environ.pyi @@ -1,6 +1,7 @@ """Type stubs for django-environ to fix Pylance type checking issues.""" -from typing import Any, Dict, List, Tuple, overload +import builtins +from typing import Any, overload class NoValue: pass @@ -28,35 +29,35 @@ class Env: def bool(self, var_name: str, *, default: Any) -> bool: ... def bool(self, var_name: str, *, default: Any = NoValue()) -> bool: ... @overload - def list(self, var_name: str) -> List[str]: ... + def list(self, var_name: str) -> builtins.list[str]: ... @overload - def list(self, var_name: str, *, default: Any) -> List[str]: ... - def list(self, var_name: str, *, default: Any = NoValue()) -> List[str]: ... + def list(self, var_name: str, *, default: Any) -> builtins.list[str]: ... + def list(self, var_name: str, *, default: Any = NoValue()) -> builtins.list[str]: ... @overload - def db(self, var_name: str) -> Dict[str, Any]: ... + def db(self, var_name: str) -> builtins.dict[str, Any]: ... @overload - def db(self, var_name: str, *, default: Any) -> Dict[str, Any]: ... - def db(self, var_name: str, *, default: Any = NoValue()) -> Dict[str, Any]: ... + def db(self, var_name: str, *, default: Any) -> builtins.dict[str, Any]: ... + def db(self, var_name: str, *, default: Any = NoValue()) -> builtins.dict[str, Any]: ... @overload - def cache(self, var_name: str) -> Dict[str, Any]: ... + def cache(self, var_name: str) -> builtins.dict[str, Any]: ... @overload - def cache(self, var_name: str, *, default: Any) -> Dict[str, Any]: ... - def cache(self, var_name: str, *, default: Any = NoValue()) -> Dict[str, Any]: ... + def cache(self, var_name: str, *, default: Any) -> builtins.dict[str, Any]: ... + def cache(self, var_name: str, *, default: Any = NoValue()) -> builtins.dict[str, Any]: ... @overload - def email(self, var_name: str) -> Dict[str, Any]: ... + def email(self, var_name: str) -> builtins.dict[str, Any]: ... @overload - def email(self, var_name: str, *, default: Any) -> Dict[str, Any]: ... - def email(self, var_name: str, *, default: Any = NoValue()) -> Dict[str, Any]: ... + def email(self, var_name: str, *, default: Any) -> builtins.dict[str, Any]: ... + def email(self, var_name: str, *, default: Any = NoValue()) -> builtins.dict[str, Any]: ... @overload - def tuple(self, var_name: str) -> Tuple[str, ...]: ... + def tuple(self, var_name: str) -> builtins.tuple[str, ...]: ... @overload - def tuple(self, var_name: str, *, default: Any) -> Tuple[str, ...]: ... - def tuple(self, var_name: str, *, default: Any = NoValue()) -> Tuple[str, ...]: ... + def tuple(self, var_name: str, *, default: Any) -> builtins.tuple[str, ...]: ... + def tuple(self, var_name: str, *, default: Any = NoValue()) -> builtins.tuple[str, ...]: ... @overload - def dict(self, var_name: str) -> Dict[str, Any]: ... + def dict(self, var_name: str) -> builtins.dict[str, Any]: ... @overload - def dict(self, var_name: str, *, default: Any) -> Dict[str, Any]: ... - def dict(self, var_name: str, *, default: Any = NoValue()) -> Dict[str, Any]: ... + def dict(self, var_name: str, *, default: Any) -> builtins.dict[str, Any]: ... + def dict(self, var_name: str, *, default: Any = NoValue()) -> builtins.dict[str, Any]: ... @overload def float(self, var_name: str) -> float: ... @overload diff --git a/backend/stubs/rest_framework/viewsets.pyi b/backend/stubs/rest_framework/viewsets.pyi index 82711f4d..6cc5e091 100644 --- a/backend/stubs/rest_framework/viewsets.pyi +++ b/backend/stubs/rest_framework/viewsets.pyi @@ -1,7 +1,8 @@ -from typing import Any, Type +from typing import Any + from django.db.models import QuerySet from rest_framework.serializers import BaseSerializer class ReadOnlyModelViewSet: def get_queryset(self) -> QuerySet[Any]: ... - def get_serializer_class(self) -> Type[BaseSerializer]: ... + def get_serializer_class(self) -> type[BaseSerializer]: ... diff --git a/backend/test_avatar_upload.py b/backend/test_avatar_upload.py index eb52b8b7..42830782 100644 --- a/backend/test_avatar_upload.py +++ b/backend/test_avatar_upload.py @@ -97,7 +97,7 @@ def main(): upload_url, cloudflare_id = step1_get_upload_url() # Step 2: Upload image - upload_result = step2_upload_image(upload_url) + step2_upload_image(upload_url) # Step 3: Save avatar reference save_result = step3_save_avatar(cloudflare_id) diff --git a/backend/tests/accessibility/test_wcag_compliance.py b/backend/tests/accessibility/test_wcag_compliance.py index 26d60e2f..0d096f81 100644 --- a/backend/tests/accessibility/test_wcag_compliance.py +++ b/backend/tests/accessibility/test_wcag_compliance.py @@ -21,18 +21,18 @@ dependencies are not installed or if running in CI without browser support. import os import unittest -from django.test import TestCase, LiveServerTestCase, override_settings -from django.urls import reverse + from django.contrib.auth import get_user_model +from django.test import LiveServerTestCase, TestCase, override_settings +from django.urls import reverse # Check if selenium and axe are available try: from selenium import webdriver from selenium.webdriver.chrome.options import Options - from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By - from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait HAS_SELENIUM = True except ImportError: HAS_SELENIUM = False @@ -158,7 +158,7 @@ class WCAGComplianceTests(AccessibilityTestMixin, LiveServerTestCase): cls.driver = webdriver.Chrome(options=chrome_options) cls.driver.implicitly_wait(10) except Exception as e: - raise unittest.SkipTest(f"Chrome WebDriver not available: {e}") + raise unittest.SkipTest(f"Chrome WebDriver not available: {e}") from None @classmethod def tearDownClass(cls): diff --git a/backend/tests/api/test_auth_api.py b/backend/tests/api/test_auth_api.py index 8d478552..4647a882 100644 --- a/backend/tests/api/test_auth_api.py +++ b/backend/tests/api/test_auth_api.py @@ -16,17 +16,11 @@ This module provides extensive test coverage for: Test patterns follow Django styleguide conventions. """ -import pytest -from unittest.mock import patch, MagicMock -from django.test import TestCase -from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase, APIClient +from rest_framework.test import APIClient from tests.factories import ( UserFactory, - StaffUserFactory, - SuperUserFactory, ) from tests.test_utils import EnhancedAPITestCase diff --git a/backend/tests/api/test_error_handling.py b/backend/tests/api/test_error_handling.py index d6c31dad..f9034e9f 100644 --- a/backend/tests/api/test_error_handling.py +++ b/backend/tests/api/test_error_handling.py @@ -6,8 +6,8 @@ with proper error codes, messages, and details. """ from django.test import TestCase -from rest_framework.test import APIClient from rest_framework import status +from rest_framework.test import APIClient class ErrorResponseFormatTestCase(TestCase): diff --git a/backend/tests/api/test_filters.py b/backend/tests/api/test_filters.py index 7c0dd677..6b8eb6b0 100644 --- a/backend/tests/api/test_filters.py +++ b/backend/tests/api/test_filters.py @@ -6,8 +6,8 @@ similar endpoints and behave as expected. """ from django.test import TestCase -from rest_framework.test import APIClient from rest_framework import status +from rest_framework.test import APIClient class FilterParameterNamingTestCase(TestCase): @@ -137,7 +137,7 @@ class FilterMetadataTestCase(TestCase): if response.status_code == status.HTTP_200_OK: data = response.json() if data.get("data") and data["data"].get("categorical"): - for field, options in data["data"]["categorical"].items(): + for _field, options in data["data"]["categorical"].items(): if isinstance(options, list) and options: option = options[0] # Each option should have value and label diff --git a/backend/tests/api/test_pagination.py b/backend/tests/api/test_pagination.py index 4fbb70a1..dd30cb76 100644 --- a/backend/tests/api/test_pagination.py +++ b/backend/tests/api/test_pagination.py @@ -6,8 +6,8 @@ metadata including count, next, previous, page_size, current_page, and total_pag """ from django.test import TestCase -from rest_framework.test import APIClient from rest_framework import status +from rest_framework.test import APIClient class PaginationMetadataTestCase(TestCase): diff --git a/backend/tests/api/test_parks_api.py b/backend/tests/api/test_parks_api.py index 784a29e6..8d0a002d 100644 --- a/backend/tests/api/test_parks_api.py +++ b/backend/tests/api/test_parks_api.py @@ -13,20 +13,18 @@ Test patterns follow Django styleguide conventions with: - Permission and authorization testing """ -import pytest -from unittest.mock import patch, MagicMock -from django.test import TestCase -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APITestCase, APIClient +from unittest.mock import patch -from apps.parks.models import Park, ParkPhoto +from rest_framework import status +from rest_framework.test import APIClient + +from apps.parks.models import Park from tests.factories import ( - UserFactory, + CompanyFactory, + ParkFactory, StaffUserFactory, SuperUserFactory, - ParkFactory, - CompanyFactory, + UserFactory, ) from tests.test_utils import EnhancedAPITestCase @@ -471,7 +469,7 @@ class TestParkAPIQueryOptimization(EnhancedAPITestCase): def test__park_list__uses_select_related(self): """Test that park list uses select_related for optimization.""" # Create multiple parks - for i in range(5): + for _i in range(5): ParkFactory(operator=self.operator) url = '/api/v1/parks/hybrid/' diff --git a/backend/tests/api/test_response_format.py b/backend/tests/api/test_response_format.py index d9bdf165..7abca98f 100644 --- a/backend/tests/api/test_response_format.py +++ b/backend/tests/api/test_response_format.py @@ -5,10 +5,9 @@ These tests verify that all API endpoints return responses in the standardized format with proper success/error indicators, data nesting, and error codes. """ -import pytest from django.test import TestCase -from rest_framework.test import APIClient from rest_framework import status +from rest_framework.test import APIClient class ResponseFormatTestCase(TestCase): diff --git a/backend/tests/api/test_rides_api.py b/backend/tests/api/test_rides_api.py index d9a3e44a..f0274d00 100644 --- a/backend/tests/api/test_rides_api.py +++ b/backend/tests/api/test_rides_api.py @@ -15,24 +15,18 @@ This module provides extensive test coverage for: Test patterns follow Django styleguide conventions. """ -import pytest -from unittest.mock import patch, MagicMock -from django.test import TestCase -from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase, APIClient +from rest_framework.test import APIClient from tests.factories import ( - UserFactory, - StaffUserFactory, - SuperUserFactory, + CoasterFactory, + DesignerCompanyFactory, + ManufacturerCompanyFactory, ParkFactory, RideFactory, - CoasterFactory, - CompanyFactory, - ManufacturerCompanyFactory, - DesignerCompanyFactory, RideModelFactory, + StaffUserFactory, + UserFactory, ) from tests.test_utils import EnhancedAPITestCase @@ -752,7 +746,7 @@ class TestRideAPIQueryOptimization(EnhancedAPITestCase): def test__ride_list__uses_select_related(self): """Test that ride list uses select_related for optimization.""" # Create multiple rides - for i in range(5): + for _i in range(5): RideFactory(park=self.park) response = self.client.get('/api/v1/rides/') diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 676751a3..f0945d6f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,7 +9,6 @@ import os import django import pytest -from django.conf import settings # Configure Django settings before any tests run os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.test") diff --git a/backend/tests/e2e/conftest.py b/backend/tests/e2e/conftest.py index 34c5254e..53693a66 100644 --- a/backend/tests/e2e/conftest.py +++ b/backend/tests/e2e/conftest.py @@ -1,6 +1,8 @@ -import pytest +import contextlib import tempfile from pathlib import Path + +import pytest from playwright.sync_api import Page @@ -212,9 +214,10 @@ def admin_page(page: Page, live_server, setup_test_data): def submission_pending(db): """Create a pending EditSubmission for FSM testing.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -246,19 +249,18 @@ def submission_pending(db): yield submission # Cleanup - try: + with contextlib.suppress(Exception): submission.delete() - except Exception: - pass @pytest.fixture def submission_approved(db): """Create an approved EditSubmission for FSM testing.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -285,10 +287,8 @@ def submission_approved(db): yield submission - try: + with contextlib.suppress(Exception): submission.delete() - except Exception: - pass @pytest.fixture @@ -322,9 +322,10 @@ def park_closed_temp(db): @pytest.fixture def park_closed_perm(db): """Create a permanently closed Park for FSM testing.""" - from tests.factories import ParkFactory from datetime import date, timedelta + from tests.factories import ParkFactory + park = ParkFactory( name="FSM Test Park Closed Perm", slug="fsm-test-park-closed-perm", @@ -368,9 +369,10 @@ def ride_sbno(db, park_operating): @pytest.fixture def ride_closed_perm(db, park_operating): """Create a permanently closed Ride for FSM testing.""" - from tests.factories import RideFactory from datetime import date, timedelta + from tests.factories import RideFactory + ride = RideFactory( name="FSM Test Ride Closed Perm", slug="fsm-test-ride-closed-perm", @@ -386,6 +388,7 @@ def ride_closed_perm(db, park_operating): def queue_item_pending(db): """Create a pending ModerationQueue item for FSM testing.""" from django.contrib.auth import get_user_model + from apps.moderation.models import ModerationQueue User = get_user_model() @@ -406,16 +409,15 @@ def queue_item_pending(db): yield queue_item - try: + with contextlib.suppress(Exception): queue_item.delete() - except Exception: - pass @pytest.fixture def bulk_operation_pending(db): """Create a pending BulkOperation for FSM testing.""" from django.contrib.auth import get_user_model + from apps.moderation.models import BulkOperation User = get_user_model() @@ -437,10 +439,8 @@ def bulk_operation_pending(db): yield operation - try: + with contextlib.suppress(Exception): operation.delete() - except Exception: - pass # ============================================================================= diff --git a/backend/tests/e2e/test_auth.py b/backend/tests/e2e/test_auth.py index b9666095..c7fe7dde 100644 --- a/backend/tests/e2e/test_auth.py +++ b/backend/tests/e2e/test_auth.py @@ -1,4 +1,4 @@ -from playwright.sync_api import expect, Page +from playwright.sync_api import Page, expect def test_login_page(page: Page): diff --git a/backend/tests/e2e/test_fsm_error_handling.py b/backend/tests/e2e/test_fsm_error_handling.py index 57bbfacc..49786d47 100644 --- a/backend/tests/e2e/test_fsm_error_handling.py +++ b/backend/tests/e2e/test_fsm_error_handling.py @@ -17,6 +17,8 @@ These tests verify: - User-friendly error messages are displayed """ +import re + import pytest from playwright.sync_api import Page, expect @@ -73,9 +75,10 @@ class TestInvalidTransitionErrors: ): """Test that trying to approve an already-approved submission shows error.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -351,9 +354,10 @@ class TestConfirmationDialogs: ): """Test that confirmation dialog appears for reject transition.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -414,9 +418,10 @@ class TestConfirmationDialogs: ): """Test that canceling the confirmation dialog prevents the transition.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -472,9 +477,10 @@ class TestConfirmationDialogs: ): """Test that accepting the confirmation dialog executes the transition.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -629,7 +635,7 @@ class TestToastNotificationBehavior: expect(toast).to_be_visible(timeout=5000) # Should have error/danger styling (red) - expect(toast).to_have_class(/error|danger|bg-red|text-red/) + expect(toast).to_have_class(re.compile(r"error|danger|bg-red|text-red")) def test_success_toast_has_correct_styling( self, mod_page: Page, live_server, db @@ -665,4 +671,4 @@ class TestToastNotificationBehavior: expect(toast).to_be_visible(timeout=5000) # Should have success styling (green) - expect(toast).to_have_class(/success|bg-green|text-green/) + expect(toast).to_have_class(re.compile(r"success|bg-green|text-green")) diff --git a/backend/tests/e2e/test_fsm_permissions.py b/backend/tests/e2e/test_fsm_permissions.py index a5dfade6..74063ac7 100644 --- a/backend/tests/e2e/test_fsm_permissions.py +++ b/backend/tests/e2e/test_fsm_permissions.py @@ -89,9 +89,10 @@ class TestRegularUserPermissions: ): """Test that regular users cannot approve submissions.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -229,9 +230,10 @@ class TestModeratorPermissions: ): """Test that moderators CAN see and use approve button.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -501,5 +503,5 @@ class TestTransitionButtonVisibility: assert visible, "Expected demolish or relocate button for CLOSED_PERM state" # Reopen should still be visible to restore to operating - reopen_btn = status_actions.get_by_role("button", name="Reopen") + status_actions.get_by_role("button", name="Reopen") # May or may not be visible depending on FSM configuration diff --git a/backend/tests/e2e/test_moderation_fsm.py b/backend/tests/e2e/test_moderation_fsm.py index 60b675f2..66e60892 100644 --- a/backend/tests/e2e/test_moderation_fsm.py +++ b/backend/tests/e2e/test_moderation_fsm.py @@ -15,14 +15,15 @@ These tests verify: """ import pytest -from playwright.sync_api import Page, expect from django.contrib.contenttypes.models import ContentType +from playwright.sync_api import Page, expect @pytest.fixture def pending_submission(db): """Create a pending EditSubmission for testing.""" from django.contrib.auth import get_user_model + from apps.moderation.models import EditSubmission from apps.parks.models import Park @@ -63,9 +64,10 @@ def pending_submission(db): def pending_photo_submission(db): """Create a pending PhotoSubmission for testing.""" from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from apps.moderation.models import PhotoSubmission from apps.parks.models import Park - from django.contrib.contenttypes.models import ContentType User = get_user_model() @@ -146,7 +148,6 @@ class TestEditSubmissionTransitions: expect(status_badge).to_contain_text("Approved") # Verify database state - from apps.moderation.models import EditSubmission pending_submission.refresh_from_db() assert pending_submission.status == "APPROVED" @@ -180,7 +181,6 @@ class TestEditSubmissionTransitions: expect(status_badge).to_contain_text("Rejected") # Verify database state - from apps.moderation.models import EditSubmission pending_submission.refresh_from_db() assert pending_submission.status == "REJECTED" @@ -214,7 +214,6 @@ class TestEditSubmissionTransitions: expect(status_badge).to_contain_text("Escalated") # Verify database state - from apps.moderation.models import EditSubmission pending_submission.refresh_from_db() assert pending_submission.status == "ESCALATED" @@ -254,7 +253,6 @@ class TestPhotoSubmissionTransitions: expect(toast).to_contain_text("approved") # Verify database state - from apps.moderation.models import PhotoSubmission pending_photo_submission.refresh_from_db() assert pending_photo_submission.status == "APPROVED" @@ -290,7 +288,6 @@ class TestPhotoSubmissionTransitions: expect(toast).to_contain_text("rejected") # Verify database state - from apps.moderation.models import PhotoSubmission pending_photo_submission.refresh_from_db() assert pending_photo_submission.status == "REJECTED" @@ -302,6 +299,7 @@ class TestModerationQueueTransitions: def pending_queue_item(self, db): """Create a pending ModerationQueue item for testing.""" from django.contrib.auth import get_user_model + from apps.moderation.models import ModerationQueue User = get_user_model() @@ -346,7 +344,6 @@ class TestModerationQueueTransitions: expect(status_badge).to_contain_text("In Progress", timeout=5000) # Verify database state - from apps.moderation.models import ModerationQueue pending_queue_item.refresh_from_db() assert pending_queue_item.status == "IN_PROGRESS" @@ -376,7 +373,6 @@ class TestModerationQueueTransitions: toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000) - from apps.moderation.models import ModerationQueue pending_queue_item.refresh_from_db() assert pending_queue_item.status == "COMPLETED" @@ -388,6 +384,7 @@ class TestBulkOperationTransitions: def pending_bulk_operation(self, db): """Create a pending BulkOperation for testing.""" from django.contrib.auth import get_user_model + from apps.moderation.models import BulkOperation User = get_user_model() @@ -438,7 +435,6 @@ class TestBulkOperationTransitions: expect(toast).to_contain_text("cancel") # Verify database state - from apps.moderation.models import BulkOperation pending_bulk_operation.refresh_from_db() assert pending_bulk_operation.status == "CANCELLED" @@ -470,7 +466,7 @@ class TestTransitionLoadingStates: # Check for htmx-indicator visibility (may be brief) # The indicator should become visible during the request - loading_indicator = submission_row.locator('.htmx-indicator') + submission_row.locator('.htmx-indicator') # Wait for transition to complete toast = mod_page.locator('[data-toast]') diff --git a/backend/tests/e2e/test_park_browsing.py b/backend/tests/e2e/test_park_browsing.py index 65f957c1..c2690718 100644 --- a/backend/tests/e2e/test_park_browsing.py +++ b/backend/tests/e2e/test_park_browsing.py @@ -83,7 +83,7 @@ class TestParkDetailPage: page.goto(f"{live_server.url}/parks/{park.slug}/") # Look for rides section/tab - rides_section = page.locator( + page.locator( "[data-testid='rides-section'], #rides, [role='tabpanel']" ) @@ -162,7 +162,7 @@ class TestParkNavigation: # Click parks link in breadcrumb breadcrumb.get_by_role("link", name="Parks").click() - expect(page).to_have_url(f"**/parks/**") + expect(page).to_have_url("**/parks/**") def test__back_button__returns_to_previous_page( self, page: Page, live_server, parks_data @@ -179,4 +179,4 @@ class TestParkNavigation: # Go back page.go_back() - expect(page).to_have_url(f"**/parks/**") + expect(page).to_have_url("**/parks/**") diff --git a/backend/tests/e2e/test_park_ride_fsm.py b/backend/tests/e2e/test_park_ride_fsm.py index d4a161b2..e67b1f43 100644 --- a/backend/tests/e2e/test_park_ride_fsm.py +++ b/backend/tests/e2e/test_park_ride_fsm.py @@ -13,15 +13,16 @@ These tests verify: - StateLog entry created in database """ +import re +from datetime import date, timedelta + import pytest from playwright.sync_api import Page, expect -from datetime import date, timedelta @pytest.fixture def operating_park(db): """Create an operating Park for testing status transitions.""" - from apps.parks.models import Park from tests.factories import ParkFactory # Use factory to create a complete park @@ -93,7 +94,6 @@ class TestParkStatusTransitions: expect(status_badge).to_contain_text("Temporarily Closed", timeout=5000) # Verify database state - from apps.parks.models import Park operating_park.refresh_from_db() assert operating_park.status == "CLOSED_TEMP" @@ -127,7 +127,6 @@ class TestParkStatusTransitions: expect(status_badge).to_contain_text("Operating", timeout=5000) # Verify database state - from apps.parks.models import Park operating_park.refresh_from_db() assert operating_park.status == "OPERATING" @@ -165,7 +164,6 @@ class TestParkStatusTransitions: expect(status_badge).to_contain_text("Permanently Closed", timeout=5000) # Verify database state - from apps.parks.models import Park operating_park.refresh_from_db() assert operating_park.status == "CLOSED_PERM" @@ -206,7 +204,6 @@ class TestParkStatusTransitions: expect(status_badge).to_contain_text("Demolished", timeout=5000) # Verify database state - from apps.parks.models import Park operating_park.refresh_from_db() assert operating_park.status == "DEMOLISHED" @@ -274,7 +271,6 @@ class TestRideStatusTransitions: expect(status_badge).to_contain_text("Temporarily Closed", timeout=5000) # Verify database state - from apps.rides.models import Ride operating_ride.refresh_from_db() assert operating_ride.status == "CLOSED_TEMP" @@ -310,7 +306,6 @@ class TestRideStatusTransitions: expect(status_badge).to_contain_text("SBNO", timeout=5000) # Verify database state - from apps.rides.models import Ride operating_ride.refresh_from_db() assert operating_ride.status == "SBNO" @@ -344,7 +339,6 @@ class TestRideStatusTransitions: expect(status_badge).to_contain_text("Operating", timeout=5000) # Verify database state - from apps.rides.models import Ride operating_ride.refresh_from_db() assert operating_ride.status == "OPERATING" @@ -384,7 +378,6 @@ class TestRideStatusTransitions: expect(status_badge).to_contain_text("Permanently Closed", timeout=5000) # Verify database state - from apps.rides.models import Ride operating_ride.refresh_from_db() assert operating_ride.status == "CLOSED_PERM" @@ -429,7 +422,6 @@ class TestRideStatusTransitions: expect(status_badge).to_contain_text("Demolished", timeout=5000) # Verify database state - from apps.rides.models import Ride operating_ride.refresh_from_db() assert operating_ride.status == "DEMOLISHED" @@ -474,7 +466,6 @@ class TestRideStatusTransitions: expect(status_badge).to_contain_text("Relocated", timeout=5000) # Verify database state - from apps.rides.models import Ride operating_ride.refresh_from_db() assert operating_ride.status == "RELOCATED" @@ -507,7 +498,6 @@ class TestRideClosingWorkflow: expect(status_badge).to_contain_text("Closing", timeout=5000) # Verify database state - from apps.rides.models import Ride operating_ride.refresh_from_db() assert operating_ride.status == "CLOSING" else: @@ -549,7 +539,7 @@ class TestStatusBadgeStyling: mod_page.wait_for_load_state("networkidle") status_badge = mod_page.locator('[data-status-badge]') - expect(status_badge).to_have_class(/bg-green|text-green|success/) + expect(status_badge).to_have_class(re.compile(r"bg-green|text-green|success")) def test_closed_temp_status_badge_style( self, mod_page: Page, operating_park, live_server @@ -562,7 +552,7 @@ class TestStatusBadgeStyling: mod_page.wait_for_load_state("networkidle") status_badge = mod_page.locator('[data-status-badge]') - expect(status_badge).to_have_class(/bg-yellow|text-yellow|warning/) + expect(status_badge).to_have_class(re.compile(r"bg-yellow|text-yellow|warning")) def test_closed_perm_status_badge_style( self, mod_page: Page, operating_park, live_server @@ -575,7 +565,7 @@ class TestStatusBadgeStyling: mod_page.wait_for_load_state("networkidle") status_badge = mod_page.locator('[data-status-badge]') - expect(status_badge).to_have_class(/bg-red|text-red|danger/) + expect(status_badge).to_have_class(re.compile(r"bg-red|text-red|danger")) def test_demolished_status_badge_style( self, mod_page: Page, operating_park, live_server @@ -588,4 +578,4 @@ class TestStatusBadgeStyling: mod_page.wait_for_load_state("networkidle") status_badge = mod_page.locator('[data-status-badge]') - expect(status_badge).to_have_class(/bg-gray|text-gray|muted/) + expect(status_badge).to_have_class(re.compile(r"bg-gray|text-gray|muted")) diff --git a/backend/tests/e2e/test_parks.py b/backend/tests/e2e/test_parks.py index 6290b42e..7a6d1486 100644 --- a/backend/tests/e2e/test_parks.py +++ b/backend/tests/e2e/test_parks.py @@ -1,4 +1,4 @@ -from playwright.sync_api import expect, Page +from playwright.sync_api import Page, expect def test_parks_list_page(page: Page): diff --git a/backend/tests/e2e/test_profiles.py b/backend/tests/e2e/test_profiles.py index 3fd5226c..e5ae2d35 100644 --- a/backend/tests/e2e/test_profiles.py +++ b/backend/tests/e2e/test_profiles.py @@ -1,4 +1,4 @@ -from playwright.sync_api import expect, Page +from playwright.sync_api import Page, expect def test_profile_page(page: Page): diff --git a/backend/tests/e2e/test_review_submission.py b/backend/tests/e2e/test_review_submission.py index f1038ec1..36afd674 100644 --- a/backend/tests/e2e/test_review_submission.py +++ b/backend/tests/e2e/test_review_submission.py @@ -168,7 +168,6 @@ class TestReviewEditing: def test__own_review__shows_edit_button(self, auth_page: Page, live_server, test_review): """Test user's own review shows edit button.""" # Navigate to reviews after creating one - park_url = auth_page.url # Look for edit button on own review edit_button = auth_page.locator( @@ -324,7 +323,7 @@ class TestRideReviews: intensity_field = auth_page.locator( "select[name='intensity'], input[name='intensity']" ) - wait_time_field = auth_page.locator( + auth_page.locator( "input[name='wait_time'], select[name='wait_time']" ) diff --git a/backend/tests/e2e/test_reviews.py b/backend/tests/e2e/test_reviews.py index 3bdd1df5..dcf6352a 100644 --- a/backend/tests/e2e/test_reviews.py +++ b/backend/tests/e2e/test_reviews.py @@ -1,4 +1,4 @@ -from playwright.sync_api import expect, Page +from playwright.sync_api import Page, expect def test_reviews_list_page(page: Page): diff --git a/backend/tests/e2e/test_rides.py b/backend/tests/e2e/test_rides.py index 28331210..7ed2a8e7 100644 --- a/backend/tests/e2e/test_rides.py +++ b/backend/tests/e2e/test_rides.py @@ -1,4 +1,4 @@ -from playwright.sync_api import expect, Page +from playwright.sync_api import Page, expect def test_rides_list_page(page: Page): diff --git a/backend/tests/factories.py b/backend/tests/factories.py index dadb1db1..b97d8dc1 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -4,11 +4,11 @@ Following Django styleguide pattern for test data creation using factory_boy. """ import factory -from factory import fuzzy -from factory.django import DjangoModelFactory from django.contrib.auth import get_user_model from django.contrib.gis.geos import Point from django.utils.text import slugify +from factory import fuzzy +from factory.django import DjangoModelFactory User = get_user_model() diff --git a/backend/tests/forms/test_park_forms.py b/backend/tests/forms/test_park_forms.py index 7f579cee..680e66ed 100644 --- a/backend/tests/forms/test_park_forms.py +++ b/backend/tests/forms/test_park_forms.py @@ -4,21 +4,18 @@ Tests for Park forms. Following Django styleguide pattern: test______ """ + import pytest -from decimal import Decimal -from unittest.mock import Mock, patch, MagicMock from django.test import TestCase from apps.parks.forms import ( + ParkAutocomplete, ParkForm, ParkSearchForm, - ParkAutocomplete, ) - from tests.factories import ( - ParkFactory, OperatorCompanyFactory, - LocationFactory, + ParkFactory, ) diff --git a/backend/tests/forms/test_ride_forms.py b/backend/tests/forms/test_ride_forms.py index d96bb453..da34eb96 100644 --- a/backend/tests/forms/test_ride_forms.py +++ b/backend/tests/forms/test_ride_forms.py @@ -4,21 +4,20 @@ Tests for Ride forms. Following Django styleguide pattern: test______ """ + import pytest -from unittest.mock import Mock, patch, MagicMock from django.test import TestCase from apps.rides.forms import ( RideForm, RideSearchForm, ) - from tests.factories import ( + DesignerCompanyFactory, + ManufacturerCompanyFactory, + ParkAreaFactory, ParkFactory, RideFactory, - ParkAreaFactory, - ManufacturerCompanyFactory, - DesignerCompanyFactory, RideModelFactory, ) diff --git a/backend/tests/integration/test_fsm_transition_view.py b/backend/tests/integration/test_fsm_transition_view.py index 71959c2d..b2991f96 100644 --- a/backend/tests/integration/test_fsm_transition_view.py +++ b/backend/tests/integration/test_fsm_transition_view.py @@ -12,11 +12,11 @@ These are faster than E2E tests and don't require Playwright. """ import json -import pytest -from django.test import TestCase, Client -from django.urls import reverse + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.test import Client, TestCase +from django.urls import reverse User = get_user_model() @@ -730,9 +730,10 @@ class TestFSMTransitionViewStateLog(TestCase): def test_transition_creates_state_log(self): """Test that FSM transition creates a StateLog entry.""" + from django_fsm_log.models import StateLog + from apps.moderation.models import EditSubmission from apps.parks.models import Park - from django_fsm_log.models import StateLog park = Park.objects.first() if not park: diff --git a/backend/tests/integration/test_fsm_transition_workflow.py b/backend/tests/integration/test_fsm_transition_workflow.py index 61ef68b2..88e86a3a 100644 --- a/backend/tests/integration/test_fsm_transition_workflow.py +++ b/backend/tests/integration/test_fsm_transition_workflow.py @@ -5,19 +5,16 @@ These tests verify the complete state transition workflows for Parks and Rides using the FSM implementation. """ -import pytest from datetime import date, timedelta -from django.test import TestCase -from django.core.exceptions import ValidationError -from apps.parks.models import Park -from apps.rides.models import Ride +import pytest +from django.test import TestCase from tests.factories import ( + ParkAreaFactory, ParkFactory, RideFactory, UserFactory, - ParkAreaFactory, ) diff --git a/backend/tests/integration/test_park_creation_workflow.py b/backend/tests/integration/test_park_creation_workflow.py index 7247c269..6cb72cdc 100644 --- a/backend/tests/integration/test_park_creation_workflow.py +++ b/backend/tests/integration/test_park_creation_workflow.py @@ -6,18 +6,15 @@ validation, location creation, and related operations. """ import pytest -from django.test import TestCase, TransactionTestCase -from django.db import transaction +from django.test import TestCase -from apps.parks.models import Park, ParkArea, ParkReview from apps.parks.forms import ParkForm - from tests.factories import ( - ParkFactory, - ParkAreaFactory, OperatorCompanyFactory, - UserFactory, + ParkAreaFactory, + ParkFactory, RideFactory, + UserFactory, ) @@ -61,9 +58,9 @@ class TestParkCreationWorkflow(TestCase): park = ParkFactory() # Add areas - area1 = ParkAreaFactory(park=park, name="Main Entrance") - area2 = ParkAreaFactory(park=park, name="Thrill Zone") - area3 = ParkAreaFactory(park=park, name="Kids Area") + ParkAreaFactory(park=park, name="Main Entrance") + ParkAreaFactory(park=park, name="Thrill Zone") + ParkAreaFactory(park=park, name="Kids Area") # Verify structure assert park.areas.count() == 3 @@ -156,7 +153,7 @@ class TestParkReviewWorkflow(TestCase): from tests.factories import ParkReviewFactory - review1 = ParkReviewFactory(park=park, user=user1, rating=10, is_published=True) + ParkReviewFactory(park=park, user=user1, rating=10, is_published=True) review2 = ParkReviewFactory(park=park, user=user2, rating=2, is_published=True) # Unpublish the low rating diff --git a/backend/tests/integration/test_photo_upload_workflow.py b/backend/tests/integration/test_photo_upload_workflow.py index 3b45b047..12c91ec8 100644 --- a/backend/tests/integration/test_photo_upload_workflow.py +++ b/backend/tests/integration/test_photo_upload_workflow.py @@ -5,22 +5,21 @@ These tests verify the complete workflow of photo uploads including validation, processing, and moderation. """ -import pytest from unittest.mock import Mock, patch -from django.test import TestCase + +import pytest from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase from apps.parks.models import ParkPhoto -from apps.rides.models import RidePhoto from apps.parks.services.media_service import ParkMediaService - from tests.factories import ( ParkFactory, - RideFactory, ParkPhotoFactory, + RideFactory, RidePhotoFactory, - UserFactory, StaffUserFactory, + UserFactory, ) @@ -215,9 +214,9 @@ class TestRidePhotoWorkflow(TestCase): """Test ride photos can have different types.""" ride = RideFactory() - exterior = RidePhotoFactory(ride=ride, photo_type="exterior") - queue = RidePhotoFactory(ride=ride, photo_type="queue") - onride = RidePhotoFactory(ride=ride, photo_type="onride") + RidePhotoFactory(ride=ride, photo_type="exterior") + RidePhotoFactory(ride=ride, photo_type="queue") + RidePhotoFactory(ride=ride, photo_type="onride") assert ride.photos.filter(photo_type="exterior").count() == 1 assert ride.photos.filter(photo_type="queue").count() == 1 diff --git a/backend/tests/managers/test_core_managers.py b/backend/tests/managers/test_core_managers.py index 0da9576f..dacbf904 100644 --- a/backend/tests/managers/test_core_managers.py +++ b/backend/tests/managers/test_core_managers.py @@ -4,31 +4,13 @@ Tests for Core managers and querysets. Following Django styleguide pattern: test______ """ + import pytest from django.test import TestCase -from django.utils import timezone -from datetime import timedelta -from unittest.mock import Mock, patch - -from apps.core.managers import ( - BaseQuerySet, - BaseManager, - LocationQuerySet, - LocationManager, - ReviewableQuerySet, - ReviewableManager, - HierarchicalQuerySet, - HierarchicalManager, - TimestampedQuerySet, - TimestampedManager, - StatusQuerySet, - StatusManager, -) from tests.factories import ( ParkFactory, ParkReviewFactory, - RideFactory, UserFactory, ) diff --git a/backend/tests/managers/test_park_managers.py b/backend/tests/managers/test_park_managers.py index 05f9dad5..df55ce95 100644 --- a/backend/tests/managers/test_park_managers.py +++ b/backend/tests/managers/test_park_managers.py @@ -4,32 +4,23 @@ Tests for Park managers and querysets. Following Django styleguide pattern: test______ """ + import pytest from django.test import TestCase -from django.utils import timezone -from datetime import timedelta -from apps.parks.models import Park, ParkArea, ParkReview, Company from apps.parks.managers import ( ParkQuerySet, - ParkManager, - ParkAreaQuerySet, - ParkAreaManager, - ParkReviewQuerySet, - ParkReviewManager, - CompanyQuerySet, - CompanyManager, ) - +from apps.parks.models import Company, Park, ParkArea, ParkReview from tests.factories import ( - ParkFactory, + CoasterFactory, + ManufacturerCompanyFactory, + OperatorCompanyFactory, ParkAreaFactory, + ParkFactory, ParkReviewFactory, RideFactory, - CoasterFactory, UserFactory, - OperatorCompanyFactory, - ManufacturerCompanyFactory, ) @@ -290,7 +281,7 @@ class TestParkReviewQuerySet(TestCase): """Test moderation_required filters reviews needing moderation.""" user1 = UserFactory() user2 = UserFactory() - published = ParkReviewFactory(is_published=True, user=user1) + ParkReviewFactory(is_published=True, user=user1) unpublished = ParkReviewFactory(is_published=False, user=user2) result = ParkReview.objects.moderation_required() diff --git a/backend/tests/managers/test_ride_managers.py b/backend/tests/managers/test_ride_managers.py index c5726070..c642284c 100644 --- a/backend/tests/managers/test_ride_managers.py +++ b/backend/tests/managers/test_ride_managers.py @@ -6,29 +6,22 @@ Following Django styleguide pattern: test__________ """ -import pytest -import json -from unittest.mock import Mock, patch, MagicMock -from django.test import TestCase, RequestFactory, override_settings -from django.http import JsonResponse, HttpResponse +from unittest.mock import Mock, patch + +from django.http import HttpResponse, JsonResponse +from django.test import RequestFactory, TestCase, override_settings from apps.api.v1.middleware import ( ContractValidationMiddleware, diff --git a/backend/tests/serializers/test_account_serializers.py b/backend/tests/serializers/test_account_serializers.py index e24d0f2b..627e2be2 100644 --- a/backend/tests/serializers/test_account_serializers.py +++ b/backend/tests/serializers/test_account_serializers.py @@ -4,29 +4,26 @@ Tests for Account serializers. Following Django styleguide pattern: test______ """ +from unittest.mock import Mock, patch + import pytest -from unittest.mock import Mock, patch, MagicMock -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase from apps.accounts.serializers import ( - UserSerializer, LoginSerializer, - SignupSerializer, - PasswordResetSerializer, PasswordChangeSerializer, + PasswordResetSerializer, + SignupSerializer, SocialProviderSerializer, + UserSerializer, ) - from apps.api.v1.accounts.serializers import ( UserProfileCreateInputSerializer, - UserProfileUpdateInputSerializer, UserProfileOutputSerializer, - + UserProfileUpdateInputSerializer, ) - from tests.factories import ( UserFactory, - StaffUserFactory, ) @@ -169,7 +166,7 @@ class TestSignupSerializer(TestCase): def test__validate_email__duplicate_email__returns_error(self): """Test validation fails with duplicate email.""" - existing_user = UserFactory(email="existing@example.com") + UserFactory(email="existing@example.com") data = { "username": "newuser", "email": "existing@example.com", @@ -185,7 +182,7 @@ class TestSignupSerializer(TestCase): def test__validate_email__case_insensitive__returns_error(self): """Test email validation is case insensitive.""" - existing_user = UserFactory(email="existing@example.com") + UserFactory(email="existing@example.com") data = { "username": "newuser", "email": "EXISTING@EXAMPLE.COM", @@ -201,7 +198,7 @@ class TestSignupSerializer(TestCase): def test__validate_username__duplicate_username__returns_error(self): """Test validation fails with duplicate username.""" - existing_user = UserFactory(username="existinguser") + UserFactory(username="existinguser") data = { "username": "existinguser", "email": "new@example.com", @@ -262,7 +259,7 @@ class TestPasswordResetSerializer(TestCase): def test__validate__valid_email__returns_normalized_email(self): """Test validation normalizes email.""" - user = UserFactory(email="test@example.com") + UserFactory(email="test@example.com") data = {"email": " TEST@EXAMPLE.COM "} serializer = PasswordResetSerializer(data=data) @@ -302,7 +299,7 @@ class TestPasswordResetSerializer(TestCase): @patch("apps.accounts.serializers.EmailService.send_email") def test__save__existing_user__sends_email(self, mock_send_email): """Test save sends email for existing user.""" - user = UserFactory(email="reset@example.com") + UserFactory(email="reset@example.com") data = {"email": "reset@example.com"} factory = RequestFactory() diff --git a/backend/tests/serializers/test_park_serializers.py b/backend/tests/serializers/test_park_serializers.py index f7661a95..40bc023a 100644 --- a/backend/tests/serializers/test_park_serializers.py +++ b/backend/tests/serializers/test_park_serializers.py @@ -4,27 +4,27 @@ Tests for Park serializers. Following Django styleguide pattern: test______ """ +from unittest.mock import Mock + import pytest -from unittest.mock import Mock, MagicMock from django.test import TestCase from apps.api.v1.parks.serializers import ( - ParkPhotoOutputSerializer, - ParkPhotoCreateInputSerializer, - ParkPhotoUpdateInputSerializer, - ParkPhotoListOutputSerializer, - ParkPhotoApprovalInputSerializer, - ParkPhotoStatsOutputSerializer, - ParkPhotoSerializer, HybridParkSerializer, + ParkPhotoApprovalInputSerializer, + ParkPhotoCreateInputSerializer, + ParkPhotoListOutputSerializer, + ParkPhotoOutputSerializer, + ParkPhotoSerializer, + ParkPhotoStatsOutputSerializer, + ParkPhotoUpdateInputSerializer, ParkSerializer, ) - from tests.factories import ( + CloudflareImageFactory, ParkFactory, ParkPhotoFactory, UserFactory, - CloudflareImageFactory, ) diff --git a/backend/tests/serializers/test_ride_serializers.py b/backend/tests/serializers/test_ride_serializers.py index ec111ead..8f6deb94 100644 --- a/backend/tests/serializers/test_ride_serializers.py +++ b/backend/tests/serializers/test_ride_serializers.py @@ -4,31 +4,30 @@ Tests for Ride serializers. Following Django styleguide pattern: test______ """ +from unittest.mock import Mock + import pytest -from unittest.mock import Mock, MagicMock from django.test import TestCase from apps.api.v1.rides.serializers import ( - RidePhotoOutputSerializer, - RidePhotoCreateInputSerializer, - RidePhotoUpdateInputSerializer, - RidePhotoListOutputSerializer, + HybridRideSerializer, RidePhotoApprovalInputSerializer, + RidePhotoCreateInputSerializer, + RidePhotoListOutputSerializer, + RidePhotoOutputSerializer, + RidePhotoSerializer, RidePhotoStatsOutputSerializer, RidePhotoTypeFilterSerializer, - RidePhotoSerializer, - HybridRideSerializer, + RidePhotoUpdateInputSerializer, RideSerializer, ) - from tests.factories import ( + CloudflareImageFactory, + DesignerCompanyFactory, + ManufacturerCompanyFactory, RideFactory, RidePhotoFactory, - ParkFactory, UserFactory, - CloudflareImageFactory, - ManufacturerCompanyFactory, - DesignerCompanyFactory, ) diff --git a/backend/tests/services/test_park_media_service.py b/backend/tests/services/test_park_media_service.py index a76cb091..0da5d1a5 100644 --- a/backend/tests/services/test_park_media_service.py +++ b/backend/tests/services/test_park_media_service.py @@ -4,20 +4,19 @@ Tests for ParkMediaService. Following Django styleguide pattern: test______ """ +from unittest.mock import Mock, patch + import pytest -from unittest.mock import Mock, patch, MagicMock -from django.test import TestCase from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase -from apps.parks.services.media_service import ParkMediaService from apps.parks.models import ParkPhoto - +from apps.parks.services.media_service import ParkMediaService from tests.factories import ( ParkFactory, ParkPhotoFactory, - UserFactory, StaffUserFactory, - CloudflareImageFactory, + UserFactory, ) @@ -114,7 +113,7 @@ class TestParkMediaServiceGetParkPhotos(TestCase): def test__get_park_photos__primary_first__orders_primary_first(self): """Test get_park_photos with primary_first orders primary photos first.""" park = ParkFactory() - non_primary = ParkPhotoFactory(park=park, is_primary=False, is_approved=True) + ParkPhotoFactory(park=park, is_primary=False, is_approved=True) primary = ParkPhotoFactory(park=park, is_primary=True, is_approved=True) result = ParkMediaService.get_park_photos(park, primary_first=True) diff --git a/backend/tests/services/test_ride_service.py b/backend/tests/services/test_ride_service.py index 368c18cc..9cb02e88 100644 --- a/backend/tests/services/test_ride_service.py +++ b/backend/tests/services/test_ride_service.py @@ -4,22 +4,21 @@ Tests for RideService. Following Django styleguide pattern: test______ """ +from unittest.mock import Mock, patch + import pytest -from unittest.mock import Mock, patch, MagicMock from django.test import TestCase -from django.core.exceptions import ValidationError -from apps.rides.services import RideService from apps.rides.models import Ride - +from apps.rides.services import RideService from tests.factories import ( + DesignerCompanyFactory, + ManufacturerCompanyFactory, + ParkAreaFactory, ParkFactory, RideFactory, RideModelFactory, - ParkAreaFactory, UserFactory, - ManufacturerCompanyFactory, - DesignerCompanyFactory, ) diff --git a/backend/tests/services/test_user_deletion_service.py b/backend/tests/services/test_user_deletion_service.py index 68b369c3..df7e2d6c 100644 --- a/backend/tests/services/test_user_deletion_service.py +++ b/backend/tests/services/test_user_deletion_service.py @@ -4,22 +4,20 @@ Tests for UserDeletionService and AccountService. Following Django styleguide pattern: test______ """ +from unittest.mock import patch + import pytest -from unittest.mock import Mock, patch, MagicMock -from django.test import TestCase, RequestFactory -from django.utils import timezone +from django.test import RequestFactory, TestCase -from apps.accounts.services import UserDeletionService, AccountService from apps.accounts.models import User - +from apps.accounts.services import AccountService, UserDeletionService from tests.factories import ( - UserFactory, - StaffUserFactory, - SuperUserFactory, - ParkReviewFactory, - RideReviewFactory, ParkFactory, + ParkReviewFactory, RideFactory, + RideReviewFactory, + SuperUserFactory, + UserFactory, ) @@ -92,7 +90,7 @@ class TestUserDeletionServiceDeleteUserPreserveSubmissions(TestCase): user_pk = user.pk - result = UserDeletionService.delete_user_preserve_submissions(user) + UserDeletionService.delete_user_preserve_submissions(user) # User should be deleted assert not User.objects.filter(pk=user_pk).exists() @@ -270,7 +268,7 @@ class TestAccountServiceInitiateEmailChange(TestCase): def test__initiate_email_change__duplicate_email__returns_error(self): """Test initiate_email_change returns error for duplicate email.""" - existing_user = UserFactory(email="existing@example.com") + UserFactory(email="existing@example.com") user = UserFactory() factory = RequestFactory() diff --git a/backend/tests/test_factories.py b/backend/tests/test_factories.py index cac12364..45bbe1ac 100644 --- a/backend/tests/test_factories.py +++ b/backend/tests/test_factories.py @@ -3,17 +3,17 @@ Test cases demonstrating the factory pattern usage. Following Django styleguide pattern for test data creation. """ -from django.test import TestCase from django.contrib.auth import get_user_model +from django.test import TestCase from .factories import ( - UserFactory, - ParkFactory, - RideFactory, - ParkReviewFactory, - RideReviewFactory, CompanyFactory, + ParkFactory, + ParkReviewFactory, + RideFactory, + RideReviewFactory, Traits, + UserFactory, ) User = get_user_model() diff --git a/backend/tests/test_parks_api.py b/backend/tests/test_parks_api.py index 1667c7cf..b2f22a4a 100644 --- a/backend/tests/test_parks_api.py +++ b/backend/tests/test_parks_api.py @@ -4,15 +4,15 @@ Comprehensive API endpoint testing with proper naming conventions. """ from django.urls import reverse -from rest_framework.test import APITestCase, APIClient from rest_framework import status +from rest_framework.test import APIClient, APITestCase from apps.parks.models import Park from tests.factories import ( - UserFactory, - StaffUserFactory, CompanyFactory, ParkFactory, + StaffUserFactory, + UserFactory, ) diff --git a/backend/tests/test_parks_models.py b/backend/tests/test_parks_models.py index 006e95f9..fb97fa54 100644 --- a/backend/tests/test_parks_models.py +++ b/backend/tests/test_parks_models.py @@ -4,18 +4,19 @@ Uses proper naming conventions and comprehensive coverage. """ from datetime import date, timedelta -from django.test import TestCase + from django.db import IntegrityError +from django.test import TestCase from django.utils import timezone -from apps.parks.models import Park, Company +from apps.parks.models import Company, Park from tests.factories import ( - UserFactory, CompanyFactory, - ParkFactory, ParkAreaFactory, + ParkFactory, ParkReviewFactory, TestScenarios, + UserFactory, ) diff --git a/backend/tests/test_runner.py b/backend/tests/test_runner.py index 9372e7ee..4f3dc0ae 100644 --- a/backend/tests/test_runner.py +++ b/backend/tests/test_runner.py @@ -1,9 +1,10 @@ #!/usr/bin/env python import os import sys + +import coverage # type: ignore import django from django.test.runner import DiscoverRunner -import coverage # type: ignore def setup_django(): diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py index ca5f2733..46dd2ae7 100644 --- a/backend/tests/test_utils.py +++ b/backend/tests/test_utils.py @@ -3,12 +3,13 @@ Test utilities and helpers following Django styleguide patterns. Provides reusable testing patterns and assertion helpers. """ -from typing import Dict, Any, Optional, List from datetime import date, datetime -from django.test import TestCase +from typing import Any + from django.contrib.auth import get_user_model -from rest_framework.test import APITestCase +from django.test import TestCase from rest_framework import status +from rest_framework.test import APITestCase User = get_user_model() @@ -22,8 +23,8 @@ class ApiTestMixin: *, status_code: int = status.HTTP_200_OK, response_status: str = "success", - data_type: Optional[type] = None, - contains_fields: Optional[List[str]] = None, + data_type: type | None = None, + contains_fields: list[str] | None = None, ): """ Assert API response has correct structure and content. @@ -68,8 +69,8 @@ class ApiTestMixin: response, *, status_code: int, - error_code: Optional[str] = None, - message_contains: Optional[str] = None, + error_code: str | None = None, + message_contains: str | None = None, ): """ Assert API response is an error with specific characteristics. @@ -94,9 +95,9 @@ class ApiTestMixin: self, response, *, - expected_count: Optional[int] = None, - has_next: Optional[bool] = None, - has_previous: Optional[bool] = None, + expected_count: int | None = None, + has_next: bool | None = None, + has_previous: bool | None = None, ): """ Assert API response has correct pagination structure. @@ -136,7 +137,7 @@ class ApiTestMixin: class ModelTestMixin: """Mixin providing common model testing utilities.""" - def assertModelFields(self, instance, expected_fields: Dict[str, Any]): + def assertModelFields(self, instance, expected_fields: dict[str, Any]): """ Assert model instance has expected field values. @@ -155,8 +156,8 @@ class ModelTestMixin: def assertModelValidation( self, model_class, - invalid_data: Dict[str, Any], - expected_errors: List[str], + invalid_data: dict[str, Any], + expected_errors: list[str], ): """ Assert model validation catches expected errors. @@ -175,7 +176,7 @@ class ModelTestMixin: for expected_error in expected_errors: self.assertIn(expected_error, exception_str) - def assertDatabaseConstraint(self, model_factory, invalid_data: Dict[str, Any]): + def assertDatabaseConstraint(self, model_factory, invalid_data: dict[str, Any]): """ Assert database constraint is enforced. @@ -299,7 +300,7 @@ class GeographyTestMixin: point2: (latitude, longitude) tuple max_distance_km: Maximum allowed distance in kilometers """ - from math import radians, cos, sin, asin, sqrt + from math import asin, cos, radians, sin, sqrt lat1, lon1 = point1 lat2, lon2 = point2 diff --git a/backend/tests/utils/__init__.py b/backend/tests/utils/__init__.py index a5e94d61..0405211d 100644 --- a/backend/tests/utils/__init__.py +++ b/backend/tests/utils/__init__.py @@ -6,14 +6,14 @@ for testing FSM transitions, HTMX interactions, and other common scenarios. """ from .fsm_test_helpers import ( - create_test_submission, + assert_state_log_created, + assert_status_changed, + assert_toast_triggered, create_test_park, create_test_ride, - assert_status_changed, - assert_state_log_created, - assert_toast_triggered, - wait_for_htmx_swap, + create_test_submission, verify_transition_buttons_visible, + wait_for_htmx_swap, ) __all__ = [ diff --git a/backend/tests/utils/fsm_test_helpers.py b/backend/tests/utils/fsm_test_helpers.py index 98e45b9a..3496f2d1 100644 --- a/backend/tests/utils/fsm_test_helpers.py +++ b/backend/tests/utils/fsm_test_helpers.py @@ -9,10 +9,11 @@ Reusable utility functions for testing FSM transitions: """ import json -from typing import Any, Dict, List, Optional, Type -from django.db.models import Model +from typing import Any + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.db.models import Model from django.http import HttpResponse User = get_user_model() @@ -25,10 +26,10 @@ User = get_user_model() def create_test_submission( status: str = "PENDING", - user: Optional[User] = None, - park: Optional[Model] = None, + user: User | None = None, + park: Model | None = None, submission_type: str = "EDIT", - changes: Optional[Dict[str, Any]] = None, + changes: dict[str, Any] | None = None, reason: str = "Test submission", **kwargs ) -> "EditSubmission": @@ -87,8 +88,8 @@ def create_test_submission( def create_test_park( status: str = "OPERATING", - name: Optional[str] = None, - slug: Optional[str] = None, + name: str | None = None, + slug: str | None = None, **kwargs ) -> "Park": """ @@ -125,9 +126,9 @@ def create_test_park( def create_test_ride( status: str = "OPERATING", - name: Optional[str] = None, - slug: Optional[str] = None, - park: Optional[Model] = None, + name: str | None = None, + slug: str | None = None, + park: Model | None = None, **kwargs ) -> "Ride": """ @@ -170,8 +171,8 @@ def create_test_ride( def create_test_photo_submission( status: str = "PENDING", - user: Optional[User] = None, - park: Optional[Model] = None, + user: User | None = None, + park: Model | None = None, **kwargs ) -> "PhotoSubmission": """ @@ -254,8 +255,8 @@ def assert_status_changed(obj: Model, expected_status: str) -> None: def assert_state_log_created( obj: Model, transition_name: str, - user: Optional[User] = None, - expected_state: Optional[str] = None + user: User | None = None, + expected_state: str | None = None ) -> None: """ Assert that a StateLog entry was created for a transition. @@ -295,7 +296,7 @@ def assert_state_log_created( def assert_toast_triggered( response: HttpResponse, - message: Optional[str] = None, + message: str | None = None, toast_type: str = "success" ) -> None: """ @@ -375,9 +376,9 @@ def wait_for_htmx_swap( def verify_transition_buttons_visible( page, - transitions: List[str], + transitions: list[str], container_selector: str = "[data-status-actions]" -) -> Dict[str, bool]: +) -> dict[str, bool]: """ Verify which transition buttons are visible on the page. @@ -487,7 +488,7 @@ def click_and_confirm(page, button_locator, accept: bool = True) -> None: # ============================================================================= -def make_htmx_post(client, url: str, data: Optional[Dict] = None) -> HttpResponse: +def make_htmx_post(client, url: str, data: dict | None = None) -> HttpResponse: """ Make a POST request with HTMX headers. @@ -529,7 +530,7 @@ def get_fsm_transition_url( pk: int, transition_name: str, use_slug: bool = False, - slug: Optional[str] = None + slug: str | None = None ) -> str: """ Generate the URL for an FSM transition. diff --git a/backend/tests/ux/test_breadcrumbs.py b/backend/tests/ux/test_breadcrumbs.py index 2bcd145d..a0283792 100644 --- a/backend/tests/ux/test_breadcrumbs.py +++ b/backend/tests/ux/test_breadcrumbs.py @@ -5,9 +5,7 @@ These tests verify that the breadcrumb system generates correct navigation structures and Schema.org markup. """ -import pytest from django.test import RequestFactory -from django.urls import reverse from apps.core.utils.breadcrumbs import ( Breadcrumb, diff --git a/backend/tests/ux/test_components.py b/backend/tests/ux/test_components.py index 471140ce..db9f61ac 100644 --- a/backend/tests/ux/test_components.py +++ b/backend/tests/ux/test_components.py @@ -7,7 +7,6 @@ with various parameter combinations. import pytest from django.template import Context, Template -from django.test import RequestFactory, override_settings @pytest.mark.django_db diff --git a/backend/tests/ux/test_htmx_utils.py b/backend/tests/ux/test_htmx_utils.py index 5097c267..cf3b9b87 100644 --- a/backend/tests/ux/test_htmx_utils.py +++ b/backend/tests/ux/test_htmx_utils.py @@ -8,7 +8,6 @@ correct responses with proper headers and content. import json import pytest -from django.http import HttpRequest from django.test import RequestFactory from apps.core.htmx_utils import ( diff --git a/backend/tests/ux/test_messages.py b/backend/tests/ux/test_messages.py index cc84118d..9a615398 100644 --- a/backend/tests/ux/test_messages.py +++ b/backend/tests/ux/test_messages.py @@ -5,7 +5,6 @@ These tests verify that message helper functions generate consistent, user-friendly messages. """ -import pytest from apps.core.utils.messages import ( confirm_delete, diff --git a/backend/tests/ux/test_meta.py b/backend/tests/ux/test_meta.py index 5394ee0b..3efeb531 100644 --- a/backend/tests/ux/test_meta.py +++ b/backend/tests/ux/test_meta.py @@ -5,7 +5,6 @@ These tests verify that meta tag helpers generate correct SEO and social sharing metadata. """ -import pytest from django.test import RequestFactory from apps.core.utils.meta import ( diff --git a/backend/thrillwiki/urls.py b/backend/thrillwiki/urls.py index 755e8d43..8f2ceb9c 100644 --- a/backend/thrillwiki/urls.py +++ b/backend/thrillwiki/urls.py @@ -1,15 +1,18 @@ -from django.contrib import admin -from django.urls import path, include -from django.conf import settings -from django.conf.urls.static import static -from django.views.static import serve -from apps.accounts import views as accounts_views -from django.views.generic import TemplateView -from .views import HomeView -from . import views import os from typing import Any +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from django.views.generic import TemplateView +from django.views.static import serve + +from apps.accounts import views as accounts_views + +from . import views +from .views import HomeView + # Import API documentation views # Ensure names are always defined for static analyzers / type checkers. # Annotate as Any so static analysis won't complain that they might be None @@ -20,8 +23,8 @@ SpectacularRedocView: Any = None try: from drf_spectacular.views import ( SpectacularAPIView, - SpectacularSwaggerView, SpectacularRedocView, + SpectacularSwaggerView, ) HAS_SPECTACULAR = True diff --git a/backend/thrillwiki/views.py b/backend/thrillwiki/views.py index 6c366ddd..4b3ea3c8 100644 --- a/backend/thrillwiki/views.py +++ b/backend/thrillwiki/views.py @@ -1,16 +1,17 @@ -from django.shortcuts import render -from django.views.generic import TemplateView -from django.db.models import Q -from django.core.cache import cache -from apps.parks.models import Park, Company -from apps.rides.models import Ride -from apps.core.analytics import PageView -from django.conf import settings import logging import os import secrets +from django.conf import settings +from django.core.cache import cache +from django.db.models import Q +from django.shortcuts import render +from django.views.generic import TemplateView + +from apps.core.analytics import PageView from apps.core.logging import log_exception +from apps.parks.models import Company, Park +from apps.rides.models import Ride logger = logging.getLogger(__name__) diff --git a/backend/verify_backend.py b/backend/verify_backend.py index e9f6f797..2457d24c 100644 --- a/backend/verify_backend.py +++ b/backend/verify_backend.py @@ -1,7 +1,7 @@ import os -import django import sys -import json + +import django # Setup Django environment sys.path.append('/Volumes/macminissd/Projects/thrillwiki_django_no_react/backend') @@ -10,20 +10,19 @@ django.setup() from django.contrib.auth import get_user_model from rest_framework.test import APIClient -from rest_framework import status -from apps.lists.models import UserList, ListItem + from apps.parks.models import Park User = get_user_model() def run_verification(): print("Starting Backend Verification...") - + # 1. Create Test User username = "verify_user" email = "verify@example.com" password = "password123" - + user, created = User.objects.get_or_create(username=username, email=email) user.set_password(password) user.save() @@ -44,7 +43,7 @@ def run_verification(): # Note: unit_system expects 'metric', 'imperial'. # Check if 'imperial' is valid key in RichChoiceField. # Assuming it is based on implementation plan. - + response = client.patch('/api/v1/accounts/profile/update/', update_data, format='json') if response.status_code == 200: print(f"Profile updated successfully: {response.data.get('unit_system')}") @@ -75,19 +74,12 @@ def run_verification(): if not park: print("Creating dummy park for testing...") park = Park.objects.create(name="Test Park", slug="test-park", country="US") - - item_data = { - "user_list": list_id, - "content_type": "parks.park", # format app.model - "object_id": park.id, - "rank": 1, - "comment": "Top tier" - } + # Note: Serializer might expect 'content_type' as ID or string. # Let's try string first if using slug-based or app-label based lookup. # If standard serializer, might be tricky. # Alternatively, use specialized endpoint or just test UserList creation for now. - + # Actually, let's just check if we can GET the list response = client.get(f'/api/v1/lists/lists/{list_id}/') if response.status_code == 200: diff --git a/backend/verify_no_tuple_fallbacks.py b/backend/verify_no_tuple_fallbacks.py index eb005b76..dae7b4ea 100644 --- a/backend/verify_no_tuple_fallbacks.py +++ b/backend/verify_no_tuple_fallbacks.py @@ -11,37 +11,38 @@ import re import sys from pathlib import Path + def search_for_tuple_fallbacks(): """Search for tuple fallback patterns in the codebase.""" - + # Patterns that indicate tuple fallbacks choice_fallback_patterns = [ r'choices\.get\([^,]+,\s*[^)]+\)', # choices.get(value, fallback) - r'status_.*\.get\([^,]+,\s*[^)]+\)', # status_colors.get(value, fallback) + r'status_.*\.get\([^,]+,\s*[^)]+\)', # status_colors.get(value, fallback) r'category_.*\.get\([^,]+,\s*[^)]+\)', # category_images.get(value, fallback) r'sla_hours\.get\([^,]+,\s*[^)]+\)', # sla_hours.get(priority, fallback) r'get_tuple_choices\(', # get_tuple_choices function r'from_tuple\(', # from_tuple function r'convert_tuple_choices\(', # convert_tuple_choices function ] - + apps_dir = Path('apps') if not apps_dir.exists(): print("❌ Error: apps directory not found") return False - + found_fallbacks = [] - + # Search all Python files in apps directory for py_file in apps_dir.rglob('*.py'): # Skip migrations (they're supposed to have hardcoded values) if 'migration' in str(py_file): continue - + try: - with open(py_file, 'r', encoding='utf-8') as f: + with open(py_file, encoding='utf-8') as f: content = f.read() - + for line_num, line in enumerate(content.split('\n'), 1): for pattern in choice_fallback_patterns: if re.search(pattern, line): @@ -54,7 +55,7 @@ def search_for_tuple_fallbacks(): except Exception as e: print(f"❌ Error reading {py_file}: {e}") continue - + # Report results if found_fallbacks: print(f"❌ FOUND {len(found_fallbacks)} TUPLE FALLBACK PATTERNS:") @@ -67,18 +68,18 @@ def search_for_tuple_fallbacks(): def verify_tuple_functions_removed(): """Verify that tuple fallback functions have been removed.""" - + # Check that get_tuple_choices is not importable try: - from apps.core.choices.registry import get_tuple_choices + from apps.core.choices.registry import get_tuple_choices # noqa: F401 print("❌ ERROR: get_tuple_choices function still exists!") return False except ImportError: print("✅ get_tuple_choices function successfully removed") - + # Check that Rich Choice objects work as primary source try: - from apps.core.choices.registry import get_choices + from apps.core.choices.registry import get_choices # noqa: F401 print("✅ get_choices function (Rich Choice objects) works as primary source") return True except ImportError: @@ -88,19 +89,19 @@ def verify_tuple_functions_removed(): def main(): """Main verification function.""" print("=== TUPLE FALLBACK ELIMINATION VERIFICATION ===\n") - + # Change to backend directory if needed if 'backend' not in os.getcwd(): backend_dir = Path(__file__).parent os.chdir(backend_dir) print(f"Changed directory to: {os.getcwd()}") - + print("1. Searching for tuple fallback patterns...") patterns_clean = search_for_tuple_fallbacks() - + print("\n2. Verifying tuple functions removed...") functions_removed = verify_tuple_functions_removed() - + print("\n=== FINAL RESULT ===") if patterns_clean and functions_removed: print("🎉 SUCCESS: ALL TUPLE FALLBACKS HAVE BEEN ELIMINATED!") @@ -110,4 +111,4 @@ def main(): return 1 if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 28fa5ec2..f4539b0c 100644 --- a/uv.lock +++ b/uv.lock @@ -22,20 +22,20 @@ wheels = [ [[package]] name = "asgiref" -version = "3.8.1" +version = "3.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] [[package]] name = "attrs" -version = "24.3.0" +version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984, upload-time = "2024-12-16T06:59:29.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397, upload-time = "2024-12-16T06:59:26.977Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] @@ -197,36 +197,55 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.1" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] name = "click" -version = "8.1.8" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -406,26 +425,25 @@ wheels = [ [[package]] name = "dj-database-url" -version = "2.3.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/9f/fc9905758256af4f68a55da94ab78a13e7775074edfdcaddd757d4921686/dj_database_url-2.3.0.tar.gz", hash = "sha256:ae52e8e634186b57e5a45e445da5dc407a819c2ceed8a53d1fac004cc5288787", size = 10980, upload-time = "2024-10-23T10:05:19.953Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/05/2ec51009f4ce424877dbd8ad95868faec0c3494ed0ff1635f9ab53d9e0ee/dj_database_url-3.0.1.tar.gz", hash = "sha256:8994961efb888fc6bf8c41550870c91f6f7691ca751888ebaa71442b7f84eff8", size = 12556, upload-time = "2025-07-02T09:40:11.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/91/641a4e5c8903ed59f6cbcce571003bba9c5d2f731759c31db0ba83bb0bdb/dj_database_url-2.3.0-py3-none-any.whl", hash = "sha256:bb0d414ba0ac5cd62773ec7f86f8cc378a9dbb00a80884c2fc08cc570452521e", size = 7793, upload-time = "2024-10-23T10:05:41.254Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5e/86a43c6fdaa41c12d58e4ff3ebbfd6b71a7cb0360a08614e3754ef2e9afb/dj_database_url-3.0.1-py3-none-any.whl", hash = "sha256:43950018e1eeea486bf11136384aec0fe55b29fe6fd8a44553231b85661d9383", size = 8808, upload-time = "2025-07-02T09:40:26.326Z" }, ] [[package]] name = "dj-rest-auth" -version = "7.0.0" +version = "7.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "djangorestframework" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/e5/6cab919e53c4981542e2090249a05278959840cffaf43a272d6ce204b53a/dj-rest-auth-7.0.0.tar.gz", hash = "sha256:08dbc03a35223872da9f59bc2d7a71bec2e721aa69f7cdc84c7a329aeae1f86e", size = 221021, upload-time = "2024-11-03T21:48:17.224Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/19/00150c8bedf7b6d4c44ecf7c2be9e58ae2203b42741ca734152d34f549f1/dj-rest-auth-7.0.1.tar.gz", hash = "sha256:3f8c744cbcf05355ff4bcbef0c8a63645da38e29a0fdef3c3332d4aced52fb90", size = 220541, upload-time = "2025-01-04T23:37:38.688Z" } [[package]] name = "django" @@ -443,13 +461,16 @@ wheels = [ [[package]] name = "django-allauth" -version = "65.3.0" +version = "65.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/ea/2362fa2566c43f2075e6c178970593d0b5aad63a98d351b0bc474942a7bd/django_allauth-65.3.0.tar.gz", hash = "sha256:92e0242724af03458b05b88c5fa798b01112ab22a86d873a8a9fd8f0ec57bbbf", size = 1546784, upload-time = "2024-11-30T21:23:59.234Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/42a048ba1dedbb6b553f5376a6126b1c753c10c70d1edab8f94c560c8066/django_allauth-65.13.1.tar.gz", hash = "sha256:2af0d07812f8c1a8e3732feaabe6a9db5ecf3fad6b45b6a0f7fd825f656c5a15", size = 1983857, upload-time = "2025-11-20T16:34:40.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/98/9d44ae1468abfdb521d651fb67f914165c7812dfdd97be16190c9b1cc246/django_allauth-65.13.1-py3-none-any.whl", hash = "sha256:2887294beedfd108b4b52ebd182e0ed373deaeb927fc5a22f77bbde3174704a6", size = 1787349, upload-time = "2025-11-20T16:34:37.354Z" }, +] [[package]] name = "django-appconf" @@ -504,7 +525,7 @@ wheels = [ [[package]] name = "django-cloudflareimages-toolkit" -version = "1.0.8" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -512,22 +533,22 @@ dependencies = [ { name = "pillow" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/be/7e31de7680cf66dba19a7764304003d1494877bbd5520b3387cda7411424/django_cloudflareimages_toolkit-1.0.8.tar.gz", hash = "sha256:6c58c58572025b65c41ed8fdb9a3027a47d730e49a92e87c61c7bfed298c5958", size = 137645, upload-time = "2025-09-27T13:05:09.442Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/66/e8132fe59a038d5cfb8a4b721bc9a61dd139a7e0314736bc0387a13d44eb/django_cloudflareimages_toolkit-1.0.9.tar.gz", hash = "sha256:29811b21ab678f38397d2349206e37fe413e67cd4416df4c7f07b9b9162b8460", size = 140318, upload-time = "2025-12-26T23:50:23.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/bd/37373047dce22c485d730b55ebe4ab7252f4bdc4e7b522097d6711762492/django_cloudflareimages_toolkit-1.0.8-py3-none-any.whl", hash = "sha256:77cb23d7fea3698d2e95882533d31402fac4ffe07360d73832a3d3c0dcfe881d", size = 45644, upload-time = "2025-09-27T13:05:07.675Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/1616b7a4427797feb28cabc47748c014190e7bca93ae0dcfd850a75d9dcb/django_cloudflareimages_toolkit-1.0.9-py3-none-any.whl", hash = "sha256:65db52f8f7e059083d51a852eb7ffbe774af3dce4c4396d683059a0fba864e5f", size = 47645, upload-time = "2025-12-26T23:50:22.634Z" }, ] [[package]] name = "django-cors-headers" -version = "4.6.0" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/e5/3b67fc05b9c02b926411436dfc553829bc00843706ce7f99752433017f47/django_cors_headers-4.6.0.tar.gz", hash = "sha256:14d76b4b4c8d39375baeddd89e4f08899051eeaf177cb02a29bd6eae8cf63aa8", size = 20961, upload-time = "2024-10-29T10:38:15.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/73/689532cf164ab10ed1521d825ea156656520cec98886c8d2ac1ce8829220/django_cors_headers-4.6.0-py3-none-any.whl", hash = "sha256:8edbc0497e611c24d5150e0055d3b178c6534b8ed826fb6f53b21c63f5d48ba3", size = 12791, upload-time = "2024-10-29T10:38:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, ] [[package]] @@ -566,14 +587,14 @@ wheels = [ [[package]] name = "django-filter" -version = "24.3" +version = "25.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/bc/dc19ae39c235332926dd0efe0951f663fa1a9fc6be8430737ff7fd566b20/django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3", size = 144444, upload-time = "2024-08-02T13:27:58.132Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" }, ] [[package]] @@ -626,52 +647,52 @@ wheels = [ [[package]] name = "django-htmx" -version = "1.21.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/5c/f6b7e9102a86c69b018142c5e3255b7f983b4f6545fe8e9bbb326e903be1/django_htmx-1.21.0.tar.gz", hash = "sha256:6ed3b42effd5980f22e68f36cd14ee4311bff3b6cb8435a89e27f45995691572", size = 9611, upload-time = "2024-10-27T23:11:50.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/f2/8c3e28a5eed8e5226835c762892bfef74eda7e8629c65b49c186098eb303/django_htmx-1.27.0.tar.gz", hash = "sha256:036e5da801bfdf5f1ca815f21592cfb9f004a898f330c842f15e55c70e301a75", size = 65362, upload-time = "2025-11-28T23:18:55.049Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/ec/0d68c022dac4a539cdd2cf8b2e23a034da9d6ef864e4bc324a5fe2b50c28/django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:64bc31463017a80552b767bc216ee5700248fa72e7ccd2963495e69afbdb6abe", size = 6901, upload-time = "2024-10-27T23:11:49.466Z" }, + { url = "https://files.pythonhosted.org/packages/23/ac/25d28489dc43224e260f4ebee7565f7ef1efe12af0f284a89500c19f75e2/django_htmx-1.27.0-py3-none-any.whl", hash = "sha256:13e1e13b87d39b57f95aae6e4987cb3df056d0b1373a41f4a94504a00298ffd8", size = 62126, upload-time = "2025-11-28T23:18:53.57Z" }, ] [[package]] name = "django-htmx-autocomplete" -version = "1.0.5" +version = "1.0.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/57/fdae7ed31f2bd6586e3347f4d0dc9d5271c9d83d10ecf6a7b0d759a715dd/django_htmx_autocomplete-1.0.5.tar.gz", hash = "sha256:e4b5e5fa255858d16eb51576718ac64331a968e617bcac3ff0b70766e354ad80", size = 41127, upload-time = "2024-11-27T20:54:03.957Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/03/290583ac2a52c675ea0f40c1a389313dfc7d0d879597860b6b7ce8303d0d/django_htmx_autocomplete-1.0.15.tar.gz", hash = "sha256:118543b12e9938a521e1f321f26c2ba3243c3309c06a7a68b9ef4d0cbdc41e0f", size = 43270, upload-time = "2025-12-19T17:46:50.563Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/ed/a508ec85ba4cacf796920b7a1d3c6f71b9b2fda9a2b12fa551bada17a0d6/django_htmx_autocomplete-1.0.5-py3-none-any.whl", hash = "sha256:8fbc73cb617c9d2e3edc9b827776771dd34f57d13572e8742fe5dfa848298735", size = 52127, upload-time = "2024-11-27T20:54:02.203Z" }, + { url = "https://files.pythonhosted.org/packages/bf/be/c00a3c861e5356105176c9f120fad3ff95698c1d61e172dd0a0a64acdb9b/django_htmx_autocomplete-1.0.15-py3-none-any.whl", hash = "sha256:c895ea457c0b2a79d14a0b6ead4fba8270fd910ad0d7a0fcbd3ae0b2cb8b6a1e", size = 54059, upload-time = "2025-12-19T17:46:49.595Z" }, ] [[package]] name = "django-pghistory" -version = "3.5.2" +version = "3.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "django-pgtrigger" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f0/a3170aafc42875a43b04a0ae6f1cce103f16eed4013147d6b5d7b6f46c29/django_pghistory-3.5.2.tar.gz", hash = "sha256:49d2fb0a5b86cffd409cfc9bfce1dee5401fbbdb18779b04bc26cea8c4e967d7", size = 31221, upload-time = "2025-02-05T11:49:12.792Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/8a/90d0fca7b7548c6894a42bcbc456f55f3e07f7f20086c86ea3b4a84014e3/django_pghistory-3.9.1.tar.gz", hash = "sha256:1ae71ddf7c30a07f988c6001c96a390c881e4fe1ef1cbc4c86f86a5bf5c6e5ba", size = 31337, upload-time = "2025-12-12T01:07:48.272Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/43/83ad03cda0e613d8e9df6398c5d6ce07cdbca8cdaad9e69841f9c58e3d1e/django_pghistory-3.5.2-py3-none-any.whl", hash = "sha256:5f81a386e4c93e34b95de2064bbf42aabe4e20be53f7ad6638d25f137338f101", size = 38319, upload-time = "2025-02-05T11:49:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/2c/1aaebe28c56ee627bbb5423ca4b35466d232895de1b843c5872eaa0071cc/django_pghistory-3.9.1-py3-none-any.whl", hash = "sha256:e0a3750b8df9c81b50991bd4ed84ae73ae3a907f5f46c4f27aff2396437d8b3b", size = 39871, upload-time = "2025-12-12T01:07:47.15Z" }, ] [[package]] name = "django-pgtrigger" -version = "4.13.3" +version = "4.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/98/d93f658316901c54a00ec0caefc3e73796b2f7ffcc1fd11188da22c45027/django_pgtrigger-4.13.3.tar.gz", hash = "sha256:c525f9e81f120d166c4bd5fe8c3770640356f0644edf0fc2b7f6426008e52f77", size = 30723, upload-time = "2024-12-16T01:56:44.466Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/3d/ea55015e2e2a8e2230c6a056768512a9660573f8926f87671f166d455936/django_pgtrigger-4.17.0.tar.gz", hash = "sha256:55d297a756b5b6a8e0c533d3323fda1589c699e7d14aa3fff7fa7d774c46363c", size = 32200, upload-time = "2025-12-04T15:46:06.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/cb/3bb87d45b1b46ef36f3786fbfcda0a43f80c8fa4e742a350a2cd1512557e/django_pgtrigger-4.13.3-py3-none-any.whl", hash = "sha256:d6e4d17021bbd5e425a308f07414b237b9b34423275d86ad756b90c307df3ca4", size = 34059, upload-time = "2024-12-16T01:56:39.643Z" }, + { url = "https://files.pythonhosted.org/packages/91/47/13c0c23c134cd0f0b0addfbc3ef1477f90d891184d21689f9074b794b00c/django_pgtrigger-4.17.0-py3-none-any.whl", hash = "sha256:06048cdeb57e20987fd0bf7dd673512a1413a08868eea248d70dbb970e967175", size = 36326, upload-time = "2025-12-04T15:46:05.427Z" }, ] [[package]] @@ -703,7 +724,7 @@ wheels = [ [[package]] name = "django-stubs" -version = "5.2.5" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -711,36 +732,36 @@ dependencies = [ { name = "types-pyyaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/8e/286150f593481c33f54d14efb58d72178f159d57d31043529d38bbc98e2f/django_stubs-5.2.5.tar.gz", hash = "sha256:fc78384e28d8c5292d60983075a5934f644f7c304c25ae2793fc57aa66d5018b", size = 247794, upload-time = "2025-09-12T19:29:49.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/75/97626224fd8f1787bb6f7f06944efcfddd5da7764bf741cf7f59d102f4a0/django_stubs-5.2.8.tar.gz", hash = "sha256:9bba597c9a8ed8c025cae4696803d5c8be1cf55bfc7648a084cbf864187e2f8b", size = 257709, upload-time = "2025-12-01T08:13:09.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/02/cdbf7652ef2c9a7a1fed7c279484b7f3806f15b1bb34aec9fef8e8cfacbf/django_stubs-5.2.5-py3-none-any.whl", hash = "sha256:223c1a3324cd4873b7629dec6e9adbe224a94508284c1926b25fddff7a92252b", size = 490196, upload-time = "2025-09-12T19:29:47.954Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3f/7c9543ad5ade5ce1d33d187a3abd82164570314ebee72c6206ab5c044ebf/django_stubs-5.2.8-py3-none-any.whl", hash = "sha256:a3c63119fd7062ac63d58869698d07c9e5ec0561295c4e700317c54e8d26716c", size = 508136, upload-time = "2025-12-01T08:13:07.963Z" }, ] [[package]] name = "django-stubs-ext" -version = "5.2.5" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/94/c9b8f4c47084a0fa666da9066c36771098101932688adf2c17a40fab79c2/django_stubs_ext-5.2.5.tar.gz", hash = "sha256:ecc628df29d36cede638567c4e33ff485dd7a99f1552ad0cece8c60e9c3a8872", size = 6489, upload-time = "2025-09-12T19:29:06.008Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/a2/d67f4a5200ff7626b104eddceaf529761cba4ed318a73ffdb0677551be73/django_stubs_ext-5.2.8.tar.gz", hash = "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b", size = 6487, upload-time = "2025-12-01T08:12:37.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/fe/a85a105fddffadb4a8d50e500caeee87d836b679d51a19d52dfa0cc6c660/django_stubs_ext-5.2.5-py3-none-any.whl", hash = "sha256:9b4b8ac9d32f7e6c304fd05477f8688fae6ed57f6a0f9f4d074f9e55b5a3da14", size = 9310, upload-time = "2025-09-12T19:29:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/da/2d/cb0151b780c3730cf0f2c0fcb1b065a5e88f877cf7a9217483c375353af1/django_stubs_ext-5.2.8-py3-none-any.whl", hash = "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", size = 9949, upload-time = "2025-12-01T08:12:36.397Z" }, ] [[package]] name = "django-tailwind-cli" -version = "2.21.1" +version = "4.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "django-typer" }, - { name = "requests" }, + { name = "semver" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/54/f242a4052aa620920e53fa5351af0b095d98e75d4f921a676fd6a43f35bb/django_tailwind_cli-2.21.1.tar.gz", hash = "sha256:181f69dc172f50914823632dfec6d1d4d52150fcde4358b7439da37da025110b", size = 66542, upload-time = "2024-12-07T18:48:25.656Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/09/8359181201a03871e34d8d47685b15244e778c8ece9f209a86d543cb7767/django_tailwind_cli-4.4.2.tar.gz", hash = "sha256:c3ad962710fc95acf1bb45b1b7747fe549d50ff99228cadc4cf2f28fd8d4e8ce", size = 97420, upload-time = "2025-09-23T15:07:23.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/50/3cfd29be03ea1abdc64121aff1e30182b6e9b06524e29661c112652b129a/django_tailwind_cli-2.21.1-py3-none-any.whl", hash = "sha256:5e6df422341844ce3dd76bab3e63bbd74f760e71c3df1005792be23066e78257", size = 12927, upload-time = "2024-12-07T18:48:23.558Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/8b8c7c4a4f9f4ad3c4815f53c4f98de19b5c37803a9af767d0cebd779af4/django_tailwind_cli-4.4.2-py3-none-any.whl", hash = "sha256:8d1d69ae19209b5d6fd66150d916edbced1d154eee55895d807441dbfe282cae", size = 31688, upload-time = "2025-09-23T15:07:22.16Z" }, ] [[package]] @@ -755,9 +776,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" }, ] +[[package]] +name = "django-turnstile" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/fd/7f916c3e603ad5d8f5e544eacd82df989d19662b551cb3c9fbfaedbe5464/django_turnstile-0.1.2.tar.gz", hash = "sha256:7eb4ec8452ff6799792233eddc037578ca465134d1409b385c05bdec911a5154", size = 5257, upload-time = "2024-10-17T12:21:01.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/7d/6dd43ddf5e4c26985656f6d945294a3c9166944577d25f9bce6634acc432/django_turnstile-0.1.2-py3-none-any.whl", hash = "sha256:e671222e89ef24117768fbc7311da21f9bc60be6bc975e942cd3eff95a4e2b2c", size = 6579, upload-time = "2024-10-17T12:20:59.223Z" }, +] + [[package]] name = "django-typer" -version = "2.6.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -765,9 +795,9 @@ dependencies = [ { name = "shellingham" }, { name = "typer-slim" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/b2/1cd457fc5ad56de56ec12e29aa73b364ed892dd8d8af6262cc09efd46703/django_typer-2.6.0.tar.gz", hash = "sha256:60b15b67d8540000aa9851ba0e381dda8700fc1a418b3861844a2eabdbf96fc3", size = 54640, upload-time = "2024-12-03T20:02:49.626Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/08/08b7ed6d33dcc07538b522dea121fd65412cc5fb57716da1a58158a272ba/django_typer-3.5.0.tar.gz", hash = "sha256:48e1c0296979eae9e76d3bce6ed9bbbff0ca40fc0753eaa66f7826919416be9a", size = 3074197, upload-time = "2025-11-22T17:26:07.62Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/d5/f093f30f2f7c364b766767f537d229b0a0391a0121e41d65c1ded828e115/django_typer-2.6.0-py3-none-any.whl", hash = "sha256:7635fc3d3af6d9e80b53089c969e3947a8e1cd127e4a497a1c46696eebddec62", size = 56114, upload-time = "2024-12-03T20:02:47.223Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/ed897a5d38be8b0dd6f9d9d482e86f7eeb48a5503250cbd217461c37e04d/django_typer-3.5.0-py3-none-any.whl", hash = "sha256:ffb0222f915bbdcfb24ef179aa374a3d7ab61454a4043dd4c7f97cb6a1b79950", size = 295611, upload-time = "2025-11-22T17:26:05.488Z" }, ] [[package]] @@ -781,14 +811,14 @@ wheels = [ [[package]] name = "djangorestframework" -version = "3.15.2" +version = "3.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, ] [[package]] @@ -855,18 +885,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/5a/26cdb1b10a55ac6eb11a738cea14865fa753606c4897d7be0f5dc230df00/faker-39.0.0-py3-none-any.whl", hash = "sha256:c72f1fca8f1a24b8da10fcaa45739135a19772218ddd61b86b7ea1b8c790dce7", size = 1980775, upload-time = "2025-12-17T19:19:02.926Z" }, ] +[[package]] +name = "fido2" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/b9/6ec8d8ec5715efc6ae39e8694bd48d57c189906f0628558f56688d0447b2/fido2-2.0.0.tar.gz", hash = "sha256:3061cd05e73b3a0ef6afc3b803d57c826aa2d6a9732d16abd7277361f58e7964", size = 274942, upload-time = "2025-05-20T09:45:00.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7d/a1dba174d7ec4b6b8d6360eed0ac3a4a4e2aa45f234e903592d3184c6c3f/fido2-2.0.0-py3-none-any.whl", hash = "sha256:685f54a50a57e019c6156e2dd699802a603e3abf70bab334f26affdd4fb8d4f7", size = 224761, upload-time = "2025-05-20T09:44:59.029Z" }, +] + [[package]] name = "flake8" -version = "7.1.1" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mccabe" }, { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/72/e8d66150c4fcace3c0a450466aa3480506ba2cae7b61e100a2613afc3907/flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", size = 48054, upload-time = "2024-08-04T20:32:44.311Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/42/65004373ac4617464f35ed15931b30d764f53cdd30cc78d5aea349c8c050/flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213", size = 57731, upload-time = "2024-08-04T20:32:42.661Z" }, + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] [[package]] @@ -880,26 +922,33 @@ wheels = [ [[package]] name = "greenlet" -version = "3.1.1" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022, upload-time = "2024-09-20T18:21:04.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990, upload-time = "2024-09-20T17:08:26.312Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175, upload-time = "2024-09-20T17:36:48.983Z" }, - { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425, upload-time = "2024-09-20T17:39:22.705Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736, upload-time = "2024-09-20T17:44:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347, upload-time = "2024-09-20T17:08:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583, upload-time = "2024-09-20T17:08:36.85Z" }, - { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039, upload-time = "2024-09-20T17:44:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716, upload-time = "2024-09-20T17:09:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490, upload-time = "2024-09-20T17:17:09.501Z" }, - { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731, upload-time = "2024-09-20T17:36:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304, upload-time = "2024-09-20T17:39:24.55Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537, upload-time = "2024-09-20T17:44:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506, upload-time = "2024-09-20T17:08:47.852Z" }, - { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753, upload-time = "2024-09-20T17:08:38.079Z" }, - { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731, upload-time = "2024-09-20T17:44:20.556Z" }, - { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112, upload-time = "2024-09-20T17:09:28.753Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, ] [[package]] @@ -960,11 +1009,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -978,11 +1027,11 @@ wheels = [ [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -1090,11 +1139,11 @@ wheels = [ [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] @@ -1133,11 +1182,11 @@ wheels = [ [[package]] name = "packaging" -version = "24.2" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] @@ -1160,38 +1209,38 @@ wheels = [ [[package]] name = "pillow" -version = "11.0.0" +version = "11.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", size = 46737780, upload-time = "2024-10-15T14:24:29.672Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715, upload-time = "2025-01-02T08:13:58.407Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", size = 3147300, upload-time = "2024-10-15T14:23:01.855Z" }, - { url = "https://files.pythonhosted.org/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", size = 2978742, upload-time = "2024-10-15T14:23:03.749Z" }, - { url = "https://files.pythonhosted.org/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", size = 4194349, upload-time = "2024-10-15T14:23:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e8/686d0caeed6b998351d57796496a70185376ed9c8ec7d99e1d19ad591fc6/pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2", size = 4298714, upload-time = "2024-10-15T14:23:07.919Z" }, - { url = "https://files.pythonhosted.org/packages/ec/da/430015cec620d622f06854be67fd2f6721f52fc17fca8ac34b32e2d60739/pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527", size = 4208514, upload-time = "2024-10-15T14:23:10.19Z" }, - { url = "https://files.pythonhosted.org/packages/44/ae/7e4f6662a9b1cb5f92b9cc9cab8321c381ffbee309210940e57432a4063a/pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa", size = 4380055, upload-time = "2024-10-15T14:23:12.08Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/1a807779ac8a0eeed57f2b92a3c32ea1b696e6140c15bd42eaf908a261cd/pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f", size = 4296751, upload-time = "2024-10-15T14:23:13.836Z" }, - { url = "https://files.pythonhosted.org/packages/38/8c/5fa3385163ee7080bc13026d59656267daaaaf3c728c233d530e2c2757c8/pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb", size = 4430378, upload-time = "2024-10-15T14:23:15.735Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1d/ad9c14811133977ff87035bf426875b93097fb50af747793f013979facdb/pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798", size = 2249588, upload-time = "2024-10-15T14:23:17.905Z" }, - { url = "https://files.pythonhosted.org/packages/fb/01/3755ba287dac715e6afdb333cb1f6d69740a7475220b4637b5ce3d78cec2/pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de", size = 2567509, upload-time = "2024-10-15T14:23:19.643Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/2c7d727079b6be1aba82d195767d35fcc2d32204c7a5820f822df5330152/pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84", size = 2254791, upload-time = "2024-10-15T14:23:21.601Z" }, - { url = "https://files.pythonhosted.org/packages/eb/38/998b04cc6f474e78b563716b20eecf42a2fa16a84589d23c8898e64b0ffd/pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b", size = 3150854, upload-time = "2024-10-15T14:23:23.91Z" }, - { url = "https://files.pythonhosted.org/packages/13/8e/be23a96292113c6cb26b2aa3c8b3681ec62b44ed5c2bd0b258bd59503d3c/pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003", size = 2982369, upload-time = "2024-10-15T14:23:27.184Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/3db4eaabb7a2ae8203cd3a332a005e4aba00067fc514aaaf3e9721be31f1/pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2", size = 4333703, upload-time = "2024-10-15T14:23:28.979Z" }, - { url = "https://files.pythonhosted.org/packages/28/ac/629ffc84ff67b9228fe87a97272ab125bbd4dc462745f35f192d37b822f1/pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a", size = 4412550, upload-time = "2024-10-15T14:23:30.846Z" }, - { url = "https://files.pythonhosted.org/packages/d6/07/a505921d36bb2df6868806eaf56ef58699c16c388e378b0dcdb6e5b2fb36/pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8", size = 4461038, upload-time = "2024-10-15T14:23:32.687Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b9/fb620dd47fc7cc9678af8f8bd8c772034ca4977237049287e99dda360b66/pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8", size = 2253197, upload-time = "2024-10-15T14:23:35.309Z" }, - { url = "https://files.pythonhosted.org/packages/df/86/25dde85c06c89d7fc5db17940f07aae0a56ac69aa9ccb5eb0f09798862a8/pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904", size = 2572169, upload-time = "2024-10-15T14:23:37.33Z" }, - { url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828, upload-time = "2024-10-15T14:23:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640, upload-time = "2025-01-02T08:11:58.329Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437, upload-time = "2025-01-02T08:12:01.797Z" }, + { url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605, upload-time = "2025-01-02T08:12:05.224Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173, upload-time = "2025-01-02T08:12:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145, upload-time = "2025-01-02T08:12:11.411Z" }, + { url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340, upload-time = "2025-01-02T08:12:15.29Z" }, + { url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906, upload-time = "2025-01-02T08:12:17.485Z" }, + { url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759, upload-time = "2025-01-02T08:12:20.382Z" }, + { url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657, upload-time = "2025-01-02T08:12:23.922Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304, upload-time = "2025-01-02T08:12:28.069Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117, upload-time = "2025-01-02T08:12:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060, upload-time = "2025-01-02T08:12:32.362Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192, upload-time = "2025-01-02T08:12:34.361Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805, upload-time = "2025-01-02T08:12:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623, upload-time = "2025-01-02T08:12:41.912Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191, upload-time = "2025-01-02T08:12:45.186Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494, upload-time = "2025-01-02T08:12:47.098Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595, upload-time = "2025-01-02T08:12:50.47Z" }, + { url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651, upload-time = "2025-01-02T08:12:53.356Z" }, ] [[package]] name = "platformdirs" -version = "4.3.6" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] [[package]] @@ -1215,11 +1264,11 @@ wheels = [ [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -1236,56 +1285,69 @@ wheels = [ [[package]] name = "psutil" -version = "7.1.3" +version = "7.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/7c/31d1c3ceb1260301f87565f50689dc6da3db427ece1e1e012af22abca54e/psutil-7.2.0.tar.gz", hash = "sha256:2e4f8e1552f77d14dc96fb0f6240c5b34a37081c0889f0853b3b29a496e5ef64", size = 489863, upload-time = "2025-12-23T20:26:24.616Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, - { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, - { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, - { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, - { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, - { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, - { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, - { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8e/b35aae6ed19bc4e2286cac4832e4d522fcf00571867b0a85a3f77ef96a80/psutil-7.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c31e927555539132a00380c971816ea43d089bf4bd5f3e918ed8c16776d68474", size = 129593, upload-time = "2025-12-23T20:26:28.019Z" }, + { url = "https://files.pythonhosted.org/packages/61/a2/773d17d74e122bbffe08b97f73f2d4a01ef53fb03b98e61b8e4f64a9c6b9/psutil-7.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:db8e44e766cef86dea47d9a1fa535d38dc76449e5878a92f33683b7dba5bfcb2", size = 130104, upload-time = "2025-12-23T20:26:30.27Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e3/d3a9b3f4bd231abbd70a988beb2e3edd15306051bccbfc4472bd34a56e01/psutil-7.2.0-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85ef849ac92169dedc59a7ac2fb565f47b3468fbe1524bf748746bc21afb94c7", size = 180579, upload-time = "2025-12-23T20:26:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/66/f8/6c73044424aabe1b7824d4d4504029d406648286d8fe7ba8c4682e0d3042/psutil-7.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26782bdbae2f5c14ce9ebe8ad2411dc2ca870495e0cd90f8910ede7fa5e27117", size = 183171, upload-time = "2025-12-23T20:26:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/48/7d/76d7a863340885d41826562225a566683e653ee6c9ba03c9f3856afa7d80/psutil-7.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b7665f612d3b38a583391b95969667a53aaf6c5706dc27a602c9a4874fbf09e4", size = 139055, upload-time = "2025-12-23T20:26:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/a0/48/200054ada0ae4872c8a71db54f3eb6a9af4101680ee6830d373b7fda526b/psutil-7.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4413373c174520ae28a24a8974ad8ce6b21f060d27dde94e25f8c73a7effe57a", size = 134737, upload-time = "2025-12-23T20:26:38.784Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/98da45dff471b93ef5ce5bcaefa00e3038295a7880a77cf74018243d37fb/psutil-7.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2f2f53fd114e7946dfba3afb98c9b7c7f376009447360ca15bfb73f2066f84c7", size = 129692, upload-time = "2025-12-23T20:26:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/10eae91ba4ad071c92db3c178ba861f30406342de9f0ddbe6d51fd741236/psutil-7.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e65c41d7e60068f60ce43b31a3a7fc90deb0dfd34ffc824a2574c2e5279b377e", size = 130110, upload-time = "2025-12-23T20:26:42.569Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/2b2897443d56fedbbc34ac68a0dc7d55faa05d555372a2f989109052f86d/psutil-7.2.0-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc66d21366850a4261412ce994ae9976bba9852dafb4f2fa60db68ed17ff5281", size = 181487, upload-time = "2025-12-23T20:26:44.633Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/44308428f7333db42c5ea7390c52af1b38f59b80b80c437291f58b5dfdad/psutil-7.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e025d67b42b8f22b096d5d20f5171de0e0fefb2f0ce983a13c5a1b5ed9872706", size = 184320, upload-time = "2025-12-23T20:26:46.83Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/d2feadc7f18e501c5ce687c377db7dca924585418fd694272b8e488ea99f/psutil-7.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:45f6b91f7ad63414d6454fd609e5e3556d0e1038d5d9c75a1368513bdf763f57", size = 140372, upload-time = "2025-12-23T20:26:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/48381f5fd0425aa054c4ee3de24f50de3d6c347019f3aec75f357377d447/psutil-7.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87b18a19574139d60a546e88b5f5b9cbad598e26cdc790d204ab95d7024f03ee", size = 135400, upload-time = "2025-12-23T20:26:51.585Z" }, + { url = "https://files.pythonhosted.org/packages/40/c5/a49160bf3e165b7b93a60579a353cf5d939d7f878fe5fd369110f1d18043/psutil-7.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:977a2fcd132d15cb05b32b2d85b98d087cad039b0ce435731670ba74da9e6133", size = 128116, upload-time = "2025-12-23T20:26:53.516Z" }, + { url = "https://files.pythonhosted.org/packages/10/a1/c75feb480f60cd768fb6ed00ac362a16a33e5076ec8475a22d8162fb2659/psutil-7.2.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:24151011c21fadd94214d7139d7c6c54569290d7e553989bdf0eab73b13beb8c", size = 128925, upload-time = "2025-12-23T20:26:55.573Z" }, + { url = "https://files.pythonhosted.org/packages/12/ff/e93136587c00a543f4bc768b157fac2c47cd77b180d4f4e5c6efb6ea53a2/psutil-7.2.0-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91f211ba9279e7c61d9d8f84b713cfc38fa161cb0597d5cb3f1ca742f6848254", size = 154666, upload-time = "2025-12-23T20:26:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/b8/dd/4c2de9c3827c892599d277a69d2224136800870a8a88a80981de905de28d/psutil-7.2.0-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f37415188b7ea98faf90fed51131181646c59098b077550246e2e092e127418b", size = 156109, upload-time = "2025-12-23T20:26:58.851Z" }, + { url = "https://files.pythonhosted.org/packages/81/3f/090943c682d3629968dd0b04826ddcbc760ee1379021dbe316e2ddfcd01b/psutil-7.2.0-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0d12c7ce6ed1128cd81fd54606afa054ac7dbb9773469ebb58cf2f171c49f2ac", size = 148081, upload-time = "2025-12-23T20:27:01.318Z" }, + { url = "https://files.pythonhosted.org/packages/c4/88/c39648ebb8ec182d0364af53cdefe6eddb5f3872ba718b5855a8ff65d6d4/psutil-7.2.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca0faef7976530940dcd39bc5382d0d0d5eb023b186a4901ca341bd8d8684151", size = 147376, upload-time = "2025-12-23T20:27:03.347Z" }, + { url = "https://files.pythonhosted.org/packages/01/a2/5b39e08bd9b27476bc7cce7e21c71a481ad60b81ffac49baf02687a50d7f/psutil-7.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:abdb74137ca232d20250e9ad471f58d500e7743bc8253ba0bfbf26e570c0e437", size = 136910, upload-time = "2025-12-23T20:27:05.289Z" }, + { url = "https://files.pythonhosted.org/packages/59/54/53839db1258c1eaeb4ded57ff202144ebc75b23facc05a74fd98d338b0c6/psutil-7.2.0-cp37-abi3-win_arm64.whl", hash = "sha256:284e71038b3139e7ab3834b63b3eb5aa5565fcd61a681ec746ef9a0a8c457fd2", size = 133807, upload-time = "2025-12-23T20:27:06.825Z" }, ] [[package]] name = "psycopg2-binary" -version = "2.9.10" +version = "2.9.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, - { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, - { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, - { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, - { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, - { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, - { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, ] [[package]] name = "pycodestyle" -version = "2.12.1" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232, upload-time = "2024-08-04T20:26:54.576Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284, upload-time = "2024-08-04T20:26:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] [[package]] @@ -1299,11 +1361,11 @@ wheels = [ [[package]] name = "pycparser" -version = "2.22" +version = "2.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] [[package]] @@ -1320,11 +1382,11 @@ wheels = [ [[package]] name = "pyflakes" -version = "3.2.0" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788, upload-time = "2024-01-05T00:28:47.703Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload-time = "2024-01-05T00:28:45.903Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] [[package]] @@ -1455,11 +1517,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] [[package]] @@ -1554,6 +1616,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[package.optional-dependencies] +pil = [ + { name = "pillow" }, +] + [[package]] name = "rcssmin" version = "1.2.2" @@ -1588,11 +1667,11 @@ wheels = [ [[package]] name = "redis" -version = "5.2.1" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355, upload-time = "2024-12-06T09:50:41.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502, upload-time = "2024-12-06T09:50:39.656Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, ] [[package]] @@ -1610,7 +1689,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.3" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1618,9 +1697,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -1776,6 +1855,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/d0/55a6b7c6f35aad4c8a54be0eb7a52c1ff29a59542fc3e655f0ecbb14456d/selenium-4.39.0-py3-none-any.whl", hash = "sha256:c85f65d5610642ca0f47dae9d5cc117cd9e831f74038bc09fe1af126288200f9", size = 9655249, upload-time = "2025-12-06T23:12:33.085Z" }, ] +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + [[package]] name = "sentry-sdk" version = "2.48.0" @@ -1827,11 +1915,11 @@ wheels = [ [[package]] name = "sqlparse" -version = "0.5.3" +version = "0.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] [[package]] @@ -1871,10 +1959,12 @@ dependencies = [ { name = "django-pghistory" }, { name = "django-redis" }, { name = "django-tailwind-cli" }, + { name = "django-turnstile" }, { name = "django-widget-tweaks" }, { name = "djangorestframework" }, { name = "djangorestframework-simplejwt" }, { name = "drf-spectacular" }, + { name = "fido2" }, { name = "hiredis" }, { name = "nplusone" }, { name = "piexif" }, @@ -1886,6 +1976,7 @@ dependencies = [ { name = "python-decouple" }, { name = "python-dotenv" }, { name = "python-json-logger" }, + { name = "qrcode", extra = ["pil"] }, { name = "rcssmin" }, { name = "redis" }, { name = "requests" }, @@ -1950,10 +2041,12 @@ requires-dist = [ { name = "django-pghistory", specifier = ">=3.5.2" }, { name = "django-redis", specifier = ">=5.4.0" }, { name = "django-tailwind-cli", specifier = ">=2.21.1" }, + { name = "django-turnstile", specifier = ">=0.1.2" }, { name = "django-widget-tweaks", specifier = ">=1.5.0" }, { name = "djangorestframework", specifier = ">=3.15.2" }, { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "drf-spectacular", specifier = ">=0.28.0" }, + { name = "fido2", specifier = ">=2.0.0" }, { name = "hiredis", specifier = ">=3.1.0" }, { name = "nplusone", specifier = ">=1.0.0" }, { name = "piexif", specifier = ">=1.1.3" }, @@ -1965,6 +2058,7 @@ requires-dist = [ { name = "python-decouple", specifier = ">=3.8" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-json-logger", url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" }, + { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, { name = "rcssmin", specifier = ">=1.1.0" }, { name = "redis", specifier = ">=5.2.0" }, { name = "requests", specifier = ">=2.32.3" }, @@ -2042,15 +2136,15 @@ wheels = [ [[package]] name = "typer-slim" -version = "0.15.1" +version = "0.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/7d/f8e0a2678a44573b2bb1e20abecb10f937a7101ce2b8e07f4eab4c721a3d/typer_slim-0.15.1.tar.gz", hash = "sha256:b8ce8fd2a3c7d52f0d0c1318776e7f2bf897fa203daf899f3863514aa926c725", size = 99874, upload-time = "2024-12-04T17:45:02.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/3d/6a4ec47010e8de34dade20c8e7bce90502b173f62a6b41619523a3fcf562/typer_slim-0.20.1.tar.gz", hash = "sha256:bb9e4f7e6dc31551c8a201383df322b81b0ce37239a5ead302598a2ebb6f7c9c", size = 106113, upload-time = "2025-12-19T16:48:54.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/7b/032ecd581e2170513bb6dc3cdb2581e20fdb94a272bae70fe93f2bca580b/typer_slim-0.15.1-py3-none-any.whl", hash = "sha256:20233cb89938ea3cca633afee10b906a1b0e7c5330f31ed8c55f4f0779efe6df", size = 44968, upload-time = "2024-12-04T17:45:00.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f9/a273c8b57c69ac1b90509ebda204972265fdc978fbbecc25980786f8c038/typer_slim-0.20.1-py3-none-any.whl", hash = "sha256:8e89c5dbaffe87a4f86f4c7a9e2f7059b5b68c66f558f298969d42ce34f10122", size = 47440, upload-time = "2025-12-19T16:48:52.678Z" }, ] [[package]] @@ -2103,11 +2197,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, ] [package.optional-dependencies] @@ -2156,11 +2250,11 @@ wheels = [ [[package]] name = "whitenoise" -version = "6.8.2" +version = "6.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/49/c21ebb5b911888c349a849ec7d1ead2dffbbcc4e2be0f6af2a7dbac03393/whitenoise-6.8.2.tar.gz", hash = "sha256:486bd7267a375fa9650b136daaec156ac572971acc8bf99add90817a530dd1d4", size = 25892, upload-time = "2024-10-29T23:04:39.888Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/95/8c81ec6b6ebcbf8aca2de7603070ccf37dbb873b03f20708e0f7c1664bc6/whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f", size = 26432, upload-time = "2025-09-18T09:16:10.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/3a/8d22513e1942899270dcdbd47f9886309836442cd7ede4b0d00be79715f5/whitenoise-6.8.2-py3-none-any.whl", hash = "sha256:df12dce147a043d1956d81d288c6f0044147c6d2ab9726e5772ac50fb45d2280", size = 20158, upload-time = "2024-10-29T23:04:38.415Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e9/4366332f9295fe0647d7d3251ce18f5615fbcb12d02c79a26f8dba9221b3/whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258", size = 20197, upload-time = "2025-09-18T09:16:09.754Z" }, ] [[package]]