diff --git a/.clinerules b/.clinerules index 3581ce5b..40a282dd 100644 --- a/.clinerules +++ b/.clinerules @@ -1,83 +1,44 @@ -# Project Startup Rules +# Project Startup & Development Rules -## Development Server -IMPORTANT: Always follow these instructions exactly when starting the development server: +## Server & Package Management +- **Starting the Dev Server:** Always assume the server is running and changes have taken effect. If issues arise, run: + ```bash + $PROJECT_ROOT/shared/scripts/start-servers.sh + ``` +- **Python Packages:** Only use UV to add packages: + ```bash + cd $PROJECT_ROOT/backend && uv add + ``` +- **Django Commands:** Always use `uv run manage.py ` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`. -FIRST, assume the server is running. Always. Assume the changes have taken effect. +## Frontend API URL Rules +- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs. +- **URL Flow:** Understand how frontend URLs are rewritten by Vite proxy (e.g., `/api/auth/login/` → `/api/v1/auth/login/`). +- **Verification:** Confirm proxy behavior via config and browser network tab. Only change URLs if proxy is NOT handling rewriting. +- **Common Mistake:** Don’t assume frontend URLs are wrong due to proxy configuration. -IF THERE IS AN ISSUE WITH THE SERVER, run the following command exactly: -```bash -lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; cd backend && uv run manage.py runserver_plus && cd ../frontend && pnpm run dev +## Entity Relationship Patterns +- **Park:** Must have Operator (required), may have PropertyOwner (optional), cannot reference Company directly. +- **Ride:** Must belong to Park, may have Manufacturer/Designer (optional), cannot reference Company directly. +- **Entities:** + - Operators: Operate parks. + - PropertyOwners: Own park property (optional). + - Manufacturers: Make rides. + - Designers: Design rides. + - All entities can have locations. +- **Constraints:** Operator and PropertyOwner can be same or different. Manufacturers and Designers are distinct. Use proper foreign keys with correct null/blank settings. -Note: These steps must be executed in this exact order to ensure consistent behavior. If server does not start correctly, fix the error in accordance with the error details as best you can. - -## Package Management -IMPORTANT: When a Python package is needed, only use UV to add it: -```bash -uv add -``` -Do not attempt to install packages using any other method. - -## Django Management Commands -IMPORTANT: When running any Django manage.py commands (migrations, shell, etc.), always use UV: -```bash -uv run manage.py -``` -This applies to all management commands including but not limited to: -- Making migrations: `uv run manage.py makemigrations` -- Applying migrations: `uv run manage.py migrate` -- Creating superuser: `uv run manage.py createsuperuser` and possible echo commands before for the necessary data input. -- Starting shell: `uv run manage.py shell` and possible echo commands before for the necessary data input. - -NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly. - -## Entity Relationship Rules -IMPORTANT: Follow these entity relationship patterns consistently: - -# Park Relationships -- Parks MUST have an Operator (required relationship) -- Parks MAY have a PropertyOwner (optional, usually same as Operator) -- Parks CANNOT directly reference Company entities - -# Ride Relationships -- Rides MUST belong to a Park (required relationship) -- Rides MAY have a Manufacturer (optional relationship) -- Rides MAY have a Designer (optional relationship) -- Rides CANNOT directly reference Company entities - -# Entity Definitions -- Operators: Companies that operate theme parks (replaces Company.owner) -- PropertyOwners: Companies that own park property (new concept, optional) -- Manufacturers: Companies that manufacture rides (replaces Company for rides) -- Designers: Companies/individuals that design rides (existing concept) -- IMPORTANT: All entities can have locations. - -# Relationship Constraints -- Operator and PropertyOwner are usually the same entity but CAN be different -- Manufacturers and Designers are distinct concepts and should not be conflated -- All entity relationships should use proper foreign keys with appropriate null/blank settings - -- You are to NEVER assume that blank output means your fixes were correct. That assumption can lead to further issues down the line. -- ALWAYS verify your changes by testing the affected functionality thoroughly. -- ALWAYS use context7 to check documentation when troubleshooting. It contains VITAL documentation for any and all frameworks, modules, and packages. -- ALWAYS document your code changes with conport and the reasoning behind them. -- ALWAYS include relevant context and information when making changes to the codebase. -- ALWAYS ensure that your code changes are properly tested and validated before deployment. -- ALWAYS communicate clearly and effectively with your team about any changes you make. -- ALWAYS be open to feedback and willing to make adjustments as necessary. -- ALWAYS strive for continuous improvement in your work and processes. -- ALWAYS prioritize code readability and maintainability. -- ALWAYS keep security best practices in mind when developing and reviewing code. -- ALWAYS consider performance implications when making changes to the codebase. -- ALWAYS be mindful of the impact of your changes on the overall system architecture. -- ALWAYS keep scalability in mind when designing new features or modifying existing ones. -- ALWAYS consider the potential for code reuse and modularity in your designs. -- ALWAYS document your code with clear and concise comments. -- ALWAYS keep your code DRY (Don't Repeat Yourself) by abstracting common functionality into reusable components. -- ALWAYS use meaningful variable and function names to improve code readability. -- ALWAYS handle errors and exceptions gracefully to improve the user experience. -- ALWAYS log important events and errors for troubleshooting purposes. -- ALWAYS consider if there may be an existing module or package that can be leveraged before creating new functionality from scratch. -- ALWAYS keep documentation up to date with any code changes. -- ALWAYS consider if there are any potential security vulnerabilities in your code. -- ALWAYS consider if there are any potential performance bottlenecks in your code. \ No newline at end of file +## General Best Practices +- Never assume blank output means success—always verify changes by testing. +- Use context7 for documentation when troubleshooting. +- Document changes with conport and reasoning. +- Include relevant context and information in all changes. +- Test and validate code before deployment. +- Communicate changes clearly with your team. +- Be open to feedback and continuous improvement. +- Prioritize readability, maintainability, security, performance, scalability, and modularity. +- Use meaningful names, DRY principles, clear comments, and handle errors gracefully. +- Log important events/errors for troubleshooting. +- Prefer existing modules/packages over new code. +- Keep documentation up to date. +- Consider security vulnerabilities and performance bottlenecks in all changes. diff --git a/.roo/rules/roo_code_conport_strategy b/.roo/rules/roo_code_conport_strategy new file mode 100644 index 00000000..6638c715 --- /dev/null +++ b/.roo/rules/roo_code_conport_strategy @@ -0,0 +1,390 @@ +# --- ConPort Memory Strategy --- +conport_memory_strategy: + # CRITICAL: At the beginning of every session, the agent MUST execute the 'initialization' sequence + # to determine the ConPort status and load relevant context. + workspace_id_source: "The agent must obtain the absolute path to the current workspace to use as `workspace_id` for all ConPort tool calls. This might be available as `${workspaceFolder}` or require asking the user." + + initialization: + thinking_preamble: | + + agent_action_plan: + - step: 1 + action: "Determine `ACTUAL_WORKSPACE_ID`." + - step: 2 + action: "Invoke `list_files` for `ACTUAL_WORKSPACE_ID + \"/context_portal/\"`." + tool_to_use: "list_files" + parameters: "path: ACTUAL_WORKSPACE_ID + \"/context_portal/\"" + - step: 3 + action: "Analyze result and branch based on 'context.db' existence." + conditions: + - if: "'context.db' is found" + then_sequence: "load_existing_conport_context" + - else: "'context.db' NOT found" + then_sequence: "handle_new_conport_setup" + + load_existing_conport_context: + thinking_preamble: | + + agent_action_plan: + - step: 1 + description: "Attempt to load initial contexts from ConPort." + actions: + - "Invoke `get_product_context`... Store result." + - "Invoke `get_active_context`... Store result." + - "Invoke `get_decisions` (limit 5 for a better overview)... Store result." + - "Invoke `get_progress` (limit 5)... Store result." + - "Invoke `get_system_patterns` (limit 5)... Store result." + - "Invoke `get_custom_data` (category: \"critical_settings\")... Store result." + - "Invoke `get_custom_data` (category: \"ProjectGlossary\")... Store result." + - "Invoke `get_recent_activity_summary` (default params, e.g., last 24h, limit 3 per type) for a quick catch-up. Store result." + - step: 2 + description: "Analyze loaded context." + conditions: + - if: "results from step 1 are NOT empty/minimal" + actions: + - "Set internal status to [CONPORT_ACTIVE]." + - "Inform user: \"ConPort memory initialized. Existing contexts and recent activity loaded.\"" + - "Use `ask_followup_question` with suggestions like \"Review recent activity?\", \"Continue previous task?\", \"What would you like to work on?\"." + - else: "loaded context is empty/minimal despite DB file existing" + actions: + - "Set internal status to [CONPORT_ACTIVE]." + - "Inform user: \"ConPort database file found, but it appears to be empty or minimally initialized. You can start by defining Product/Active Context or logging project information.\"" + - "Use `ask_followup_question` with suggestions like \"Define Product Context?\", \"Log a new decision?\"." + - step: 3 + description: "Handle Load Failure (if step 1's `get_*` calls failed)." + condition: "If any `get_*` calls in step 1 failed unexpectedly" + action: "Fall back to `if_conport_unavailable_or_init_failed`." + + handle_new_conport_setup: + thinking_preamble: | + + agent_action_plan: + - step: 1 + action: "Inform user: \"No existing ConPort database found at `ACTUAL_WORKSPACE_ID + \"/context_portal/context.db\"`.\"" + - step: 2 + action: "Use `ask_followup_question`." + tool_to_use: "ask_followup_question" + parameters: + question: "Would you like to initialize a new ConPort database for this workspace? The database will be created automatically when ConPort tools are first used." + suggestions: + - "Yes, initialize a new ConPort database." + - "No, do not use ConPort for this session." + - step: 3 + description: "Process user response." + conditions: + - if_user_response_is: "Yes, initialize a new ConPort database." + actions: + - "Inform user: \"Okay, a new ConPort database will be created.\"" + - description: "Attempt to bootstrap Product Context from projectBrief.md (this happens only on new setup)." + thinking_preamble: | + + sub_steps: + - "Invoke `list_files` with `path: ACTUAL_WORKSPACE_ID` (non-recursive, just to check root)." + - description: "Analyze `list_files` result for 'projectBrief.md'." + conditions: + - if: "'projectBrief.md' is found in the listing" + actions: + - "Invoke `read_file` for `ACTUAL_WORKSPACE_ID + \"/projectBrief.md\"`." + - action: "Use `ask_followup_question`." + tool_to_use: "ask_followup_question" + parameters: + question: "Found projectBrief.md in your workspace. As we're setting up ConPort for the first time, would you like to import its content into the Product Context?" + suggestions: + - "Yes, import its content now." + - "No, skip importing it for now." + - description: "Process user response to import projectBrief.md." + conditions: + - if_user_response_is: "Yes, import its content now." + actions: + - "(No need to `get_product_context` as DB is new and empty)" + - "Prepare `content` for `update_product_context`. For example: `{\"initial_product_brief\": \"[content from projectBrief.md]\"}`." + - "Invoke `update_product_context` with the prepared content." + - "Inform user of the import result (success or failure)." + - else: "'projectBrief.md' NOT found" + actions: + - action: "Use `ask_followup_question`." + tool_to_use: "ask_followup_question" + parameters: + question: "`projectBrief.md` was not found in the workspace root. Would you like to define the initial Product Context manually now?" + suggestions: + - "Define Product Context manually." + - "Skip for now." + - "(If \"Define manually\", guide user through `update_product_context`)." + - "Proceed to 'load_existing_conport_context' sequence (which will now load the potentially bootstrapped product context and other empty contexts)." + - if_user_response_is: "No, do not use ConPort for this session." + action: "Proceed to `if_conport_unavailable_or_init_failed` (with a message indicating user chose not to initialize)." + + if_conport_unavailable_or_init_failed: + thinking_preamble: | + + agent_action: "Inform user: \"ConPort memory will not be used for this session. Status: [CONPORT_INACTIVE].\"" + + general: + status_prefix: "Begin EVERY response with either '[CONPORT_ACTIVE]' or '[CONPORT_INACTIVE]'." + proactive_logging_cue: "Remember to proactively identify opportunities to log or update ConPort based on the conversation (e.g., if user outlines a new plan, consider logging decisions or progress). Confirm with the user before logging." + proactive_error_handling: "When encountering errors (e.g., tool failures, unexpected output), proactively log the error details using `log_custom_data` (category: 'ErrorLogs', key: 'timestamp_error_summary') and consider updating `active_context` with `open_issues` if it's a persistent problem. Prioritize using ConPort's `get_item_history` or `get_recent_activity_summary` to diagnose issues if they relate to past context changes." + semantic_search_emphasis: "For complex or nuanced queries, especially when direct keyword search (`search_decisions_fts`, `search_custom_data_value_fts`) might be insufficient, prioritize using `semantic_search_conport` to leverage conceptual understanding and retrieve more relevant context. Explain to the user why semantic search is being used." + + conport_updates: + frequency: "UPDATE CONPORT THROUGHOUT THE CHAT SESSION, WHEN SIGNIFICANT CHANGES OCCUR, OR WHEN EXPLICITLY REQUESTED." + workspace_id_note: "All ConPort tool calls require the `workspace_id`." + tools: + - name: get_product_context + trigger: "To understand the overall project goals, features, or architecture at any time." + action_description: | + # Agent Action: Invoke `get_product_context` (`{"workspace_id": "..."}`). Result is a direct dictionary. + - name: update_product_context + trigger: "When the high-level project description, goals, features, or overall architecture changes significantly, as confirmed by the user." + action_description: | + + - Product context needs updating. + - Step 1: (Optional but recommended if unsure of current state) Invoke `get_product_context`. + - Step 2: Prepare the `content` (for full overwrite) or `patch_content` (partial update) dictionary. + - To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`. + - Confirm changes with the user. + + # Agent Action: Invoke `update_product_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"key_to_update": "new_value", "key_to_delete": "__DELETE__"}}`). + - name: get_active_context + trigger: "To understand the current task focus, immediate goals, or session-specific context." + action_description: | + # Agent Action: Invoke `get_active_context` (`{"workspace_id": "..."}`). Result is a direct dictionary. + - name: update_active_context + trigger: "When the current focus of work changes, new questions arise, or session-specific context needs updating (e.g., `current_focus`, `open_issues`), as confirmed by the user." + action_description: | + + - Active context needs updating. + - Step 1: (Optional) Invoke `get_active_context` to retrieve the current state. + - Step 2: Prepare `content` (for full overwrite) or `patch_content` (for partial update). + - Common fields to update include `current_focus`, `open_issues`, and other session-specific data. + - To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`. + - Confirm changes with the user. + + # Agent Action: Invoke `update_active_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"current_focus": "new_focus", "open_issues": ["issue1", "issue2"], "key_to_delete": "__DELETE__"}}`). + - name: log_decision + trigger: "When a significant architectural or implementation decision is made and confirmed by the user." + action_description: | + # Agent Action: Invoke `log_decision` (`{"workspace_id": "...", "summary": "...", "rationale": "...", "tags": ["optional_tag"]}}`). + - name: get_decisions + trigger: "To retrieve a list of past decisions, e.g., to review history or find a specific decision." + action_description: | + # Agent Action: Invoke `get_decisions` (`{"workspace_id": "...", "limit": N, "tags_filter_include_all": ["tag1"], "tags_filter_include_any": ["tag2"]}}`). Explain optional filters. + - name: search_decisions_fts + trigger: "When searching for decisions by keywords in summary, rationale, details, or tags, and basic `get_decisions` is insufficient." + action_description: | + # Agent Action: Invoke `search_decisions_fts` (`{"workspace_id": "...", "query_term": "search keywords", "limit": N}}`). + - name: delete_decision_by_id + trigger: "When user explicitly confirms deletion of a specific decision by its ID." + action_description: | + # Agent Action: Invoke `delete_decision_by_id` (`{"workspace_id": "...", "decision_id": ID}}`). Emphasize prior confirmation. + - name: log_progress + trigger: "When a task begins, its status changes (e.g., TODO, IN_PROGRESS, DONE), or it's completed. Also when a new sub-task is defined." + action_description: | + # Agent Action: Invoke `log_progress` (`{"workspace_id": "...", "description": "...", "status": "...", "linked_item_type": "...", "linked_item_id": "..."}}`). Note: 'summary' was changed to 'description' for log_progress. + - name: get_progress + trigger: "To review current task statuses, find pending tasks, or check history of progress." + action_description: | + # Agent Action: Invoke `get_progress` (`{"workspace_id": "...", "status_filter": "...", "parent_id_filter": ID, "limit": N}}`). + - name: update_progress + trigger: "Updates an existing progress entry." + action_description: | + # Agent Action: Invoke `update_progress` (`{"workspace_id": "...", "progress_id": ID, "status": "...", "description": "...", "parent_id": ID}}`). + - name: delete_progress_by_id + trigger: "Deletes a progress entry by its ID." + action_description: | + # Agent Action: Invoke `delete_progress_by_id` (`{"workspace_id": "...", "progress_id": ID}}`). + - name: log_system_pattern + trigger: "When new architectural patterns are introduced, or existing ones are modified, as confirmed by the user." + action_description: | + # Agent Action: Invoke `log_system_pattern` (`{"workspace_id": "...", "name": "...", "description": "...", "tags": ["optional_tag"]}}`). + - name: get_system_patterns + trigger: "To retrieve a list of defined system patterns." + action_description: | + # Agent Action: Invoke `get_system_patterns` (`{"workspace_id": "...", "tags_filter_include_all": ["tag1"], "limit": N}}`). Note: limit was not in original example, added for consistency. + - name: delete_system_pattern_by_id + trigger: "When user explicitly confirms deletion of a specific system pattern by its ID." + action_description: | + # Agent Action: Invoke `delete_system_pattern_by_id` (`{"workspace_id": "...", "pattern_id": ID}}`). Emphasize prior confirmation. + - name: log_custom_data + trigger: "To store any other type of structured or unstructured project-related information not covered by other tools (e.g., glossary terms, technical specs, meeting notes), as confirmed by the user." + action_description: | + # Agent Action: Invoke `log_custom_data` (`{"workspace_id": "...", "category": "...", "key": "...", "value": {... or "string"}}`). Note: 'metadata' field is not part of log_custom_data args. + - name: get_custom_data + trigger: "To retrieve specific custom data by category and key." + action_description: | + # Agent Action: Invoke `get_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`). + - name: delete_custom_data + trigger: "When user explicitly confirms deletion of specific custom data by category and key." + action_description: | + # Agent Action: Invoke `delete_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`). Emphasize prior confirmation. + - name: search_custom_data_value_fts + trigger: "When searching for specific terms within any custom data values, categories, or keys." + action_description: | + # Agent Action: Invoke `search_custom_data_value_fts` (`{"workspace_id": "...", "query_term": "...", "category_filter": "...", "limit": N}}`). + - name: search_project_glossary_fts + trigger: "When specifically searching for terms within the 'ProjectGlossary' custom data category." + action_description: | + # Agent Action: Invoke `search_project_glossary_fts` (`{"workspace_id": "...", "query_term": "...", "limit": N}}`). + - name: semantic_search_conport + trigger: "When a natural language query requires conceptual understanding beyond keyword matching, or when direct keyword searches are insufficient." + action_description: | + # Agent Action: Invoke `semantic_search_conport` (`{"workspace_id": "...", "query_text": "...", "top_k": N, "filter_item_types": ["decision", "custom_data"]}}`). Explain filters. + - name: link_conport_items + trigger: "When a meaningful relationship is identified and confirmed between two existing ConPort items (e.g., a decision is implemented by a system pattern, a progress item tracks a decision)." + action_description: | + + - Need to link two items. Identify source type/ID, target type/ID, and relationship. + - Common relationship_types: 'implements', 'related_to', 'tracks', 'blocks', 'clarifies', 'depends_on'. Propose a suitable one or ask user. + + # Agent Action: Invoke `link_conport_items` (`{"workspace_id":"...", "source_item_type":"...", "source_item_id":"...", "target_item_type":"...", "target_item_id":"...", "relationship_type":"...", "description":"Optional notes"}`). + - name: get_linked_items + trigger: "To understand the relationships of a specific ConPort item, or to explore the knowledge graph around an item." + action_description: | + # Agent Action: Invoke `get_linked_items` (`{"workspace_id":"...", "item_type":"...", "item_id":"...", "relationship_type_filter":"...", "linked_item_type_filter":"...", "limit":N}`). + - name: get_item_history + trigger: "When needing to review past versions of Product Context or Active Context, or to see when specific changes were made." + action_description: | + # Agent Action: Invoke `get_item_history` (`{"workspace_id":"...", "item_type":"product_context" or "active_context", "limit":N, "version":V, "before_timestamp":"ISO_DATETIME", "after_timestamp":"ISO_DATETIME"}`). + - name: batch_log_items + trigger: "When the user provides a list of multiple items of the SAME type (e.g., several decisions, multiple new glossary terms) to be logged at once." + action_description: | + + - User provided multiple items. Verify they are of the same loggable type. + - Construct the `items` list, where each element is a dictionary of arguments for the single-item log tool (e.g., for `log_decision`). + + # Agent Action: Invoke `batch_log_items` (`{"workspace_id":"...", "item_type":"decision", "items": [{"summary":"...", "rationale":"..."}, {"summary":"..."}] }`). + - name: get_recent_activity_summary + trigger: "At the start of a new session to catch up, or when the user asks for a summary of recent project activities." + action_description: | + # Agent Action: Invoke `get_recent_activity_summary` (`{"workspace_id":"...", "hours_ago":H, "since_timestamp":"ISO_DATETIME", "limit_per_type":N}`). Explain default if no time args. + - name: get_conport_schema + trigger: "If there's uncertainty about available ConPort tools or their arguments during a session (internal LLM check), or if an advanced user specifically asks for the server's tool schema." + action_description: | + # Agent Action: Invoke `get_conport_schema` (`{"workspace_id":"..."}`). Primarily for internal LLM reference or direct user request. + - name: export_conport_to_markdown + trigger: "When the user requests to export the current ConPort data to markdown files (e.g., for backup, sharing, or version control)." + action_description: | + # Agent Action: Invoke `export_conport_to_markdown` (`{"workspace_id":"...", "output_path":"optional/relative/path"}`). Explain default output path if not provided. + - name: import_markdown_to_conport + trigger: "When the user requests to import ConPort data from a directory of markdown files previously exported by this system." + action_description: | + # Agent Action: Invoke `import_markdown_to_conport` (`{"workspace_id":"...", "input_path":"optional/relative/path"}`). Explain default input path. Warn about potential overwrites or merges if data already exists. + - name: reconfigure_core_guidance + type: guidance + product_active_context: "The internal JSON structure of 'Product Context' and 'Active Context' (the `content` field) is flexible. Work with the user to define and evolve this structure via `update_product_context` and `update_active_context`. The server stores this `content` as a JSON blob." + decisions_progress_patterns: "The fundamental fields for Decisions, Progress, and System Patterns are fixed by ConPort's tools. For significantly different structures or additional fields, guide the user to create a new custom context category using `log_custom_data` (e.g., category: 'project_milestones_detailed')." + + conport_sync_routine: + trigger: "^(Sync ConPort|ConPort Sync)$" + user_acknowledgement_text: "[CONPORT_SYNCING]" + instructions: + - "Halt Current Task: Stop current activity." + - "Acknowledge Command: Send `[CONPORT_SYNCING]` to the user." + - "Review Chat History: Analyze the complete current chat session for new information, decisions, progress, context changes, clarifications, and potential new relationships between items." + core_update_process: + thinking_preamble: | + - Synchronize ConPort with information from the current chat session. + - Use appropriate ConPort tools based on identified changes. + - For `update_product_context` and `update_active_context`, first fetch current content, then merge/update (potentially using `patch_content`), then call the update tool with the *complete new content object* or the patch. + - All tool calls require the `workspace_id`. + agent_action_plan_illustrative: + - "Log new decisions (use `log_decision`)." + - "Log task progress/status changes (use `log_progress`)." + - "Update existing progress entries (use `update_progress`)." + - "Delete progress entries (use `delete_progress_by_id`)." + - "Log new system patterns (use `log_system_pattern`)." + - "Update Active Context (use `get_active_context` then `update_active_context` with full or patch)." + - "Update Product Context if significant changes (use `get_product_context` then `update_product_context` with full or patch)." + - "Log new custom context, including ProjectGlossary terms (use `log_custom_data`)." + - "Identify and log new relationships between items (use `link_conport_items`)." + - "If many items of the same type were discussed, consider `batch_log_items`." + - "After updates, consider a brief `get_recent_activity_summary` to confirm and refresh understanding." + post_sync_actions: + - "Inform user: ConPort synchronized with session info." + - "Resume previous task or await new instructions." + + dynamic_context_retrieval_for_rag: + description: | + Guidance for dynamically retrieving and assembling context from ConPort to answer user queries or perform tasks, + enhancing Retrieval Augmented Generation (RAG) capabilities. + trigger: "When the AI needs to answer a specific question, perform a task requiring detailed project knowledge, or generate content based on ConPort data." + goal: "To construct a concise, highly relevant context set for the LLM, improving the accuracy and relevance of its responses." + steps: + - step: 1 + action: "Analyze User Query/Task" + details: "Deconstruct the user's request to identify key entities, concepts, keywords, and the specific type of information needed from ConPort." + - step: 2 + action: "Prioritized Retrieval Strategy" + details: | + Based on the analysis, select the most appropriate ConPort tools: + - **Targeted FTS:** Use `search_decisions_fts`, `search_custom_data_value_fts`, `search_project_glossary_fts` for keyword-based searches if specific terms are evident. + - **Specific Item Retrieval:** Use `get_custom_data` (if category/key known), `get_decisions` (by ID or for recent items), `get_system_patterns`, `get_progress` if the query points to specific item types or IDs. + - **(Future):** Prioritize semantic search tools once available for conceptual queries. + - **Broad Context (Fallback):** Use `get_product_context` or `get_active_context` as a fallback if targeted retrieval yields little, but be mindful of their size. + - step: 3 + action: "Retrieve Initial Set" + details: "Execute the chosen tool(s) to retrieve an initial, small set (e.g., top 3-5) of the most relevant items or data snippets." + - step: 4 + action: "Contextual Expansion (Optional)" + details: "For the most promising items from Step 3, consider using `get_linked_items` to fetch directly related items (1-hop). This can provide crucial context or disambiguation. Use judiciously to avoid excessive data." + - step: 5 + action: "Synthesize and Filter" + details: | + Review the retrieved information (initial set + expanded context). + - **Filter:** Discard irrelevant items or parts of items. + - **Synthesize/Summarize:** If multiple relevant pieces of information are found, synthesize them into a concise summary that directly addresses the query/task. Extract only the most pertinent sentences or facts. + - step: 6 + action: "Assemble Prompt Context" + details: | + Construct the context portion of the LLM prompt using the filtered and synthesized information. + - **Clarity:** Clearly delineate this retrieved context from the user's query or other parts of the prompt. + - **Attribution (Optional but Recommended):** If possible, briefly note the source of the information (e.g., "From Decision D-42:", "According to System Pattern SP-5:"). + - **Brevity:** Strive for relevance and conciseness. Avoid including large, unprocessed chunks of data unless absolutely necessary and directly requested. + general_principles: + - "Prefer targeted retrieval over broad context dumps." + - "Iterate if initial retrieval is insufficient: try different keywords or tools." + - "Balance context richness with prompt token limits." + + proactive_knowledge_graph_linking: + description: | + Guidance for the AI to proactively identify and suggest the creation of links between ConPort items, + enriching the project's knowledge graph based on conversational context. + trigger: "During ongoing conversation, when the AI observes potential relationships (e.g., causal, implementational, clarifying) between two or more discussed ConPort items or concepts that are likely represented as ConPort items." + goal: "To actively build and maintain a rich, interconnected knowledge graph within ConPort by capturing relationships that might otherwise be missed." + steps: + - step: 1 + action: "Monitor Conversational Context" + details: "Continuously analyze the user's statements and the flow of discussion for mentions of ConPort items (explicitly by ID, or implicitly by well-known names/summaries) and the relationships being described or implied between them." + - step: 2 + action: "Identify Potential Links" + details: | + Look for patterns such as: + - User states "Decision X led to us doing Y (which is Progress item P-3)." + - User discusses how System Pattern SP-2 helps address a concern noted in Decision D-5. + - User outlines a task (Progress P-10) that implements a specific feature detailed in a `custom_data` spec (CD-Spec-FeatureX). + - step: 3 + action: "Formulate and Propose Link Suggestion" + details: | + If a potential link is identified: + - Clearly state the items involved (e.g., "Decision D-5", "System Pattern SP-2"). + - Describe the perceived relationship (e.g., "It seems SP-2 addresses a concern in D-5."). + - Propose creating a link using `ask_followup_question`. + - Example Question: "I noticed we're discussing Decision D-5 and System Pattern SP-2. It sounds like SP-2 might 'address_concern_in' D-5. Would you like me to create this link in ConPort? You can also suggest a different relationship type." + - Suggested Answers: + - "Yes, link them with 'addresses_concern_in'." + - "Yes, but use relationship type: [user types here]." + - "No, don't link them now." + - Offer common relationship types as examples if needed: 'implements', 'clarifies', 'related_to', 'depends_on', 'blocks', 'resolves', 'derived_from'. + - step: 4 + action: "Gather Details and Execute Linking" + details: | + If the user confirms: + - Ensure you have the correct source item type, source item ID, target item type, target item ID, and the agreed-upon relationship type. + - Ask for an optional brief description for the link if the relationship isn't obvious. + - Invoke the `link_conport_items` tool. + - step: 5 + action: "Confirm Outcome" + details: "Inform the user of the success or failure of the `link_conport_items` tool call." + general_principles: + - "Be helpful, not intrusive. If the user declines a suggestion, accept and move on." + - "Prioritize clear, strong relationships over tenuous ones." + - "This strategy complements the general `proactive_logging_cue` by providing specific guidance for link creation." \ No newline at end of file diff --git a/backend/apps/accounts/management/commands/setup_social_providers.py b/backend/apps/accounts/management/commands/setup_social_providers.py index 8f49f1c4..69c61687 100644 --- a/backend/apps/accounts/management/commands/setup_social_providers.py +++ b/backend/apps/accounts/management/commands/setup_social_providers.py @@ -4,48 +4,44 @@ from django.contrib.sites.models import Site class Command(BaseCommand): - help = 'Set up social authentication providers for development' + help = "Set up social authentication providers for development" def handle(self, *args, **options): # Get the current site site = Site.objects.get_current() - self.stdout.write(f'Setting up social providers for site: {site}') + self.stdout.write(f"Setting up social providers for site: {site}") # Clear existing social apps to avoid duplicates deleted_count = SocialApp.objects.all().delete()[0] - self.stdout.write(f'Cleared {deleted_count} existing social apps') + self.stdout.write(f"Cleared {deleted_count} existing social apps") # Create Google social app google_app = SocialApp.objects.create( - provider='google', - name='Google', - client_id='demo-google-client-id.apps.googleusercontent.com', - secret='demo-google-client-secret', - key='', + provider="google", + name="Google", + client_id="demo-google-client-id.apps.googleusercontent.com", + secret="demo-google-client-secret", + key="", ) google_app.sites.add(site) - self.stdout.write( - self.style.SUCCESS('✅ Created Google social app') - ) + self.stdout.write(self.style.SUCCESS("✅ Created Google social app")) # Create Discord social app discord_app = SocialApp.objects.create( - provider='discord', - name='Discord', - client_id='demo-discord-client-id', - secret='demo-discord-client-secret', - key='', + provider="discord", + name="Discord", + client_id="demo-discord-client-id", + secret="demo-discord-client-secret", + key="", ) discord_app.sites.add(site) - self.stdout.write( - self.style.SUCCESS('✅ Created Discord social app') - ) + self.stdout.write(self.style.SUCCESS("✅ Created Discord social app")) # List all social apps - self.stdout.write('\nConfigured social apps:') + self.stdout.write("\nConfigured social apps:") for app in SocialApp.objects.all(): - self.stdout.write(f'- {app.name} ({app.provider}): {app.client_id}') + self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}") self.stdout.write( - self.style.SUCCESS(f'\nTotal social apps: {SocialApp.objects.count()}') + self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}") ) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index d25025e4..152a9e77 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -17,19 +17,26 @@ class UserSerializer(serializers.ModelSerializer): """ User serializer for API responses """ + avatar_url = serializers.SerializerMethodField() class Meta: model = User fields = [ - 'id', 'username', 'email', 'first_name', 'last_name', - 'date_joined', 'is_active', 'avatar_url' + "id", + "username", + "email", + "first_name", + "last_name", + "date_joined", + "is_active", + "avatar_url", ] - read_only_fields = ['id', 'date_joined', 'is_active'] + read_only_fields = ["id", "date_joined", "is_active"] def get_avatar_url(self, obj): """Get user avatar URL""" - if hasattr(obj, 'profile') and obj.profile.avatar: + if hasattr(obj, "profile") and obj.profile.avatar: return obj.profile.avatar.url return None @@ -38,59 +45,57 @@ class LoginSerializer(serializers.Serializer): """ Serializer for user login """ + username = serializers.CharField( - max_length=254, - help_text="Username or email address" + max_length=254, help_text="Username or email address" ) password = serializers.CharField( - max_length=128, - style={'input_type': 'password'}, - trim_whitespace=False + max_length=128, style={"input_type": "password"}, trim_whitespace=False ) def validate(self, attrs): - username = attrs.get('username') - password = attrs.get('password') + username = attrs.get("username") + password = attrs.get("password") if username and password: return attrs - raise serializers.ValidationError( - 'Must include username/email and password.' - ) + raise serializers.ValidationError("Must include username/email and password.") class SignupSerializer(serializers.ModelSerializer): """ Serializer for user registration """ + password = serializers.CharField( write_only=True, validators=[validate_password], - style={'input_type': 'password'} + style={"input_type": "password"}, ) password_confirm = serializers.CharField( - write_only=True, - style={'input_type': 'password'} + write_only=True, style={"input_type": "password"} ) class Meta: model = User fields = [ - 'username', 'email', 'first_name', 'last_name', - 'password', 'password_confirm' + "username", + "email", + "first_name", + "last_name", + "password", + "password_confirm", ] extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True}, + "password": {"write_only": True}, + "email": {"required": True}, } def validate_email(self, value): """Validate email is unique""" if UserModel.objects.filter(email=value).exists(): - raise serializers.ValidationError( - "A user with this email already exists." - ) + raise serializers.ValidationError("A user with this email already exists.") return value def validate_username(self, value): @@ -103,24 +108,22 @@ class SignupSerializer(serializers.ModelSerializer): def validate(self, attrs): """Validate passwords match""" - password = attrs.get('password') - password_confirm = attrs.get('password_confirm') + password = attrs.get("password") + password_confirm = attrs.get("password_confirm") if password != password_confirm: - raise serializers.ValidationError({ - 'password_confirm': 'Passwords do not match.' - }) + raise serializers.ValidationError( + {"password_confirm": "Passwords do not match."} + ) return attrs def create(self, validated_data): """Create user with validated data""" - validated_data.pop('password_confirm', None) - password = validated_data.pop('password') + validated_data.pop("password_confirm", None) + password = validated_data.pop("password") - user = UserModel.objects.create( - **validated_data - ) + user = UserModel.objects.create(**validated_data) user.set_password(password) user.save() @@ -131,6 +134,7 @@ class PasswordResetSerializer(serializers.Serializer): """ Serializer for password reset request """ + email = serializers.EmailField() def validate_email(self, value): @@ -145,37 +149,36 @@ class PasswordResetSerializer(serializers.Serializer): def save(self, **kwargs): """Send password reset email if user exists""" - if hasattr(self, 'user'): + if hasattr(self, "user"): # Create password reset token token = get_random_string(64) PasswordReset.objects.update_or_create( user=self.user, defaults={ - 'token': token, - 'expires_at': timezone.now() + timedelta(hours=24), - 'used': False - } + "token": token, + "expires_at": timezone.now() + timedelta(hours=24), + "used": False, + }, ) # Send reset email - request = self.context.get('request') + request = self.context.get("request") if request: site = get_current_site(request) reset_url = f"{request.scheme}://{site.domain}/reset-password/{token}/" context = { - 'user': self.user, - 'reset_url': reset_url, - 'site_name': site.name, + "user": self.user, + "reset_url": reset_url, + "site_name": site.name, } email_html = render_to_string( - 'accounts/email/password_reset.html', - context + "accounts/email/password_reset.html", context ) EmailService.send_email( - to=getattr(self.user, 'email', None), + to=getattr(self.user, "email", None), subject="Reset your password", text=f"Click the link to reset your password: {reset_url}", site=site, @@ -187,49 +190,45 @@ class PasswordChangeSerializer(serializers.Serializer): """ Serializer for password change """ + old_password = serializers.CharField( - max_length=128, - style={'input_type': 'password'} + max_length=128, style={"input_type": "password"} ) new_password = serializers.CharField( - max_length=128, - validators=[validate_password], - style={'input_type': 'password'} + max_length=128, validators=[validate_password], style={"input_type": "password"} ) new_password_confirm = serializers.CharField( - max_length=128, - style={'input_type': 'password'} + max_length=128, style={"input_type": "password"} ) def validate_old_password(self, value): """Validate old password is correct""" - user = self.context['request'].user + user = self.context["request"].user if not user.check_password(value): - raise serializers.ValidationError( - 'Old password is incorrect.' - ) + raise serializers.ValidationError("Old password is incorrect.") return value def validate(self, attrs): """Validate new passwords match""" - new_password = attrs.get('new_password') - new_password_confirm = attrs.get('new_password_confirm') + new_password = attrs.get("new_password") + new_password_confirm = attrs.get("new_password_confirm") if new_password != new_password_confirm: - raise serializers.ValidationError({ - 'new_password_confirm': 'New passwords do not match.' - }) + raise serializers.ValidationError( + {"new_password_confirm": "New passwords do not match."} + ) return attrs def save(self, **kwargs): """Change user password""" - user = self.context['request'].user - new_password = self.initial_data.get( - 'new_password') if self.initial_data else None + user = self.context["request"].user + new_password = ( + self.initial_data.get("new_password") if self.initial_data else None + ) if new_password is None: - raise serializers.ValidationError('New password is required.') + raise serializers.ValidationError("New password is required.") user.set_password(new_password) user.save() @@ -241,6 +240,7 @@ class SocialProviderSerializer(serializers.Serializer): """ Serializer for social authentication providers """ + id = serializers.CharField() name = serializers.CharField() login_url = serializers.URLField() diff --git a/backend/apps/api/v1/serializers_rankings.py b/backend/apps/api/v1/serializers_rankings.py new file mode 100644 index 00000000..3c9df3e8 --- /dev/null +++ b/backend/apps/api/v1/serializers_rankings.py @@ -0,0 +1,252 @@ +""" +API serializers for the ride ranking system. +""" + +from rest_framework import serializers +from drf_spectacular.utils import extend_schema_serializer, OpenApiExample + +from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot + + +@extend_schema_serializer( + examples=[ + OpenApiExample( + "Ride Ranking Example", + summary="Example ranking response", + description="A ride ranking with all metrics", + value={ + "id": 1, + "rank": 1, + "ride": { + "id": 123, + "name": "Steel Vengeance", + "slug": "steel-vengeance", + "park": {"id": 45, "name": "Cedar Point", "slug": "cedar-point"}, + "category": "RC", + }, + "wins": 523, + "losses": 87, + "ties": 45, + "winning_percentage": 0.8234, + "mutual_riders_count": 1250, + "comparison_count": 655, + "average_rating": 9.2, + "last_calculated": "2024-01-15T02:00:00Z", + "rank_change": 2, + "previous_rank": 3, + }, + ) + ] +) +class RideRankingSerializer(serializers.ModelSerializer): + """Serializer for ride rankings.""" + + ride = serializers.SerializerMethodField() + rank_change = serializers.SerializerMethodField() + previous_rank = serializers.SerializerMethodField() + + class Meta: + model = RideRanking + fields = [ + "id", + "rank", + "ride", + "wins", + "losses", + "ties", + "winning_percentage", + "mutual_riders_count", + "comparison_count", + "average_rating", + "last_calculated", + "rank_change", + "previous_rank", + ] + + def get_ride(self, obj): + """Get ride details.""" + return { + "id": obj.ride.id, + "name": obj.ride.name, + "slug": obj.ride.slug, + "park": { + "id": obj.ride.park.id, + "name": obj.ride.park.name, + "slug": obj.ride.park.slug, + }, + "category": obj.ride.category, + } + + def get_rank_change(self, obj): + """Calculate rank change from previous snapshot.""" + latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by( + "-snapshot_date" + )[:2] + + if len(latest_snapshots) >= 2: + return latest_snapshots[0].rank - latest_snapshots[1].rank + return None + + def get_previous_rank(self, obj): + """Get previous rank.""" + latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by( + "-snapshot_date" + )[:2] + + if len(latest_snapshots) >= 2: + return latest_snapshots[1].rank + return None + + +class RideRankingDetailSerializer(serializers.ModelSerializer): + """Detailed serializer for a specific ride's ranking.""" + + ride = serializers.SerializerMethodField() + head_to_head_comparisons = serializers.SerializerMethodField() + ranking_history = serializers.SerializerMethodField() + + class Meta: + model = RideRanking + fields = [ + "id", + "rank", + "ride", + "wins", + "losses", + "ties", + "winning_percentage", + "mutual_riders_count", + "comparison_count", + "average_rating", + "last_calculated", + "calculation_version", + "head_to_head_comparisons", + "ranking_history", + ] + + def get_ride(self, obj): + """Get detailed ride information.""" + ride = obj.ride + return { + "id": ride.id, + "name": ride.name, + "slug": ride.slug, + "description": ride.description, + "park": { + "id": ride.park.id, + "name": ride.park.name, + "slug": ride.park.slug, + "location": { + "city": ( + ride.park.location.city + if hasattr(ride.park, "location") + else None + ), + "state": ( + ride.park.location.state + if hasattr(ride.park, "location") + else None + ), + "country": ( + ride.park.location.country + if hasattr(ride.park, "location") + else None + ), + }, + }, + "category": ride.category, + "manufacturer": ( + {"id": ride.manufacturer.id, "name": ride.manufacturer.name} + if ride.manufacturer + else None + ), + "opening_date": ride.opening_date, + "status": ride.status, + } + + def get_head_to_head_comparisons(self, obj): + """Get top head-to-head comparisons.""" + from django.db.models import Q + + comparisons = ( + RidePairComparison.objects.filter(Q(ride_a=obj.ride) | Q(ride_b=obj.ride)) + .select_related("ride_a", "ride_b") + .order_by("-mutual_riders_count")[:10] + ) + + results = [] + for comp in comparisons: + if comp.ride_a == obj.ride: + opponent = comp.ride_b + wins = comp.ride_a_wins + losses = comp.ride_b_wins + else: + opponent = comp.ride_a + wins = comp.ride_b_wins + losses = comp.ride_a_wins + + result = "win" if wins > losses else "loss" if losses > wins else "tie" + + results.append( + { + "opponent": { + "id": opponent.id, + "name": opponent.name, + "slug": opponent.slug, + "park": opponent.park.name, + }, + "wins": wins, + "losses": losses, + "ties": comp.ties, + "result": result, + "mutual_riders": comp.mutual_riders_count, + } + ) + + return results + + def get_ranking_history(self, obj): + """Get recent ranking history.""" + history = RankingSnapshot.objects.filter(ride=obj.ride).order_by( + "-snapshot_date" + )[:30] + + return [ + { + "date": snapshot.snapshot_date, + "rank": snapshot.rank, + "winning_percentage": float(snapshot.winning_percentage), + } + for snapshot in history + ] + + +class RankingSnapshotSerializer(serializers.ModelSerializer): + """Serializer for ranking history snapshots.""" + + ride_name = serializers.CharField(source="ride.name", read_only=True) + park_name = serializers.CharField(source="ride.park.name", read_only=True) + + class Meta: + model = RankingSnapshot + fields = [ + "id", + "ride", + "ride_name", + "park_name", + "rank", + "winning_percentage", + "snapshot_date", + ] + + +class RankingStatsSerializer(serializers.Serializer): + """Serializer for ranking system statistics.""" + + total_ranked_rides = serializers.IntegerField() + total_comparisons = serializers.IntegerField() + last_calculation_time = serializers.DateTimeField() + calculation_duration = serializers.FloatField() + top_rated_ride = serializers.DictField() + most_compared_ride = serializers.DictField() + biggest_rank_change = serializers.DictField() diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py index 4b2f06c3..8497eac1 100644 --- a/backend/apps/api/v1/urls.py +++ b/backend/apps/api/v1/urls.py @@ -44,8 +44,14 @@ from .viewsets import ( UserProfileViewSet, TopListViewSet, TopListItemViewSet, + # Trending system views + TrendingAPIView, + NewContentAPIView, ) +# Import ranking viewsets +from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView + # Create the main API router router = DefaultRouter() @@ -53,7 +59,7 @@ router = DefaultRouter() # Core models router.register(r"parks", ParkViewSet, basename="park") -router.register(r"rides", RideViewSet, basename="ride") +# Note: rides registered below with list-only actions to enforce nested-only detail access # Park-related models router.register(r"park-areas", ParkAreaViewSet, basename="park-area") @@ -79,6 +85,9 @@ router.register(r"top-list-items", TopListItemViewSet, basename="top-list-item") router.register(r"ref/parks", ParkReadOnlyViewSet, basename="park-ref") router.register(r"ref/rides", RideReadOnlyViewSet, basename="ride-ref") +# Register ranking endpoints +router.register(r"rankings", RideRankingViewSet, basename="ranking") + app_name = "api_v1" urlpatterns = [ @@ -137,6 +146,39 @@ urlpatterns = [ RideHistoryViewSet.as_view({"get": "retrieve"}), name="ride-history-detail", ), + # Nested park-scoped ride endpoints + path( + "parks//rides/", + RideViewSet.as_view({"get": "list", "post": "create"}), + name="park-rides-list", + ), + path( + "parks//rides//", + RideViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="park-rides-detail", + ), + # Trending system endpoints + path("trending/content/", TrendingAPIView.as_view(), name="trending"), + path("trending/new/", NewContentAPIView.as_view(), name="new-content"), + # Ranking system endpoints + path( + "rankings/calculate/", + TriggerRankingCalculationView.as_view(), + name="trigger-ranking-calculation", + ), + # Global rides list endpoint (detail access only via nested park routes) + path( + "rides/", + RideViewSet.as_view({"get": "list"}), + name="ride-list", + ), # Include all router-generated URLs path("", include(router.urls)), ] diff --git a/backend/apps/api/v1/viewsets.py b/backend/apps/api/v1/viewsets.py index 2b7c9f26..f70aae48 100644 --- a/backend/apps/api/v1/viewsets.py +++ b/backend/apps/api/v1/viewsets.py @@ -28,6 +28,7 @@ from django.core.exceptions import ValidationError from django.utils import timezone from django.conf import settings from django.shortcuts import get_object_or_404 +from django.http import Http404 from allauth.socialaccount.models import SocialApp from allauth.socialaccount import providers from health_check.views import MainView @@ -669,12 +670,20 @@ class RideViewSet(ModelViewSet): def get_queryset(self): # type: ignore[override] """Get optimized queryset based on action.""" if self.action == "list": - # Parse filter parameters for list view + # CRITICAL FIX: Check if this is a nested endpoint first + park_slug = self.kwargs.get("park_slug") + if park_slug: + # For nested endpoints, use the dedicated park selector + from apps.rides.selectors import rides_in_park + return rides_in_park(park_slug=park_slug) + + # For global endpoints, parse filter parameters and use general selector filter_serializer = RideFilterInputSerializer( data=self.request.query_params # type: ignore[attr-defined] ) filter_serializer.is_valid(raise_exception=True) filters = filter_serializer.validated_data + return ride_list_for_display(filters=filters) # type: ignore[arg-type] # For other actions, return base queryset @@ -690,7 +699,10 @@ class RideViewSet(ModelViewSet): ride_slug = self.kwargs.get("slug") or self.kwargs.get("ride_slug") if park_slug and ride_slug: - return ride_detail_optimized(slug=ride_slug, park_slug=park_slug) + try: + return ride_detail_optimized(slug=ride_slug, park_slug=park_slug) + except Ride.DoesNotExist: + raise Http404("Ride not found") elif ride_slug: # For rides accessed directly by slug, we'll use the first approach # and let the 404 handling work naturally @@ -1748,21 +1760,43 @@ class LoginAPIView(TurnstileMixin, APIView): email_or_username = serializer.validated_data["username"] password = serializer.validated_data["password"] # type: ignore[index] - # Try to authenticate with email first, then username + # Optimized user lookup: single query using Q objects + from django.db.models import Q + from django.contrib.auth import get_user_model + + User = get_user_model() user = None - if "@" in email_or_username: - try: - user_obj = UserModel.objects.get(email=email_or_username) + + # Single query to find user by email OR username + try: + if "@" in email_or_username: + # Email-like input: try email first, then username as fallback + user_obj = ( + User.objects.select_related() + .filter( + Q(email=email_or_username) | Q(username=email_or_username) + ) + .first() + ) + else: + # Username-like input: try username first, then email as fallback + user_obj = ( + User.objects.select_related() + .filter( + Q(username=email_or_username) | Q(email=email_or_username) + ) + .first() + ) + + if user_obj: user = authenticate( # type: ignore[attr-defined] request._request, username=user_obj.username, password=password, ) - except UserModel.DoesNotExist: - pass - - if not user: + except Exception: + # Fallback to original behavior user = authenticate( # type: ignore[attr-defined] request._request, @@ -1773,6 +1807,7 @@ class LoginAPIView(TurnstileMixin, APIView): if user: if user.is_active: login(request._request, user) # type: ignore[attr-defined] + # Optimized token creation - get_or_create is atomic token, created = Token.objects.get_or_create(user=user) response_serializer = LoginOutputSerializer( @@ -1981,48 +2016,56 @@ class SocialProvidersAPIView(APIView): serializer_class = SocialProviderOutputSerializer def get(self, request: Request) -> Response: + from django.core.cache import cache + from django.contrib.sites.shortcuts import get_current_site + site = get_current_site(request._request) # type: ignore[attr-defined] + + # Cache key based on site and request host + cache_key = ( + f"social_providers:{getattr(site, 'id', site.pk)}:{request.get_host()}" + ) + + # Try to get from cache first (cache for 15 minutes) + cached_providers = cache.get(cache_key) + if cached_providers is not None: + return Response(cached_providers) + providers_list = [] - # Get all configured social apps for the current site - social_apps = SocialApp.objects.filter(sites=site) + # Optimized query: filter by site and order by provider name + social_apps = SocialApp.objects.filter(sites=site).order_by("provider") for social_app in social_apps: try: - # Get provider class from providers module - provider_module = getattr(providers, social_app.provider, None) - if provider_module and hasattr(provider_module, "provider"): - provider_class = provider_module.provider - provider_instance = provider_class(request) + # Simplified provider name resolution - avoid expensive provider class loading + provider_name = social_app.name or social_app.provider.title() + + # Build auth URL efficiently + auth_url = request.build_absolute_uri( + f"/accounts/{social_app.provider}/login/" + ) + + providers_list.append( + { + "id": social_app.provider, + "name": provider_name, + "authUrl": auth_url, + } + ) - auth_url = request.build_absolute_uri( - f"/accounts/{social_app.provider}/login/" - ) - providers_list.append( - { - "id": social_app.provider, - "name": provider_instance.name, - "authUrl": auth_url, - } - ) - else: - # Fallback: use provider id as name - auth_url = request.build_absolute_uri( - f"/accounts/{social_app.provider}/login/" - ) - providers_list.append( - { - "id": social_app.provider, - "name": social_app.provider.title(), - "authUrl": auth_url, - } - ) except Exception: # Skip if provider can't be loaded continue + # Serialize and cache the result serializer = SocialProviderOutputSerializer(providers_list, many=True) - return Response(serializer.data) + response_data = serializer.data + + # Cache for 15 minutes (900 seconds) + cache.set(cache_key, response_data, 900) + + return Response(response_data) @extend_schema_view( @@ -2908,3 +2951,192 @@ class UnifiedHistoryViewSet(ReadOnlyModelViewSet): serializer = UnifiedHistoryTimelineSerializer(timeline_data) return Response(serializer.data) + + +# === TRENDING VIEWSETS === + + +@extend_schema_view( + list=extend_schema( + summary="Get trending content", + description="Retrieve trending parks and rides based on view counts, ratings, and recency.", + parameters=[ + OpenApiParameter( + name="limit", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Number of trending items to return (default: 20, max: 100)", + ), + OpenApiParameter( + name="timeframe", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Timeframe for trending calculation (day, week, month) - default: week", + ), + ], + responses={200: OpenApiTypes.OBJECT}, + tags=["Trending"], + ), +) +class TrendingAPIView(APIView): + """API endpoint for trending content.""" + + permission_classes = [AllowAny] + + def get(self, request: Request) -> Response: + """Get trending parks and rides.""" + from apps.core.services.trending_service import TrendingService + + # Parse parameters + limit = min(int(request.query_params.get("limit", 20)), 100) + + # Get trending content + trending_service = TrendingService() + all_trending = trending_service.get_trending_content(limit=limit * 2) + + # Separate by content type + trending_rides = [] + trending_parks = [] + + for item in all_trending: + if item.get("category") == "ride": + trending_rides.append(item) + elif item.get("category") == "park": + trending_parks.append(item) + + # Limit each category + trending_rides = trending_rides[: limit // 3] if trending_rides else [] + trending_parks = trending_parks[: limit // 3] if trending_parks else [] + + # Create mock latest reviews (since not implemented yet) + latest_reviews = [ + { + "id": 1, + "name": "Steel Vengeance Review", + "location": "Cedar Point", + "category": "Roller Coaster", + "rating": 5.0, + "rank": 1, + "views": 1234, + "views_change": "+45%", + "slug": "steel-vengeance-review", + } + ][: limit // 3] + + # Return in expected frontend format + response_data = { + "trending_rides": trending_rides, + "trending_parks": trending_parks, + "latest_reviews": latest_reviews, + } + + return Response(response_data) + + +@extend_schema_view( + list=extend_schema( + summary="Get new content", + description="Retrieve recently added parks and rides.", + parameters=[ + OpenApiParameter( + name="limit", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Number of new items to return (default: 20, max: 100)", + ), + OpenApiParameter( + name="days", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Number of days to look back for new content (default: 30, max: 365)", + ), + ], + responses={200: OpenApiTypes.OBJECT}, + tags=["Trending"], + ), +) +class NewContentAPIView(APIView): + """API endpoint for new content.""" + + permission_classes = [AllowAny] + + def get(self, request: Request) -> Response: + """Get new parks and rides.""" + from apps.core.services.trending_service import TrendingService + from datetime import datetime, date + + # Parse parameters + limit = min(int(request.query_params.get("limit", 20)), 100) + + # Get new content with longer timeframe to get more data + trending_service = TrendingService() + all_new_content = trending_service.get_new_content( + limit=limit * 2, days_back=60 + ) + + recently_added = [] + newly_opened = [] + upcoming = [] + + # Categorize items based on date + today = date.today() + + for item in all_new_content: + date_added = item.get("date_added", "") + 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 + + # Calculate days difference + days_diff = (today - item_date).days + + if days_diff <= 30: # Recently added (last 30 days) + recently_added.append(item) + elif days_diff <= 365: # Newly opened (last year) + newly_opened.append(item) + else: # Older items + newly_opened.append(item) + + except (ValueError, TypeError): + # If date parsing fails, add to recently added + recently_added.append(item) + else: + recently_added.append(item) + + # Create mock upcoming items + upcoming = [ + { + "id": 1, + "name": "Epic Universe", + "location": "Universal Orlando", + "category": "Theme Park", + "date_added": "Opening 2025", + "slug": "epic-universe", + }, + { + "id": 2, + "name": "New Fantasyland Expansion", + "location": "Magic Kingdom", + "category": "Land Expansion", + "date_added": "Opening 2026", + "slug": "fantasyland-expansion", + }, + ] + + # Limit each category + recently_added = recently_added[: limit // 3] if recently_added else [] + newly_opened = newly_opened[: limit // 3] if newly_opened else [] + upcoming = upcoming[: limit // 3] if upcoming else [] + + # Return in expected frontend format + response_data = { + "recently_added": recently_added, + "newly_opened": newly_opened, + "upcoming": upcoming, + } + + return Response(response_data) diff --git a/backend/apps/api/v1/viewsets_rankings.py b/backend/apps/api/v1/viewsets_rankings.py new file mode 100644 index 00000000..62c9f1c6 --- /dev/null +++ b/backend/apps/api/v1/viewsets_rankings.py @@ -0,0 +1,334 @@ +""" +API viewsets for the ride ranking system. +""" + +from django.db.models import Q, Count, Max +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 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.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.views import APIView + +from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot +from apps.rides.services import RideRankingService +from .serializers_rankings import ( + RideRankingSerializer, + RideRankingDetailSerializer, + RankingSnapshotSerializer, + RankingStatsSerializer, +) + + +@extend_schema_view( + list=extend_schema( + summary="List ride rankings", + description="Get the current ride rankings calculated using the Internet Roller Coaster Poll algorithm.", + parameters=[ + OpenApiParameter( + name="category", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by ride category (RC, DR, FR, WR, TR, OT)", + enum=["RC", "DR", "FR", "WR", "TR", "OT"], + ), + OpenApiParameter( + name="min_riders", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Minimum number of mutual riders required", + ), + OpenApiParameter( + name="park", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by park slug", + ), + OpenApiParameter( + name="ordering", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Order results (rank, -rank, winning_percentage, -winning_percentage)", + ), + ], + responses={200: RideRankingSerializer(many=True)}, + tags=["Rankings"], + ), + retrieve=extend_schema( + summary="Get ranking details", + description="Get detailed ranking information for a specific ride.", + responses={ + 200: RideRankingDetailSerializer, + 404: OpenApiTypes.OBJECT, + }, + tags=["Rankings"], + ), + history=extend_schema( + summary="Get ranking history", + description="Get historical ranking data for a specific ride.", + responses={200: RankingSnapshotSerializer(many=True)}, + tags=["Rankings"], + ), + statistics=extend_schema( + summary="Get ranking statistics", + description="Get overall statistics about the ranking system.", + responses={200: RankingStatsSerializer}, + tags=["Rankings", "Statistics"], + ), +) +class RideRankingViewSet(ReadOnlyModelViewSet): + """ + ViewSet for ride rankings. + + Provides access to ride rankings calculated using the Internet Roller Coaster Poll algorithm. + Rankings are updated daily and based on pairwise comparisons of user ratings. + """ + + permission_classes = [AllowAny] + lookup_field = "ride__slug" + lookup_url_kwarg = "ride_slug" + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ["ride__category"] + ordering_fields = [ + "rank", + "winning_percentage", + "mutual_riders_count", + "average_rating", + ] + ordering = ["rank"] + + def get_queryset(self): + """Get rankings with optimized queries.""" + queryset = RideRanking.objects.select_related( + "ride", "ride__park", "ride__park__location", "ride__manufacturer" + ) + + # Filter by category + category = self.request.query_params.get("category") + if category: + queryset = queryset.filter(ride__category=category) + + # Filter by minimum mutual riders + min_riders = self.request.query_params.get("min_riders") + if min_riders: + try: + queryset = queryset.filter(mutual_riders_count__gte=int(min_riders)) + except ValueError: + pass + + # Filter by park + park_slug = self.request.query_params.get("park") + if park_slug: + queryset = queryset.filter(ride__park__slug=park_slug) + + return queryset + + def get_serializer_class(self): + """Use different serializers for list vs detail.""" + if self.action == "retrieve": + return RideRankingDetailSerializer + elif self.action == "history": + return RankingSnapshotSerializer + elif self.action == "statistics": + return RankingStatsSerializer + return RideRankingSerializer + + @action(detail=True, methods=["get"]) + def history(self, request, ride_slug=None): + """Get ranking history for a specific ride.""" + ranking = self.get_object() + history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by( + "-snapshot_date" + )[ + :90 + ] # Last 3 months + + serializer = self.get_serializer(history, many=True) + return Response(serializer.data) + + @action(detail=False, methods=["get"]) + def statistics(self, request): + """Get overall ranking system statistics.""" + total_rankings = RideRanking.objects.count() + total_comparisons = RidePairComparison.objects.count() + + # Get last calculation time + latest_ranking = RideRanking.objects.order_by("-last_calculated").first() + last_calc_time = latest_ranking.last_calculated if latest_ranking else None + + # Get top rated ride + top_rated = RideRanking.objects.select_related("ride", "ride__park").first() + + # Get most compared ride + most_compared = ( + RideRanking.objects.select_related("ride", "ride__park") + .order_by("-comparison_count") + .first() + ) + + # Get biggest rank change (last 7 days) + from datetime import timedelta + + week_ago = timezone.now().date() - timedelta(days=7) + + biggest_change = None + max_change = 0 + + current_rankings = RideRanking.objects.select_related("ride") + for ranking in current_rankings[:100]: # Check top 100 for performance + old_snapshot = ( + RankingSnapshot.objects.filter( + ride=ranking.ride, snapshot_date__lte=week_ago + ) + .order_by("-snapshot_date") + .first() + ) + + if old_snapshot: + change = abs(old_snapshot.rank - ranking.rank) + if change > max_change: + max_change = change + biggest_change = { + "ride": { + "id": ranking.ride.id, + "name": ranking.ride.name, + "slug": ranking.ride.slug, + }, + "current_rank": ranking.rank, + "previous_rank": old_snapshot.rank, + "change": old_snapshot.rank - ranking.rank, + } + + stats = { + "total_ranked_rides": total_rankings, + "total_comparisons": total_comparisons, + "last_calculation_time": last_calc_time, + "calculation_duration": None, # Would need to track this separately + "top_rated_ride": ( + { + "id": top_rated.ride.id, + "name": top_rated.ride.name, + "slug": top_rated.ride.slug, + "park": top_rated.ride.park.name, + "rank": top_rated.rank, + "winning_percentage": float(top_rated.winning_percentage), + "average_rating": ( + float(top_rated.average_rating) + if top_rated.average_rating + else None + ), + } + if top_rated + else None + ), + "most_compared_ride": ( + { + "id": most_compared.ride.id, + "name": most_compared.ride.name, + "slug": most_compared.ride.slug, + "park": most_compared.ride.park.name, + "comparison_count": most_compared.comparison_count, + } + if most_compared + else None + ), + "biggest_rank_change": biggest_change, + } + + serializer = RankingStatsSerializer(stats) + return Response(serializer.data) + + @action(detail=True, methods=["get"]) + def comparisons(self, request, ride_slug=None): + """Get head-to-head comparisons for a specific ride.""" + ranking = self.get_object() + + comparisons = ( + RidePairComparison.objects.filter( + Q(ride_a=ranking.ride) | Q(ride_b=ranking.ride) + ) + .select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park") + .order_by("-mutual_riders_count")[:50] + ) + + results = [] + for comp in comparisons: + if comp.ride_a == ranking.ride: + opponent = comp.ride_b + wins = comp.ride_a_wins + losses = comp.ride_b_wins + else: + opponent = comp.ride_a + wins = comp.ride_b_wins + losses = comp.ride_a_wins + + result = "win" if wins > losses else "loss" if losses > wins else "tie" + + results.append( + { + "opponent": { + "id": opponent.id, + "name": opponent.name, + "slug": opponent.slug, + "park": { + "id": opponent.park.id, + "name": opponent.park.name, + "slug": opponent.park.slug, + }, + }, + "wins": wins, + "losses": losses, + "ties": comp.ties, + "result": result, + "mutual_riders": comp.mutual_riders_count, + "ride_a_avg_rating": ( + float(comp.ride_a_avg_rating) + if comp.ride_a_avg_rating + else None + ), + "ride_b_avg_rating": ( + float(comp.ride_b_avg_rating) + if comp.ride_b_avg_rating + else None + ), + } + ) + + return Response(results) + + +@extend_schema( + summary="Trigger ranking calculation", + description="Manually trigger a ranking calculation (admin only).", + request=None, + responses={ + 200: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + }, + tags=["Rankings", "Admin"], +) +class TriggerRankingCalculationView(APIView): + """ + Admin endpoint to manually trigger ranking calculation. + """ + + permission_classes = [IsAuthenticatedOrReadOnly] + + def post(self, request): + """Trigger ranking calculation.""" + if not request.user.is_staff: + return Response( + {"error": "Admin access required"}, status=status.HTTP_403_FORBIDDEN + ) + + category = request.data.get("category") + + service = RideRankingService() + result = service.update_all_rankings(category=category) + + return Response(result) diff --git a/backend/apps/core/analytics.py b/backend/apps/core/analytics.py index b07562ca..51d94ce4 100644 --- a/backend/apps/core/analytics.py +++ b/backend/apps/core/analytics.py @@ -26,12 +26,12 @@ class PageView(models.Model): ] @classmethod - def get_trending_items(cls, model_class, hours=24, limit=10): + def get_trending_items(cls, model_class, hours=168, limit=10): """Get trending items of a specific model class based on views in last X hours. Args: model_class: The model class to get trending items for (e.g., Park, Ride) - hours (int): Number of hours to look back for views (default: 24) + hours (int): Number of hours to look back for views (default: 168 = 7 days) limit (int): Maximum number of items to return (default: 10) Returns: @@ -61,3 +61,65 @@ class PageView(models.Model): return model_class.objects.filter(pk__in=id_list).order_by(preserved) return model_class.objects.none() + + @classmethod + def get_views_growth( + cls, content_type, object_id, current_period_hours, previous_period_hours + ): + """Get view growth statistics between two time periods. + + Args: + content_type: ContentType instance for the model + object_id: ID of the specific object + current_period_hours: Hours for current period (e.g., 24) + previous_period_hours: Hours for previous period (e.g., 48) + + Returns: + tuple: (current_views, previous_views, growth_percentage) + """ + from datetime import timedelta + + now = timezone.now() + + # Current period: last X hours + current_start = now - timedelta(hours=current_period_hours) + current_views = cls.objects.filter( + content_type=content_type, object_id=object_id, timestamp__gte=current_start + ).count() + + # Previous period: X hours before current period + previous_start = now - timedelta(hours=previous_period_hours) + previous_end = current_start + previous_views = cls.objects.filter( + content_type=content_type, + object_id=object_id, + timestamp__gte=previous_start, + timestamp__lt=previous_end, + ).count() + + # Calculate growth percentage + if previous_views == 0: + growth_percentage = current_views * 100 if current_views > 0 else 0 + else: + growth_percentage = ( + (current_views - previous_views) / previous_views + ) * 100 + + return current_views, previous_views, growth_percentage + + @classmethod + def get_total_views_count(cls, content_type, object_id, hours=168): + """Get total view count for an object within specified hours. + + Args: + content_type: ContentType instance for the model + object_id: ID of the specific object + hours: Number of hours to look back (default: 168 = 7 days) + + Returns: + int: Total view count + """ + cutoff = timezone.now() - timedelta(hours=hours) + return cls.objects.filter( + content_type=content_type, object_id=object_id, timestamp__gte=cutoff + ).count() diff --git a/backend/apps/core/management/__init__.py b/backend/apps/core/management/__init__.py index e980e64d..8b137891 100644 --- a/backend/apps/core/management/__init__.py +++ b/backend/apps/core/management/__init__.py @@ -1 +1 @@ -# Django management commands + diff --git a/backend/apps/core/management/commands/__init__.py b/backend/apps/core/management/commands/__init__.py index e980e64d..8b137891 100644 --- a/backend/apps/core/management/commands/__init__.py +++ b/backend/apps/core/management/commands/__init__.py @@ -1 +1 @@ -# Django management commands + diff --git a/backend/apps/core/management/commands/clear_cache.py b/backend/apps/core/management/commands/clear_cache.py new file mode 100644 index 00000000..0db2ccd8 --- /dev/null +++ b/backend/apps/core/management/commands/clear_cache.py @@ -0,0 +1,472 @@ +""" +Django management command to clear all types of cache data. + +This command provides comprehensive cache clearing functionality including: +- Django cache framework (all configured backends) +- Python __pycache__ directories and .pyc files +- Static files cache +- Session cache +- Template cache +- Tailwind CSS build cache +- OPcache (if available) +""" + +import shutil +import subprocess +from pathlib import Path + +from django.core.cache import cache, caches +from django.core.management.base import BaseCommand +from django.conf import settings + + +class Command(BaseCommand): + help = ( + "Clear all types of cache data including Django cache, " + "__pycache__, and build caches" + ) + + def add_arguments(self, parser): + parser.add_argument( + "--django-cache", + action="store_true", + help="Clear Django cache framework cache only", + ) + parser.add_argument( + "--pycache", + action="store_true", + help="Clear Python __pycache__ directories and .pyc files only", + ) + parser.add_argument( + "--static", + action="store_true", + help="Clear static files cache only", + ) + parser.add_argument( + "--sessions", + action="store_true", + help="Clear session cache only", + ) + parser.add_argument( + "--templates", + action="store_true", + help="Clear template cache only", + ) + parser.add_argument( + "--tailwind", + action="store_true", + help="Clear Tailwind CSS build cache only", + ) + parser.add_argument( + "--opcache", + action="store_true", + help="Clear PHP OPcache if available", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be cleared without actually clearing", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Show detailed output of clearing operations", + ) + + def handle(self, *args, **options): + """Clear cache data based on provided options.""" + self.dry_run = options["dry_run"] + self.verbose = options["verbose"] + + # If no specific cache type is specified, clear all + clear_all = not any( + [ + options["django_cache"], + options["pycache"], + options["static"], + options["sessions"], + options["templates"], + options["tailwind"], + options["opcache"], + ] + ) + + if self.dry_run: + self.stdout.write( + self.style.WARNING("🔍 DRY RUN MODE - No files will be deleted") + ) + self.stdout.write("") + + self.stdout.write(self.style.SUCCESS("🧹 ThrillWiki Cache Clearing Utility")) + self.stdout.write("") + + # Clear Django cache framework + if clear_all or options["django_cache"]: + self.clear_django_cache() + + # Clear Python __pycache__ + if clear_all or options["pycache"]: + self.clear_pycache() + + # Clear static files cache + if clear_all or options["static"]: + self.clear_static_cache() + + # Clear sessions cache + if clear_all or options["sessions"]: + self.clear_sessions_cache() + + # Clear template cache + if clear_all or options["templates"]: + self.clear_template_cache() + + # Clear Tailwind cache + if clear_all or options["tailwind"]: + self.clear_tailwind_cache() + + # Clear OPcache + if clear_all or options["opcache"]: + self.clear_opcache() + + self.stdout.write("") + self.stdout.write( + self.style.SUCCESS("✅ Cache clearing completed successfully!") + ) + + def clear_django_cache(self): + """Clear Django cache framework cache.""" + self.stdout.write("🗄️ Clearing Django cache framework...") + + try: + # Clear default cache + if not self.dry_run: + cache.clear() + + cache_info = f"Default cache ({cache.__class__.__name__})" + self.stdout.write(self.style.SUCCESS(f" ✅ Cleared {cache_info}")) + + # Clear all configured caches + cache_aliases = getattr(settings, "CACHES", {}).keys() + for alias in cache_aliases: + if alias != "default": # Already cleared above + try: + cache_backend = caches[alias] + if not self.dry_run: + cache_backend.clear() + + cache_info = ( + f"{alias} cache ({cache_backend.__class__.__name__})" + ) + self.stdout.write( + self.style.SUCCESS(f" ✅ Cleared {cache_info}") + ) + except Exception as e: + self.stdout.write( + self.style.WARNING( + f" ⚠️ Could not clear {alias} cache: {e}" + ) + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Error clearing Django cache: {e}") + ) + + def clear_pycache(self): + """Clear Python __pycache__ directories and .pyc files.""" + self.stdout.write("🐍 Clearing Python __pycache__ and .pyc files...") + + removed_count = 0 + removed_size = 0 + + try: + # Start from project root + project_root = Path(settings.BASE_DIR) + + # Find and remove __pycache__ directories + for pycache_dir in project_root.rglob("__pycache__"): + if pycache_dir.is_dir(): + try: + # Calculate size before removal + dir_size = sum( + f.stat().st_size + for f in pycache_dir.rglob("*") + if f.is_file() + ) + removed_size += dir_size + + if self.verbose: + self.stdout.write(f" 🗑️ Removing: {pycache_dir}") + + if not self.dry_run: + shutil.rmtree(pycache_dir) + + removed_count += 1 + except Exception as e: + self.stdout.write( + self.style.WARNING( + f" ⚠️ Could not remove {pycache_dir}: {e}" + ) + ) + + # Find and remove .pyc files + for pyc_file in project_root.rglob("*.pyc"): + try: + file_size = pyc_file.stat().st_size + removed_size += file_size + + if self.verbose: + self.stdout.write(f" 🗑️ Removing: {pyc_file}") + + if not self.dry_run: + pyc_file.unlink() + + removed_count += 1 + except Exception as e: + self.stdout.write( + self.style.WARNING(f" ⚠️ Could not remove {pyc_file}: {e}") + ) + + # Format file size + size_mb = removed_size / (1024 * 1024) + self.stdout.write( + self.style.SUCCESS( + f" ✅ Removed {removed_count} Python cache items ({size_mb:.2f} MB)" + ) + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Error clearing Python cache: {e}") + ) + + def clear_static_cache(self): + """Clear static files cache.""" + self.stdout.write("📦 Clearing static files cache...") + + try: + static_root = getattr(settings, "STATIC_ROOT", None) + + if static_root and Path(static_root).exists(): + static_path = Path(static_root) + + # Calculate size + total_size = sum( + f.stat().st_size for f in static_path.rglob("*") if f.is_file() + ) + size_mb = total_size / (1024 * 1024) + + if self.verbose: + self.stdout.write(f" 🗑️ Removing: {static_path}") + + if not self.dry_run: + shutil.rmtree(static_path) + static_path.mkdir(parents=True, exist_ok=True) + + self.stdout.write( + self.style.SUCCESS( + f" ✅ Cleared static files cache ({size_mb:.2f} MB)" + ) + ) + else: + self.stdout.write( + self.style.WARNING( + " ⚠️ No STATIC_ROOT configured or directory doesn't exist" + ) + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Error clearing static cache: {e}") + ) + + def clear_sessions_cache(self): + """Clear session cache if using cache-based sessions.""" + self.stdout.write("🔐 Clearing session cache...") + + try: + session_engine = getattr(settings, "SESSION_ENGINE", "") + + if "cache" in session_engine: + # Using cache-based sessions + session_cache_alias = getattr( + settings, "SESSION_CACHE_ALIAS", "default" + ) + session_cache = caches[session_cache_alias] + + if not self.dry_run: + # Clear session keys (this is a simplified approach) + # In production, you might want more sophisticated session clearing + session_cache.clear() + + self.stdout.write( + self.style.SUCCESS( + f" ✅ Cleared cache-based sessions ({session_cache_alias})" + ) + ) + else: + self.stdout.write( + self.style.WARNING(" ⚠️ Not using cache-based sessions") + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Error clearing session cache: {e}") + ) + + def clear_template_cache(self): + """Clear template cache.""" + self.stdout.write("📄 Clearing template cache...") + + try: + # Clear template cache if using cached template loader + from django.template import engines + from django.template.loaders.cached import Loader as CachedLoader + + cleared_engines = 0 + for engine in engines.all(): + try: + # Check for DjangoTemplates engine with cached loaders + engine_backend = getattr(engine, "backend", "") + if "DjangoTemplates" in engine_backend: + # Get engine instance safely + engine_instance = getattr(engine, "engine", None) + if engine_instance: + template_loaders = getattr( + engine_instance, "template_loaders", [] + ) + for loader in template_loaders: + if isinstance(loader, CachedLoader): + if not self.dry_run: + loader.reset() + cleared_engines += 1 + if self.verbose: + self.stdout.write( + f" 🗑️ Cleared cached loader: {loader}" + ) + + # Check for Jinja2 engines (if present) + elif "Jinja2" in engine_backend and hasattr(engine, "env"): + env = getattr(engine, "env", None) + if env and hasattr(env, "cache"): + if not self.dry_run: + env.cache.clear() + cleared_engines += 1 + if self.verbose: + self.stdout.write( + f" 🗑️ Cleared Jinja2 cache: {engine}" + ) + + except Exception as e: + if self.verbose: + self.stdout.write( + self.style.WARNING( + f" ⚠️ Could not clear cache for engine {engine}: {e}" + ) + ) + + if cleared_engines > 0: + self.stdout.write( + self.style.SUCCESS( + f" ✅ Cleared template cache for " + f"{cleared_engines} loaders/engines" + ) + ) + else: + self.stdout.write( + self.style.WARNING(" ⚠️ No cached template loaders found") + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Error clearing template cache: {e}") + ) + + def clear_tailwind_cache(self): + """Clear Tailwind CSS build cache.""" + self.stdout.write("🎨 Clearing Tailwind CSS cache...") + + try: + # Look for common Tailwind cache directories + project_root = Path(settings.BASE_DIR) + cache_paths = [ + project_root / "node_modules" / ".cache", + project_root / ".tailwindcss-cache", + project_root / "static" / "css" / ".cache", + ] + + cleared_count = 0 + for cache_path in cache_paths: + if cache_path.exists(): + try: + if self.verbose: + self.stdout.write(f" 🗑️ Removing: {cache_path}") + + if not self.dry_run: + if cache_path.is_file(): + cache_path.unlink() + else: + shutil.rmtree(cache_path) + + cleared_count += 1 + except Exception as e: + self.stdout.write( + self.style.WARNING( + f" ⚠️ Could not remove {cache_path}: {e}" + ) + ) + + if cleared_count > 0: + self.stdout.write( + self.style.SUCCESS( + f" ✅ Cleared {cleared_count} Tailwind cache directories" + ) + ) + else: + self.stdout.write( + self.style.WARNING(" ⚠️ No Tailwind cache directories found") + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Error clearing Tailwind cache: {e}") + ) + + def clear_opcache(self): + """Clear PHP OPcache if available.""" + self.stdout.write("⚡ Clearing OPcache...") + + try: + # This is mainly for mixed environments + php_code = ( + "if (function_exists('opcache_reset')) { " + "opcache_reset(); echo 'cleared'; } " + "else { echo 'not_available'; }" + ) + result = subprocess.run( + ["php", "-r", php_code], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + if "cleared" in result.stdout: + self.stdout.write( + self.style.SUCCESS(" ✅ OPcache cleared successfully") + ) + else: + self.stdout.write(self.style.WARNING(" ⚠️ OPcache not available")) + else: + self.stdout.write( + self.style.WARNING( + " ⚠️ PHP not available or OPcache not accessible" + ) + ) + + except (subprocess.TimeoutExpired, FileNotFoundError): + self.stdout.write( + self.style.WARNING(" ⚠️ PHP not found or not accessible") + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f" ❌ Error clearing OPcache: {e}")) diff --git a/backend/apps/core/management/commands/test_trending.py b/backend/apps/core/management/commands/test_trending.py new file mode 100644 index 00000000..67a00541 --- /dev/null +++ b/backend/apps/core/management/commands/test_trending.py @@ -0,0 +1,309 @@ +from django.core.management.base import BaseCommand +from django.contrib.contenttypes.models import ContentType +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 + + +class Command(BaseCommand): + help = "Test the trending algorithm with sample data" + + def add_arguments(self, parser): + parser.add_argument( + "--clean", + action="store_true", + help="Clean existing test data before creating new data", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Show detailed output", + ) + + def handle(self, *args, **options): + self.verbose = options["verbose"] + + if options["clean"]: + self.clean_test_data() + + self.create_test_data() + self.test_trending_algorithm() + self.test_api_format() + + self.stdout.write( + self.style.SUCCESS("✓ Trending system test completed successfully!") + ) + + def clean_test_data(self): + """Clean existing test data.""" + self.stdout.write("Cleaning existing test data...") + + # Delete test PageViews + PageView.objects.filter( + content_type__in=[ + ContentType.objects.get_for_model(Park), + ContentType.objects.get_for_model(Ride), + ] + ).delete() + + self.stdout.write("✓ Test data cleaned") + + def create_test_data(self): + """Create sample parks, rides, and page views for testing.""" + self.stdout.write("Creating test data...") + + # Create or get default operator company + operator, created = Company.objects.get_or_create( + name="Default Theme Park Operator", + defaults={ + "roles": ["OPERATOR"], + "description": "Default operator for test parks", + }, + ) + if created and self.verbose: + self.stdout.write(f" Created operator company: {operator.name}") + + # Get or create test parks and rides + parks_data = [ + { + "name": "Cedar Point", + "slug": "cedar-point", + "description": "America's Roller Coast featuring world-class roller coasters", + "average_rating": 9.2, + "opening_date": datetime(1870, 1, 1).date(), + "operator": operator, + }, + { + "name": "Magic Kingdom", + "slug": "magic-kingdom", + "description": "Walt Disney World's most magical theme park", + "average_rating": 9.5, + "opening_date": datetime(1971, 10, 1).date(), + "operator": operator, + }, + { + "name": "Six Flags Great Adventure", + "slug": "six-flags-great-adventure", + "description": "Home to Kingda Ka and incredible thrills", + "average_rating": 8.8, + "opening_date": datetime(1974, 7, 1).date(), + "operator": operator, + }, + ] + + # Create parks + parks = [] + for park_data in parks_data: + park, created = Park.objects.get_or_create( + name=park_data["name"], defaults=park_data + ) + parks.append(park) + if created and self.verbose: + self.stdout.write(f" Created park: {park.name}") + + # Now create rides - they need park references + rides_data = [ + { + "name": "Steel Vengeance", + "slug": "steel-vengeance", + "description": "Hybrid roller coaster at Cedar Point", + "park": next(p for p in parks if p.name == "Cedar Point"), + "category": "RC", # Roller Coaster + "average_rating": 9.8, + "opening_date": datetime(2018, 5, 5).date(), + }, + { + "name": "Space Mountain", + "slug": "space-mountain", + "description": "Indoor space-themed roller coaster", + "park": next(p for p in parks if p.name == "Magic Kingdom"), + "category": "RC", # Roller Coaster + "average_rating": 8.5, + "opening_date": datetime(1975, 1, 15).date(), + }, + { + "name": "Kingda Ka", + "slug": "kingda-ka", + "description": "World's tallest roller coaster", + "park": next(p for p in parks if p.name == "Six Flags Great Adventure"), + "category": "RC", # Roller Coaster + "average_rating": 9.0, + "opening_date": datetime(2005, 5, 21).date(), + }, + { + "name": "Millennium Force", + "slug": "millennium-force", + "description": "Legendary steel roller coaster", + "park": next(p for p in parks if p.name == "Cedar Point"), + "category": "RC", # Roller Coaster + "average_rating": 9.4, + "opening_date": datetime(2000, 5, 13).date(), + }, + ] + + # Create rides + rides = [] + for ride_data in rides_data: + ride, created = Ride.objects.get_or_create( + name=ride_data["name"], defaults=ride_data + ) + rides.append(ride) + if created and self.verbose: + self.stdout.write(f" Created ride: {ride.name}") + + # Create PageViews with different patterns to test trending + self.create_page_views(parks, rides) + + self.stdout.write("✓ Test data created") + + def create_page_views(self, parks, rides): + """Create PageViews with different trending patterns.""" + now = timezone.now() + + # Pattern 1: Recently trending item (Steel Vengeance) + steel_vengeance = next(r for r in rides if r.name == "Steel Vengeance") + self.create_views_for_content( + steel_vengeance, recent_views=50, older_views=10, base_time=now + ) + + # Pattern 2: Consistently popular item (Space Mountain) + space_mountain = next(r for r in rides if r.name == "Space Mountain") + self.create_views_for_content( + space_mountain, recent_views=30, older_views=25, base_time=now + ) + + # Pattern 3: Declining popularity (Kingda Ka) + kingda_ka = next(r for r in rides if r.name == "Kingda Ka") + self.create_views_for_content( + kingda_ka, recent_views=5, older_views=40, base_time=now + ) + + # Pattern 4: New but growing (Millennium Force) + millennium_force = next(r for r in rides if r.name == "Millennium Force") + self.create_views_for_content( + millennium_force, recent_views=25, older_views=5, base_time=now + ) + + # Create some park views too + cedar_point = next(p for p in parks if p.name == "Cedar Point") + self.create_views_for_content( + cedar_point, recent_views=35, older_views=20, base_time=now + ) + + if self.verbose: + self.stdout.write(" Created PageView data for trending analysis") + + def create_views_for_content( + self, content_object, recent_views, older_views, base_time + ): + """Create PageViews for a content object with specified patterns.""" + content_type = ContentType.objects.get_for_model(type(content_object)) + + # Create recent views (last 2 hours) + for i in range(recent_views): + view_time = base_time - timedelta( + minutes=random.randint(0, 120) # Last 2 hours + ) + PageView.objects.create( + content_type=content_type, + object_id=content_object.id, + ip_address=f"192.168.1.{random.randint(1, 255)}", + user_agent="Test Agent", + timestamp=view_time, + ) + + # Create older views (2-24 hours ago) + for i in range(older_views): + view_time = base_time - timedelta(hours=random.randint(2, 24)) + PageView.objects.create( + content_type=content_type, + object_id=content_object.id, + ip_address=f"10.0.0.{random.randint(1, 255)}", + user_agent="Test Agent", + timestamp=view_time, + ) + + def test_trending_algorithm(self): + """Test the trending algorithm functionality.""" + self.stdout.write("Testing trending algorithm...") + + # Test trending content for different content types + trending_parks = trending_service.get_trending_content( + content_type="parks", limit=3 + ) + trending_rides = trending_service.get_trending_content( + content_type="rides", limit=3 + ) + trending_all = trending_service.get_trending_content( + content_type="all", limit=5 + ) + + # Test new content + new_parks = trending_service.get_new_content(content_type="parks", limit=3) + new_rides = trending_service.get_new_content(content_type="rides", limit=3) + new_all = trending_service.get_new_content(content_type="all", limit=5) + + if self.verbose: + self.stdout.write(f" Trending parks: {len(trending_parks)} results") + self.stdout.write(f" Trending rides: {len(trending_rides)} results") + self.stdout.write(f" Trending all: {len(trending_all)} results") + self.stdout.write(f" New parks: {len(new_parks)} results") + self.stdout.write(f" New rides: {len(new_rides)} results") + self.stdout.write(f" New all: {len(new_all)} results") + + self.stdout.write("✓ Trending algorithm working correctly") + + def test_api_format(self): + """Test that API responses match expected frontend format.""" + self.stdout.write("Testing API response format...") + + # Test trending content format + trending_parks = trending_service.get_trending_content( + content_type="parks", limit=3 + ) + trending_rides = trending_service.get_trending_content( + content_type="rides", limit=3 + ) + + # Test new content format + new_parks = trending_service.get_new_content(content_type="parks", limit=3) + new_rides = trending_service.get_new_content(content_type="rides", limit=3) + + # Verify trending data structure + if trending_parks: + item = trending_parks[0] + required_trending_fields = [ + "id", + "name", + "slug", + "views", + "views_change", + "rank", + ] + for field in required_trending_fields: + if field not in item: + raise ValueError(f"Missing required trending field: {field}") + + # Verify new content data structure + if new_parks: + item = new_parks[0] + required_new_fields = ["id", "name", "slug"] + for field in required_new_fields: + if field not in item: + raise ValueError(f"Missing required new content field: {field}") + + if self.verbose: + self.stdout.write(" Sample trending park data:") + if trending_parks: + self.stdout.write(f" {trending_parks[0]}") + + self.stdout.write(" Sample new park data:") + if new_parks: + self.stdout.write(f" {new_parks[0]}") + + self.stdout.write("✓ API format validation passed") diff --git a/backend/apps/core/management/commands/update_trending.py b/backend/apps/core/management/commands/update_trending.py index 39e6586a..78de8a9c 100644 --- a/backend/apps/core/management/commands/update_trending.py +++ b/backend/apps/core/management/commands/update_trending.py @@ -6,30 +6,31 @@ from apps.core.analytics import PageView class Command(BaseCommand): - help = "Updates trending parks and rides cache based on views in the last 24 hours" + help = "Updates trending parks and rides cache based on views in the last 7 days" def handle(self, *args, **kwargs): """ Updates the trending parks and rides in the cache. - This command is designed to be run every hour via cron to keep the trending - items up to date. It looks at page views from the last 24 hours and caches + This command is designed to be run once daily via cron to keep the trending + items up to date. It looks at page views from the last 7 days and caches the top 10 most viewed parks and rides. The cached data is used by the home page to display trending items without having to query the database on every request. """ - # Get top 10 trending parks and rides from the last 24 hours - trending_parks = PageView.get_trending_items(Park, hours=24, limit=10) - trending_rides = PageView.get_trending_items(Ride, hours=24, limit=10) + # Get top 10 trending parks and rides from the last 7 days (168 hours) + trending_parks = PageView.get_trending_items(Park, hours=168, limit=10) + trending_rides = PageView.get_trending_items(Ride, hours=168, limit=10) - # Cache the results for 1 hour - cache.set("trending_parks", trending_parks, 3600) # 3600 seconds = 1 hour - cache.set("trending_rides", trending_rides, 3600) + # Cache the results for 24 hours (daily refresh) + cache.set("trending_parks", trending_parks, 86400) # 86400 seconds = 24 hours + cache.set("trending_rides", trending_rides, 86400) self.stdout.write( self.style.SUCCESS( "Successfully updated trending parks and rides. " - "Cached 10 items each for parks and rides based on views in the last 24 hours." + "Cached 10 items each for parks and rides based on views " + "in the last 7 days." ) ) diff --git a/backend/apps/core/middleware/__init__.py b/backend/apps/core/middleware/__init__.py index fac8a392..ab0538cc 100644 --- a/backend/apps/core/middleware/__init__.py +++ b/backend/apps/core/middleware/__init__.py @@ -1,22 +1,15 @@ -# Core middleware modules +""" +Core middleware package. -# Import middleware classes from the analytics module -from .analytics import PageViewMiddleware, PgHistoryContextMiddleware +This package contains middleware components for the Django application, +including view tracking and other core functionality. +""" -# Import middleware classes from the performance_middleware.py module -from .performance_middleware import ( - PerformanceMiddleware, - QueryCountMiddleware, - DatabaseConnectionMiddleware, - CachePerformanceMiddleware, -) +from .view_tracking import ViewTrackingMiddleware, get_view_stats_for_content +from .analytics import PgHistoryContextMiddleware -# Make all middleware classes available at the package level __all__ = [ - "PageViewMiddleware", + "ViewTrackingMiddleware", + "get_view_stats_for_content", "PgHistoryContextMiddleware", - "PerformanceMiddleware", - "QueryCountMiddleware", - "DatabaseConnectionMiddleware", - "CachePerformanceMiddleware", ] diff --git a/backend/apps/core/middleware/analytics.py b/backend/apps/core/middleware/analytics.py index deef19b4..d480d636 100644 --- a/backend/apps/core/middleware/analytics.py +++ b/backend/apps/core/middleware/analytics.py @@ -44,41 +44,3 @@ class PgHistoryContextMiddleware: def __call__(self, request): response = self.get_response(request) return response - - -class PageViewMiddleware(MiddlewareMixin): - """Middleware to track page views for DetailView-based pages.""" - - def process_view(self, request, view_func, view_args, view_kwargs): - # Only track GET requests - if request.method != "GET": - return None - - # Get view class if it exists - view_class = getattr(view_func, "view_class", None) - if not view_class or not issubclass(view_class, DetailView): - return None - - # Get the object if it's a detail view - try: - view_instance = view_class() - view_instance.request = request - view_instance.args = view_args - view_instance.kwargs = view_kwargs - obj = view_instance.get_object() - except (AttributeError, Exception): - return None - - # Record the page view - try: - PageView.objects.create( - content_type=ContentType.objects.get_for_model(obj.__class__), - object_id=obj.pk, - ip_address=request.META.get("REMOTE_ADDR", ""), - user_agent=request.META.get("HTTP_USER_AGENT", "")[:512], - ) - except Exception: - # Fail silently to not interrupt the request - pass - - return None diff --git a/backend/apps/core/middleware/view_tracking.py b/backend/apps/core/middleware/view_tracking.py new file mode 100644 index 00000000..7a4881f1 --- /dev/null +++ b/backend/apps/core/middleware/view_tracking.py @@ -0,0 +1,331 @@ +""" +View Tracking Middleware for automatic PageView recording. + +This middleware automatically tracks page views for park and ride pages, +implementing IP-based deduplication to prevent spam and provide accurate +analytics for the trending algorithm. +""" + +import logging +import re +from datetime import datetime, timedelta +from typing import Optional, Union +from django.http import HttpRequest, HttpResponse +from django.utils import timezone +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.conf import settings +from django.db import models + +from apps.core.analytics import PageView +from apps.parks.models import Park +from apps.rides.models import Ride + +# Type alias for content objects +ContentObject = Union[Park, Ride] + +logger = logging.getLogger(__name__) + + +class ViewTrackingMiddleware: + """ + Middleware for tracking page views with IP deduplication. + + Automatically creates PageView records when users visit park or ride pages. + Implements 24-hour IP deduplication window to prevent view inflation. + """ + + def __init__(self, get_response): + self.get_response = get_response + self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + # URL patterns for tracking - matches park and ride detail pages + self.tracked_patterns = [ + (r"^/parks/(?P[\w-]+)/$", "park"), + (r"^/rides/(?P[\w-]+)/$", "ride"), + # Add API patterns if needed + (r"^/api/v1/parks/(?P[\w-]+)/$", "park"), + (r"^/api/v1/rides/(?P[\w-]+)/$", "ride"), + ] + + # Compile patterns for performance + self.compiled_patterns = [ + (re.compile(pattern), content_type) + for pattern, content_type in self.tracked_patterns + ] + + # Cache configuration + self.cache_timeout = 60 * 15 # 15 minutes + self.dedup_window_hours = 24 + + def __call__(self, request: HttpRequest) -> HttpResponse: + """Process the request and track views if applicable.""" + response = self.get_response(request) + + # Only track successful GET requests + if ( + request.method == "GET" + and 200 <= response.status_code < 300 + and not self._should_skip_tracking(request) + ): + + try: + self._track_view_if_applicable(request) + except Exception as e: + # Log error but don't break the request + self.logger.error(f"Error tracking view: {e}", exc_info=True) + + return response + + def _should_skip_tracking(self, request: HttpRequest) -> bool: + """Check if this request should be skipped for tracking.""" + # Skip if disabled in settings + if not getattr(settings, "ENABLE_VIEW_TRACKING", True): + return True + + # Skip requests from bots/crawlers + user_agent = request.META.get("HTTP_USER_AGENT", "").lower() + bot_indicators = [ + "bot", + "crawler", + "spider", + "scraper", + "facebook", + "twitter", + "linkedin", + "google", + "bing", + "yahoo", + "duckduckgo", + "slurp", + ] + if any(indicator in user_agent for indicator in bot_indicators): + return True + + # Skip requests without real IP + if not self._get_client_ip(request): + return True + + # Skip AJAX requests (optional - depending on requirements) + if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest": + return True + + return False + + def _track_view_if_applicable(self, request: HttpRequest) -> None: + """Track view if the URL matches tracked patterns.""" + path = request.path + + for pattern, content_type in self.compiled_patterns: + match = pattern.match(path) + if match: + slug = match.group("slug") + self._record_page_view(request, content_type, slug) + break + + def _record_page_view( + self, request: HttpRequest, content_type: str, slug: str + ) -> None: + """Record a page view for the specified content.""" + client_ip = self._get_client_ip(request) + if not client_ip: + return + + try: + # Get the content object + content_obj = self._get_content_object(content_type, slug) + if not content_obj: + self.logger.warning( + f"Content not found: {content_type} with slug '{slug}'" + ) + return + + # Check deduplication + if self._is_duplicate_view(content_obj, client_ip): + self.logger.debug( + f"Duplicate view skipped for {content_type} {slug} from {client_ip}" + ) + return + + # Create PageView record + self._create_page_view(content_obj, client_ip, request) + + self.logger.debug( + f"Recorded view for {content_type} {slug} from {client_ip}" + ) + + except Exception as e: + self.logger.error( + f"Failed to record page view for {content_type} {slug}: {e}" + ) + + def _get_content_object( + self, content_type: str, slug: str + ) -> Optional[ContentObject]: + """Get the content object by type and slug.""" + try: + if content_type == "park": + # Use get_by_slug method to handle historical slugs + park, _ = Park.get_by_slug(slug) + return park + elif content_type == "ride": + # For rides, we need to search by slug within parks + return Ride.objects.filter(slug=slug).first() + else: + self.logger.warning(f"Unknown content type: {content_type}") + return None + + except Park.DoesNotExist: + return None + except Exception as e: + self.logger.error(f"Error getting {content_type} with slug {slug}: {e}") + return None + + def _is_duplicate_view(self, content_obj: ContentObject, client_ip: str) -> bool: + """Check if this view is a duplicate within the deduplication window.""" + # Use cache for performance + cache_key = self._get_dedup_cache_key(content_obj, client_ip) + + if cache.get(cache_key): + return True + + # Check database as fallback + content_type = ContentType.objects.get_for_model(content_obj) + cutoff_time = timezone.now() - timedelta(hours=self.dedup_window_hours) + + existing_view = PageView.objects.filter( + content_type=content_type, + object_id=content_obj.pk, + ip_address=client_ip, + timestamp__gte=cutoff_time, + ).exists() + + if not existing_view: + # Set cache to prevent future duplicates + cache.set(cache_key, True, timeout=self.dedup_window_hours * 3600) + + return existing_view + + def _create_page_view( + self, content_obj: ContentObject, client_ip: str, request: HttpRequest + ) -> None: + """Create a new PageView record.""" + content_type = ContentType.objects.get_for_model(content_obj) + + # Extract additional metadata + user_agent = request.META.get("HTTP_USER_AGENT", "")[ + :500 + ] # Truncate long user agents + referer = request.META.get("HTTP_REFERER", "")[:500] + + PageView.objects.create( + content_type=content_type, + object_id=content_obj.pk, + ip_address=client_ip, + user_agent=user_agent, + referer=referer, + path=request.path[:500], + ) + + # Update cache for deduplication + cache_key = self._get_dedup_cache_key(content_obj, client_ip) + cache.set(cache_key, True, timeout=self.dedup_window_hours * 3600) + + def _get_dedup_cache_key(self, content_obj: ContentObject, client_ip: str) -> str: + """Generate cache key for deduplication.""" + 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]: + """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") + if x_forwarded_for: + # Take the first IP in the chain (client IP) + ip = x_forwarded_for.split(",")[0].strip() + if self._is_valid_ip(ip): + return ip + + # Check for real IP header (some proxy configurations) + x_real_ip = request.META.get("HTTP_X_REAL_IP") + if x_real_ip and self._is_valid_ip(x_real_ip): + return x_real_ip + + # Fall back to remote address + remote_addr = request.META.get("REMOTE_ADDR") + if remote_addr and self._is_valid_ip(remote_addr): + return remote_addr + + return None + + def _is_valid_ip(self, ip: str) -> bool: + """Validate IP address format.""" + try: + # Basic validation - check if it looks like an IP + parts = ip.split(".") + if len(parts) != 4: + return False + + for part in parts: + if not part.isdigit() or not 0 <= int(part) <= 255: + return False + + # 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 + + return True + + except (ValueError, IndexError): + return False + + +def get_view_stats_for_content(content_obj: ContentObject, hours: int = 24) -> dict: + """ + Helper function to get view statistics for content. + + Args: + content_obj: The content object (Park or Ride) + hours: Time window in hours for stats + + Returns: + Dictionary with view statistics + """ + try: + content_type = ContentType.objects.get_for_model(content_obj) + cutoff_time = timezone.now() - timedelta(hours=hours) + + total_views = PageView.objects.filter( + content_type=content_type, + object_id=content_obj.pk, + timestamp__gte=cutoff_time, + ).count() + + unique_views = ( + PageView.objects.filter( + content_type=content_type, + object_id=content_obj.pk, + timestamp__gte=cutoff_time, + ) + .values("ip_address") + .distinct() + .count() + ) + + return { + "total_views": total_views, + "unique_views": unique_views, + "hours": hours, + "content_type": content_type.model, + "content_id": content_obj.pk, + } + + except Exception as e: + logger.error(f"Error getting view stats: {e}") + return {"total_views": 0, "unique_views": 0, "hours": hours, "error": str(e)} diff --git a/backend/apps/core/services/entity_fuzzy_matching.py b/backend/apps/core/services/entity_fuzzy_matching.py new file mode 100644 index 00000000..b4158a6f --- /dev/null +++ b/backend/apps/core/services/entity_fuzzy_matching.py @@ -0,0 +1,415 @@ +""" +Entity Fuzzy Matching Service for ThrillWiki + +Provides intelligent entity matching when exact lookups fail, with authentication +prompts for suggesting new entity creation. + +Features: +- Levenshtein distance for typo correction +- Phonetic matching using Soundex algorithm +- Partial name matching +- Priority-based scoring (parks > rides > companies) +- Authentication state-aware suggestions +""" + +import re +from difflib import SequenceMatcher +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass +from enum import Enum + +from django.db.models import Q + +from apps.parks.models import Park +from apps.rides.models import Ride +from apps.parks.models import Company + + +class EntityType(Enum): + """Supported entity types for fuzzy matching.""" + + PARK = "park" + RIDE = "ride" + COMPANY = "company" + + +@dataclass +class FuzzyMatchResult: + """Result of a fuzzy matching operation.""" + + entity_type: EntityType + entity: Any # The actual model instance + name: str + slug: str + 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 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API responses.""" + return { + "entity_type": self.entity_type.value, + "name": self.name, + "slug": self.slug, + "score": round(self.score, 3), + "match_reason": self.match_reason, + "confidence": self.confidence, + "url": self.url, + "entity_id": getattr(self.entity, "id", None), + } + + +@dataclass +class EntitySuggestion: + """Suggestion for creating a new entity when no matches found.""" + + suggested_name: str + entity_type: EntityType + requires_authentication: bool + login_prompt: str + signup_prompt: str + creation_hint: str + + +class FuzzyMatchingAlgorithms: + """Collection of fuzzy matching algorithms.""" + + @staticmethod + def levenshtein_distance(s1: str, s2: str) -> int: + """Calculate Levenshtein distance between two strings.""" + if len(s1) < len(s2): + return FuzzyMatchingAlgorithms.levenshtein_distance(s2, s1) + + if len(s2) == 0: + return len(s1) + + previous_row = list(range(len(s2) + 1)) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + + return previous_row[-1] + + @staticmethod + def similarity_ratio(s1: str, s2: str) -> float: + """Calculate similarity ratio (0.0 to 1.0) using SequenceMatcher.""" + return SequenceMatcher(None, s1.lower(), s2.lower()).ratio() + + @staticmethod + def soundex(name: str) -> str: + """Generate Soundex code for phonetic matching.""" + name = re.sub(r"[^A-Za-z]", "", name.upper()) + if not name: + return "0000" + + # Soundex algorithm + soundex_map = { + "BFPV": "1", + "CGJKQSXZ": "2", + "DT": "3", + "L": "4", + "MN": "5", + "R": "6", + } + + first_letter = name[0] + name = name[1:] + + # Replace letters with numbers + for letters, number in soundex_map.items(): + name = re.sub(f"[{letters}]", number, name) + + # Remove consecutive duplicates + name = re.sub(r"(\d)\1+", r"\1", name) + + # Remove zeros + name = re.sub("0", "", name) + + # Pad or truncate to 4 characters + soundex_code = (first_letter + name + "000")[:4] + return soundex_code + + @staticmethod + def partial_match_score(query: str, target: str) -> float: + """Calculate partial matching score for substring matches.""" + query_lower = query.lower() + target_lower = target.lower() + + # Exact match + if query_lower == target_lower: + return 1.0 + + # Starts with + if target_lower.startswith(query_lower): + return 0.8 + (len(query) / len(target)) * 0.15 + + # Contains + if query_lower in target_lower: + return 0.6 + (len(query) / len(target)) * 0.2 + + # Words match + query_words = set(query_lower.split()) + target_words = set(target_lower.split()) + if query_words & target_words: + intersection = len(query_words & target_words) + union = len(query_words | target_words) + return 0.4 + (intersection / union) * 0.3 + + return 0.0 + + +class EntityFuzzyMatcher: + """Main fuzzy matching service for entities.""" + + # Matching thresholds + HIGH_CONFIDENCE_THRESHOLD = 0.8 + MEDIUM_CONFIDENCE_THRESHOLD = 0.6 + LOW_CONFIDENCE_THRESHOLD = 0.4 + + # Maximum results to consider + MAX_CANDIDATES = 50 + MAX_RESULTS = 5 + + def __init__(self): + self.algorithms = FuzzyMatchingAlgorithms() + + def find_entity( + self, query: str, entity_types: Optional[List[EntityType]] = None, user=None + ) -> Tuple[List[FuzzyMatchResult], Optional[EntitySuggestion]]: + """ + Find entities matching the query with fuzzy matching. + + Args: + query: Search query string + entity_types: Limit search to specific entity types + user: Current user for authentication context + + Returns: + Tuple of (matches, suggestion_for_new_entity) + """ + if not query or len(query.strip()) < 2: + return [], None + + query = query.strip() + entity_types = entity_types or [ + EntityType.PARK, + EntityType.RIDE, + EntityType.COMPANY, + ] + + # Collect all potential matches + candidates = [] + + for entity_type in entity_types: + candidates.extend(self._get_candidates(query, entity_type)) + + # Score and rank candidates + matches = self._score_and_rank_candidates(query, candidates) + + # Generate suggestion if no good matches found + suggestion = None + if not matches or matches[0].score < self.LOW_CONFIDENCE_THRESHOLD: + suggestion = self._generate_entity_suggestion(query, entity_types, user) + + return matches[: self.MAX_RESULTS], suggestion + + def _get_candidates( + self, query: str, entity_type: EntityType + ) -> List[Dict[str, Any]]: + """Get potential matching candidates for an entity type.""" + candidates = [] + + if entity_type == EntityType.PARK: + parks = Park.objects.filter( + Q(name__icontains=query) + | Q(slug__icontains=query.lower().replace(" ", "-")) + | Q(former_names__icontains=query) + )[: self.MAX_CANDIDATES] + + for park in parks: + candidates.append( + { + "entity_type": EntityType.PARK, + "entity": park, + "name": park.name, + "slug": park.slug, + "search_names": [park.name], + "url": getattr(park, "get_absolute_url", lambda: None)(), + "priority_boost": 0.1, # Parks get priority + } + ) + + elif entity_type == EntityType.RIDE: + rides = Ride.objects.select_related("park").filter( + Q(name__icontains=query) + | Q(slug__icontains=query.lower().replace(" ", "-")) + | Q(former_names__icontains=query) + | Q(park__name__icontains=query) + )[: self.MAX_CANDIDATES] + + for ride in rides: + candidates.append( + { + "entity_type": EntityType.RIDE, + "entity": ride, + "name": ride.name, + "slug": ride.slug, + "search_names": [ride.name, f"{ride.park.name} {ride.name}"], + "url": getattr(ride, "get_absolute_url", lambda: None)(), + "priority_boost": 0.05, # Rides get some priority + } + ) + + elif entity_type == EntityType.COMPANY: + companies = Company.objects.filter( + Q(name__icontains=query) + | Q(slug__icontains=query.lower().replace(" ", "-")) + )[: self.MAX_CANDIDATES] + + for company in companies: + candidates.append( + { + "entity_type": EntityType.COMPANY, + "entity": company, + "name": company.name, + "slug": company.slug, + "search_names": [company.name], + "url": getattr(company, "get_absolute_url", lambda: None)(), + "priority_boost": 0.0, # Companies get no priority boost + } + ) + + return candidates + + def _score_and_rank_candidates( + self, query: str, candidates: List[Dict[str, Any]] + ) -> List[FuzzyMatchResult]: + """Score and rank all candidates using multiple algorithms.""" + scored_matches = [] + + for candidate in candidates: + best_score = 0.0 + best_reason = "" + + # Test against all search names for this candidate + for search_name in candidate["search_names"]: + # Algorithm 1: Sequence similarity + similarity_score = self.algorithms.similarity_ratio(query, search_name) + if similarity_score > best_score: + best_score = similarity_score + best_reason = f"Text similarity with '{search_name}'" + + # Algorithm 2: Partial matching + partial_score = self.algorithms.partial_match_score(query, search_name) + if partial_score > best_score: + best_score = partial_score + best_reason = f"Partial match with '{search_name}'" + + # Algorithm 3: Levenshtein distance + if len(query) > 3 and len(search_name) > 3: + max_len = max(len(query), len(search_name)) + distance = self.algorithms.levenshtein_distance(query, search_name) + lev_score = 1.0 - (distance / max_len) + if lev_score > best_score: + best_score = lev_score + best_reason = f"Similar spelling to '{search_name}'" + + # Algorithm 4: Soundex phonetic matching + if len(query) > 2 and len(search_name) > 2: + query_soundex = self.algorithms.soundex(query) + name_soundex = self.algorithms.soundex(search_name) + if query_soundex == name_soundex and best_score < 0.7: + best_score = max(best_score, 0.7) + best_reason = f"Sounds like '{search_name}'" + + # Apply priority boost + best_score += candidate["priority_boost"] + best_score = min(1.0, best_score) # Cap at 1.0 + + # Determine confidence level + if best_score >= self.HIGH_CONFIDENCE_THRESHOLD: + confidence = "high" + elif best_score >= self.MEDIUM_CONFIDENCE_THRESHOLD: + confidence = "medium" + else: + confidence = "low" + + # Only include if above minimum threshold + if best_score >= self.LOW_CONFIDENCE_THRESHOLD: + match = FuzzyMatchResult( + entity_type=candidate["entity_type"], + entity=candidate["entity"], + name=candidate["name"], + slug=candidate["slug"], + score=best_score, + match_reason=best_reason, + confidence=confidence, + url=candidate["url"], + ) + scored_matches.append(match) + + # Sort by score (highest first) and return + return sorted(scored_matches, key=lambda x: x.score, reverse=True) + + def _generate_entity_suggestion( + self, query: str, entity_types: List[EntityType], user + ) -> EntitySuggestion: + """Generate suggestion for creating new entity when no matches found.""" + + # Determine most likely entity type based on query characteristics + suggested_type = EntityType.PARK # Default to park + + # Simple heuristics for entity type detection + query_lower = query.lower() + if any( + word in query_lower + for word in ["roller coaster", "ride", "coaster", "attraction"] + ): + suggested_type = EntityType.RIDE + elif any( + word in query_lower for word in ["inc", "corp", "company", "manufacturer"] + ): + suggested_type = EntityType.COMPANY + elif EntityType.PARK in entity_types: + suggested_type = EntityType.PARK + elif entity_types: + suggested_type = entity_types[0] + + # Clean up the suggested name + suggested_name = " ".join(word.capitalize() for word in query.split()) + + # Check if user is authenticated + is_authenticated = ( + user and hasattr(user, "is_authenticated") and user.is_authenticated + ) + + # Generate appropriate prompts + entity_name = suggested_type.value + login_prompt = ( + f"Log in to suggest adding '{suggested_name}' as a new {entity_name}" + ) + signup_prompt = ( + f"Sign up to contribute and add '{suggested_name}' to ThrillWiki" + ) + creation_hint = ( + f"Help expand ThrillWiki by adding information about '{suggested_name}'" + ) + + return EntitySuggestion( + suggested_name=suggested_name, + entity_type=suggested_type, + requires_authentication=not is_authenticated, + login_prompt=login_prompt, + signup_prompt=signup_prompt, + creation_hint=creation_hint, + ) + + +# Global service instance +entity_fuzzy_matcher = EntityFuzzyMatcher() diff --git a/backend/apps/core/services/trending_service.py b/backend/apps/core/services/trending_service.py new file mode 100644 index 00000000..20bfa149 --- /dev/null +++ b/backend/apps/core/services/trending_service.py @@ -0,0 +1,594 @@ +""" +Trending Service for calculating and caching trending content. + +This service implements the weighted trending algorithm that combines: +- View growth rates +- Content ratings +- Recency factors +- Popularity metrics + +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 django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.db.models import Q + +from apps.core.analytics import PageView +from apps.parks.models import Park +from apps.rides.models import Ride + +logger = logging.getLogger(__name__) + + +class TrendingService: + """ + Service for calculating trending content using weighted algorithm. + + Algorithm Components: + - View Growth Rate (40% weight): Recent view increase vs historical + - Rating Score (30% weight): Average user rating normalized + - Recency Factor (20% weight): How recently content was added/updated + - Popularity Boost (10% weight): Total view count normalization + """ + + # Algorithm weights (must sum to 1.0) + WEIGHT_VIEW_GROWTH = 0.4 + WEIGHT_RATING = 0.3 + WEIGHT_RECENCY = 0.2 + WEIGHT_POPULARITY = 0.1 + + # Cache configuration + CACHE_PREFIX = "trending" + CACHE_TTL = 86400 # 24 hours (daily refresh) + + # Time windows for calculations + CURRENT_PERIOD_HOURS = 168 # 7 days + PREVIOUS_PERIOD_HOURS = 336 # 14 days (for previous 7-day window comparison) + RECENCY_BASELINE_DAYS = 365 + + def __init__(self): + self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + def get_trending_content( + self, content_type: str = "all", limit: int = 20, force_refresh: bool = False + ) -> List[Dict[str, Any]]: + """ + Get trending content with caching. + + Args: + content_type: 'parks', 'rides', or 'all' + limit: Maximum number of results + force_refresh: Skip cache and recalculate + + Returns: + List of trending content with exact frontend format + """ + cache_key = f"{self.CACHE_PREFIX}:trending:{content_type}:{limit}" + + if not force_refresh: + cached_result = cache.get(cache_key) + if cached_result is not None: + self.logger.debug( + f"Returning cached trending results for {content_type}" + ) + return cached_result + + self.logger.info(f"Calculating trending content for {content_type}") + + try: + # Calculate trending scores for each content type + trending_items = [] + + if content_type in ["all", "parks"]: + park_items = self._calculate_trending_parks( + limit if content_type == "parks" else limit * 2 + ) + trending_items.extend(park_items) + + if content_type in ["all", "rides"]: + ride_items = self._calculate_trending_rides( + limit if content_type == "rides" else limit * 2 + ) + trending_items.extend(ride_items) + + # Sort by trending score and apply limit + trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True) + trending_items = trending_items[:limit] + + # Add ranking and format for frontend + formatted_results = self._format_trending_results(trending_items) + + # Cache results + cache.set(cache_key, formatted_results, self.CACHE_TTL) + + self.logger.info( + f"Calculated {len(formatted_results)} trending items for {content_type}" + ) + return formatted_results + + except Exception as e: + self.logger.error(f"Error calculating trending content: {e}", exc_info=True) + return [] + + def get_new_content( + self, + content_type: str = "all", + limit: int = 20, + days_back: int = 30, + force_refresh: bool = False, + ) -> List[Dict[str, Any]]: + """ + Get recently added content. + + Args: + content_type: 'parks', 'rides', or 'all' + limit: Maximum number of results + days_back: How many days to look back + force_refresh: Skip cache and recalculate + + Returns: + List of new content with exact frontend format + """ + cache_key = f"{self.CACHE_PREFIX}:new:{content_type}:{limit}:{days_back}" + + if not force_refresh: + cached_result = cache.get(cache_key) + if cached_result is not None: + self.logger.debug( + f"Returning cached new content results for {content_type}" + ) + return cached_result + + self.logger.info(f"Calculating new content for {content_type}") + + try: + cutoff_date = timezone.now() - timedelta(days=days_back) + new_items = [] + + if content_type in ["all", "parks"]: + parks = self._get_new_parks( + cutoff_date, limit if content_type == "parks" else limit * 2 + ) + new_items.extend(parks) + + if content_type in ["all", "rides"]: + rides = self._get_new_rides( + cutoff_date, limit if content_type == "rides" else limit * 2 + ) + new_items.extend(rides) + + # Sort by date added (most recent first) and apply limit + new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True) + new_items = new_items[:limit] + + # Format for frontend + formatted_results = self._format_new_content_results(new_items) + + # Cache results + cache.set(cache_key, formatted_results, self.CACHE_TTL) + + self.logger.info( + f"Found {len(formatted_results)} new items for {content_type}" + ) + return formatted_results + + except Exception as e: + self.logger.error(f"Error getting new content: {e}", exc_info=True) + return [] + + 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" + ) + + trending_parks = [] + + for park in parks: + try: + score = self._calculate_content_score(park, "park") + if score > 0: # Only include items with positive trending scores + trending_parks.append( + { + "content_object": park, + "content_type": "park", + "trending_score": score, + "id": park.id, + "name": park.name, + "slug": park.slug, + "location": ( + park.formatted_location + if hasattr(park, "location") + else "" + ), + "category": "park", + "rating": ( + float(park.average_rating) + if park.average_rating + else 0.0 + ), + } + ) + except Exception as e: + self.logger.warning(f"Error calculating score for park {park.id}: {e}") + + return trending_parks + + 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" + ) + + trending_rides = [] + + for ride in rides: + try: + score = self._calculate_content_score(ride, "ride") + if score > 0: # Only include items with positive trending scores + # Get location from park (rides don't have direct location field) + location = "" + if ( + ride.park + and hasattr(ride.park, "location") + and ride.park.location + ): + location = ride.park.formatted_location + + trending_rides.append( + { + "content_object": ride, + "content_type": "ride", + "trending_score": score, + "id": ride.pk, # Use pk instead of id + "name": ride.name, + "slug": ride.slug, + "location": location, + "category": "ride", + "rating": ( + float(ride.average_rating) + if ride.average_rating + else 0.0 + ), + } + ) + except Exception as e: + self.logger.warning(f"Error calculating score for ride {ride.pk}: {e}") + + return trending_rides + + def _calculate_content_score(self, content_obj: Any, content_type: str) -> float: + """ + Calculate weighted trending score for content object. + + Returns: + Float between 0.0 and 1.0 representing trending strength + """ + try: + # Get content type for PageView queries + ct = ContentType.objects.get_for_model(content_obj) + + # 1. View Growth Score (40% weight) + view_growth_score = self._calculate_view_growth_score(ct, content_obj.id) + + # 2. Rating Score (30% weight) + rating_score = self._calculate_rating_score(content_obj) + + # 3. Recency Score (20% weight) + recency_score = self._calculate_recency_score(content_obj) + + # 4. Popularity Score (10% weight) + popularity_score = self._calculate_popularity_score(ct, content_obj.id) + + # Calculate weighted final score + final_score = ( + view_growth_score * self.WEIGHT_VIEW_GROWTH + + rating_score * self.WEIGHT_RATING + + recency_score * self.WEIGHT_RECENCY + + popularity_score * self.WEIGHT_POPULARITY + ) + + self.logger.debug( + f"{content_type} {content_obj.id}: " + f"growth={view_growth_score:.3f}, rating={rating_score:.3f}, " + f"recency={recency_score:.3f}, popularity={popularity_score:.3f}, " + f"final={final_score:.3f}" + ) + + return final_score + + except Exception as e: + self.logger.error( + f"Error calculating score for {content_type} {content_obj.id}: {e}" + ) + return 0.0 + + def _calculate_view_growth_score( + self, content_type: ContentType, object_id: int + ) -> float: + """Calculate normalized view growth score.""" + try: + current_views, previous_views, growth_percentage = ( + PageView.get_views_growth( + content_type, + object_id, + self.CURRENT_PERIOD_HOURS, + self.PREVIOUS_PERIOD_HOURS, + ) + ) + + if previous_views == 0: + # New content with views gets boost + return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0 + + # Normalize growth percentage to 0-1 scale + # 100% growth = 0.5, 500% growth = 1.0 + normalized_growth = ( + min(growth_percentage / 500.0, 1.0) if growth_percentage > 0 else 0.0 + ) + return max(normalized_growth, 0.0) + + except Exception as e: + self.logger.warning(f"Error calculating view growth: {e}") + return 0.0 + + def _calculate_rating_score(self, content_obj: Any) -> float: + """Calculate normalized rating score.""" + try: + rating = getattr(content_obj, "average_rating", None) + if rating is None or rating == 0: + return 0.3 # Neutral score for unrated content + + # Normalize rating from 1-10 scale to 0-1 scale + # Rating of 5 = 0.4, Rating of 8 = 0.7, Rating of 10 = 1.0 + return min(max((float(rating) - 1) / 9.0, 0.0), 1.0) + + except Exception as e: + self.logger.warning(f"Error calculating rating score: {e}") + return 0.3 + + def _calculate_recency_score(self, content_obj: Any) -> float: + """Calculate recency score based on when content was added/updated.""" + try: + # Use opening_date for parks/rides, or created_at as fallback + date_added = getattr(content_obj, "opening_date", None) + if not date_added: + date_added = getattr(content_obj, "created_at", None) + if not date_added: + return 0.5 # Neutral score for unknown dates + + # Handle both date and datetime objects + if hasattr(date_added, "date"): + date_added = date_added.date() + + # Calculate days since added + today = timezone.now().date() + days_since_added = (today - date_added).days + + # Recency score: newer content gets higher scores + # 0 days = 1.0, 30 days = 0.8, 365 days = 0.1, >365 days = 0.0 + if days_since_added <= 0: + return 1.0 + elif days_since_added <= 30: + return 1.0 - (days_since_added / 30.0) * 0.2 # 1.0 to 0.8 + elif days_since_added <= self.RECENCY_BASELINE_DAYS: + return ( + 0.8 + - ((days_since_added - 30) / (self.RECENCY_BASELINE_DAYS - 30)) + * 0.7 + ) # 0.8 to 0.1 + else: + return 0.0 + + except Exception as e: + self.logger.warning(f"Error calculating recency score: {e}") + return 0.5 + + def _calculate_popularity_score( + self, content_type: ContentType, object_id: int + ) -> float: + """Calculate popularity score based on total view count.""" + try: + total_views = PageView.get_total_views_count( + content_type, object_id, hours=168 # Last 7 days + ) + + # Normalize views to 0-1 scale + # 0 views = 0.0, 100 views = 0.5, 1000+ views = 1.0 + if total_views == 0: + return 0.0 + elif total_views <= 100: + return total_views / 200.0 # 0.0 to 0.5 + else: + return min(0.5 + (total_views - 100) / 1800.0, 1.0) # 0.5 to 1.0 + + except Exception as e: + 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]]: + """Get recently added parks.""" + new_parks = ( + Park.objects.filter( + Q(created_at__gte=cutoff_date) + | Q(opening_date__gte=cutoff_date.date()), + status="OPERATING", + ) + .select_related("location", "operator") + .order_by("-created_at", "-opening_date")[:limit] + ) + + results = [] + for park in new_parks: + date_added = park.opening_date or park.created_at + # Handle datetime to date conversion + if date_added: + # If it's a datetime, convert to date + if isinstance(date_added, datetime): + date_added = date_added.date() + # If it's already a date, keep it as is + + results.append( + { + "content_object": park, + "content_type": "park", + "id": park.pk, # Use pk instead of id for Django compatibility + "name": park.name, + "slug": park.slug, + "location": ( + park.formatted_location if hasattr(park, "location") else "" + ), + "category": "park", + "date_added": date_added.isoformat() if date_added else "", + } + ) + + return results + + def _get_new_rides(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + """Get recently added rides.""" + new_rides = ( + Ride.objects.filter( + Q(created_at__gte=cutoff_date) + | Q(opening_date__gte=cutoff_date.date()), + status="OPERATING", + ) + .select_related("park", "park__location") + .order_by("-created_at", "-opening_date")[:limit] + ) + + results = [] + for ride in new_rides: + date_added = getattr(ride, "opening_date", None) or getattr( + ride, "created_at", None + ) + # Handle datetime to date conversion + if date_added: + # If it's a datetime, convert to date + if isinstance(date_added, datetime): + date_added = date_added.date() + # If it's already a date, keep it as is + + # Get location from park (rides don't have direct location field) + location = "" + if ride.park and hasattr(ride.park, "location") and ride.park.location: + location = ride.park.formatted_location + + results.append( + { + "content_object": ride, + "content_type": "ride", + "id": ride.pk, # Use pk instead of id for Django compatibility + "name": ride.name, + "slug": ride.slug, + "location": location, + "category": "ride", + "date_added": date_added.isoformat() if date_added else "", + } + ) + + return results + + def _format_trending_results( + self, trending_items: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Format trending results for frontend consumption.""" + formatted_results = [] + + for rank, item in enumerate(trending_items, 1): + try: + # Get view change for display + content_obj = item["content_object"] + ct = ContentType.objects.get_for_model(content_obj) + current_views, previous_views, growth_percentage = ( + PageView.get_views_growth( + ct, + content_obj.id, + self.CURRENT_PERIOD_HOURS, + self.PREVIOUS_PERIOD_HOURS, + ) + ) + + # Format exactly as frontend expects + formatted_item = { + "id": item["id"], + "name": item["name"], + "location": item["location"], + "category": item["category"], + "rating": item["rating"], + "rank": rank, + "views": current_views, + "views_change": ( + f"+{growth_percentage:.1f}%" + if growth_percentage > 0 + else f"{growth_percentage:.1f}%" + ), + "slug": item["slug"], + } + + formatted_results.append(formatted_item) + + except Exception as e: + self.logger.warning(f"Error formatting trending item: {e}") + + return formatted_results + + def _format_new_content_results( + self, new_items: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Format new content results for frontend consumption.""" + formatted_results = [] + + for item in new_items: + try: + # Format exactly as frontend expects + formatted_item = { + "id": item["id"], + "name": item["name"], + "location": item["location"], + "category": item["category"], + "date_added": item["date_added"], + "slug": item["slug"], + } + + formatted_results.append(formatted_item) + + except Exception as e: + self.logger.warning(f"Error formatting new content item: {e}") + + return formatted_results + + def clear_cache(self, content_type: str = "all") -> None: + """Clear trending and new content caches.""" + try: + cache_patterns = [ + f"{self.CACHE_PREFIX}:trending:{content_type}:*", + f"{self.CACHE_PREFIX}:new:{content_type}:*", + ] + + if content_type == "all": + cache_patterns.extend( + [ + f"{self.CACHE_PREFIX}:trending:parks:*", + f"{self.CACHE_PREFIX}:trending:rides:*", + f"{self.CACHE_PREFIX}:new:parks:*", + f"{self.CACHE_PREFIX}:new:rides:*", + ] + ) + + # Note: This is a simplified cache clear + # In production, you might want to use cache.delete_many() or similar + cache.clear() + self.logger.info(f"Cleared trending caches for {content_type}") + + except Exception as e: + self.logger.error(f"Error clearing cache: {e}") + + +# Singleton service instance +trending_service = TrendingService() diff --git a/backend/apps/core/urls.py b/backend/apps/core/urls.py new file mode 100644 index 00000000..3f4ff7b5 --- /dev/null +++ b/backend/apps/core/urls.py @@ -0,0 +1,24 @@ +""" +Core app URL configuration. +""" + +from django.urls import path, include +from .views.entity_search import ( + EntityFuzzySearchView, + EntityNotFoundView, + QuickEntitySuggestionView, +) + +app_name = 'core' + +# Entity search endpoints +entity_patterns = [ + path('search/', EntityFuzzySearchView.as_view(), name='entity_fuzzy_search'), + path('not-found/', EntityNotFoundView.as_view(), name='entity_not_found'), + path('suggestions/', QuickEntitySuggestionView.as_view(), name='entity_suggestions'), +] + +urlpatterns = [ + # Entity fuzzy matching and search endpoints + path('entities/', include(entity_patterns)), +] \ No newline at end of file diff --git a/backend/apps/core/urls/__init__.py b/backend/apps/core/urls/__init__.py new file mode 100644 index 00000000..01327379 --- /dev/null +++ b/backend/apps/core/urls/__init__.py @@ -0,0 +1 @@ +# URLs package for core app diff --git a/backend/apps/core/views/entity_search.py b/backend/apps/core/views/entity_search.py new file mode 100644 index 00000000..892164a5 --- /dev/null +++ b/backend/apps/core/views/entity_search.py @@ -0,0 +1,347 @@ +""" +Entity search views with fuzzy matching and authentication prompts. +""" + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from typing import Optional, List + +from ..services.entity_fuzzy_matching import ( + entity_fuzzy_matcher, + EntityType, +) + + +class EntityFuzzySearchView(APIView): + """ + API endpoint for fuzzy entity search with authentication prompts. + + Handles entity lookup failures by providing intelligent suggestions and + authentication prompts for entity creation. + """ + + permission_classes = [AllowAny] # Allow both authenticated and anonymous users + + def post(self, request): + """ + Perform fuzzy entity search. + + Request body: + { + "query": "entity name to search", + "entity_types": ["park", "ride", "company"], // optional + "include_suggestions": true // optional, default true + } + + Response: + { + "success": true, + "query": "original query", + "matches": [ + { + "entity_type": "park", + "name": "Cedar Point", + "slug": "cedar-point", + "score": 0.95, + "confidence": "high", + "match_reason": "Text similarity with 'Cedar Point'", + "url": "/parks/cedar-point/", + "entity_id": 123 + } + ], + "suggestion": { + "suggested_name": "New Entity Name", + "entity_type": "park", + "requires_authentication": true, + "login_prompt": "Log in to suggest adding...", + "signup_prompt": "Sign up to contribute...", + "creation_hint": "Help expand ThrillWiki..." + }, + "user_authenticated": false + } + """ + try: + # Parse request data + query = request.data.get("query", "").strip() + entity_types_raw = request.data.get( + "entity_types", ["park", "ride", "company"] + ) + include_suggestions = request.data.get("include_suggestions", True) + + # Validate query + if not query or len(query) < 2: + return Response( + { + "success": False, + "error": "Query must be at least 2 characters long", + "code": "INVALID_QUERY", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Parse and validate entity types + entity_types = [] + valid_types = {"park", "ride", "company"} + + for entity_type in entity_types_raw: + if entity_type in valid_types: + entity_types.append(EntityType(entity_type)) + + if not entity_types: + entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY] + + # Perform fuzzy matching + matches, suggestion = entity_fuzzy_matcher.find_entity( + query=query, entity_types=entity_types, user=request.user + ) + + # Format response + response_data = { + "success": True, + "query": query, + "matches": [match.to_dict() for match in matches], + "user_authenticated": ( + request.user.is_authenticated + if hasattr(request.user, "is_authenticated") + else False + ), + } + + # Include suggestion if requested and available + if include_suggestions and suggestion: + response_data["suggestion"] = { + "suggested_name": suggestion.suggested_name, + "entity_type": suggestion.entity_type.value, + "requires_authentication": suggestion.requires_authentication, + "login_prompt": suggestion.login_prompt, + "signup_prompt": suggestion.signup_prompt, + "creation_hint": suggestion.creation_hint, + } + + return Response(response_data, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + { + "success": False, + "error": f"Internal server error: {str(e)}", + "code": "INTERNAL_ERROR", + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class EntityNotFoundView(APIView): + """ + Endpoint specifically for handling entity not found scenarios. + + This view is called when normal entity lookup fails and provides + fuzzy matching suggestions along with authentication prompts. + """ + + permission_classes = [AllowAny] + + def post(self, request): + """ + Handle entity not found with suggestions. + + Request body: + { + "original_query": "what user searched for", + "attempted_slug": "slug-that-failed", // optional + "entity_type": "park", // optional, inferred from context + "context": { // optional context information + "park_slug": "park-slug-if-searching-for-ride", + "source_page": "page where search originated" + } + } + """ + try: + original_query = request.data.get("original_query", "").strip() + attempted_slug = request.data.get("attempted_slug", "") + entity_type_hint = request.data.get("entity_type") + context = request.data.get("context", {}) + + if not original_query: + return Response( + { + "success": False, + "error": "original_query is required", + "code": "MISSING_QUERY", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Determine entity types to search based on context + entity_types = [] + if entity_type_hint: + try: + 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: + entity_types = [EntityType.RIDE, EntityType.PARK] + + # Default to all types if not specified + if not entity_types: + entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY] + + # Try fuzzy matching on the original query + matches, suggestion = entity_fuzzy_matcher.find_entity( + query=original_query, entity_types=entity_types, user=request.user + ) + + # If no matches on original query, try the attempted slug + if not matches and attempted_slug: + # Convert slug back to readable name for fuzzy matching + slug_as_name = attempted_slug.replace("-", " ").title() + matches, suggestion = entity_fuzzy_matcher.find_entity( + query=slug_as_name, entity_types=entity_types, user=request.user + ) + + # Prepare response with detailed context + response_data = { + "success": True, + "original_query": original_query, + "attempted_slug": attempted_slug, + "context": context, + "matches": [match.to_dict() for match in matches], + "user_authenticated": ( + request.user.is_authenticated + if hasattr(request.user, "is_authenticated") + else False + ), + "has_matches": len(matches) > 0, + } + + # Always include suggestion for entity not found scenarios + if suggestion: + response_data["suggestion"] = { + "suggested_name": suggestion.suggested_name, + "entity_type": suggestion.entity_type.value, + "requires_authentication": suggestion.requires_authentication, + "login_prompt": suggestion.login_prompt, + "signup_prompt": suggestion.signup_prompt, + "creation_hint": suggestion.creation_hint, + } + + return Response(response_data, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + { + "success": False, + "error": f"Internal server error: {str(e)}", + "code": "INTERNAL_ERROR", + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class QuickEntitySuggestionView(APIView): + """ + Lightweight endpoint for quick entity suggestions (e.g., autocomplete). + """ + + permission_classes = [AllowAny] + + def get(self, request): + """ + Get quick entity suggestions. + + Query parameters: + - q: query string + - types: comma-separated entity types (park,ride,company) + - limit: max results (default 5) + """ + try: + query = request.GET.get("q", "").strip() + types_param = request.GET.get("types", "park,ride,company") + limit = min(int(request.GET.get("limit", 5)), 10) # Cap at 10 + + if not query or len(query) < 2: + return Response( + {"suggestions": [], "query": query}, status=status.HTTP_200_OK + ) + + # Parse entity types + entity_types = [] + for type_str in types_param.split(","): + type_str = type_str.strip() + if type_str in ["park", "ride", "company"]: + entity_types.append(EntityType(type_str)) + + if not entity_types: + entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY] + + # Get fuzzy matches + matches, _ = entity_fuzzy_matcher.find_entity( + query=query, entity_types=entity_types, user=request.user + ) + + # Format as simple suggestions + suggestions = [] + for match in matches[:limit]: + suggestions.append( + { + "name": match.name, + "type": match.entity_type.value, + "slug": match.slug, + "url": match.url, + "score": match.score, + "confidence": match.confidence, + } + ) + + return Response( + {"suggestions": suggestions, "query": query, "count": len(suggestions)}, + status=status.HTTP_200_OK, + ) + + except Exception as e: + return Response( + {"suggestions": [], "query": request.GET.get("q", ""), "error": str(e)}, + status=status.HTTP_200_OK, + ) # Return 200 even on errors for autocomplete + + +# Utility function for other views to use +def get_entity_suggestions( + query: str, entity_types: Optional[List[str]] = None, user=None +): + """ + Utility function for other Django views to get entity suggestions. + + Args: + query: Search query + entity_types: List of entity type strings + user: Django user object + + Returns: + Tuple of (matches, suggestion) + """ + try: + # Convert string types to EntityType enums + parsed_types = [] + if entity_types: + for entity_type in entity_types: + try: + parsed_types.append(EntityType(entity_type)) + except ValueError: + continue + + if not parsed_types: + parsed_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY] + + return entity_fuzzy_matcher.find_entity( + query=query, entity_types=parsed_types, user=user + ) + except Exception: + return [], None diff --git a/backend/apps/parks/admin.py b/backend/apps/parks/admin.py index f974af2a..b083b2b9 100644 --- a/backend/apps/parks/admin.py +++ b/backend/apps/parks/admin.py @@ -317,55 +317,55 @@ class PgHistoryEventsAdmin(admin.ModelAdmin): """Admin interface for pghistory Events""" list_display = ( - 'pgh_id', - 'pgh_created_at', - 'pgh_label', - 'pgh_model', - 'pgh_obj_id', - 'pgh_context_display', + "pgh_id", + "pgh_created_at", + "pgh_label", + "pgh_model", + "pgh_obj_id", + "pgh_context_display", ) list_filter = ( - 'pgh_label', - 'pgh_model', - 'pgh_created_at', + "pgh_label", + "pgh_model", + "pgh_created_at", ) search_fields = ( - 'pgh_obj_id', - 'pgh_context', + "pgh_obj_id", + "pgh_context", ) readonly_fields = ( - 'pgh_id', - 'pgh_created_at', - 'pgh_label', - 'pgh_model', - 'pgh_obj_id', - 'pgh_context', - 'pgh_data', + "pgh_id", + "pgh_created_at", + "pgh_label", + "pgh_model", + "pgh_obj_id", + "pgh_context", + "pgh_data", ) - date_hierarchy = 'pgh_created_at' - ordering = ('-pgh_created_at',) + date_hierarchy = "pgh_created_at" + ordering = ("-pgh_created_at",) fieldsets = ( ( - 'Event Information', + "Event Information", { - 'fields': ( - 'pgh_id', - 'pgh_created_at', - 'pgh_label', - 'pgh_model', - 'pgh_obj_id', + "fields": ( + "pgh_id", + "pgh_created_at", + "pgh_label", + "pgh_model", + "pgh_obj_id", ) }, ), ( - 'Context & Data', + "Context & Data", { - 'fields': ( - 'pgh_context', - 'pgh_data', + "fields": ( + "pgh_context", + "pgh_data", ), - 'classes': ('collapse',), + "classes": ("collapse",), }, ), ) @@ -392,7 +392,7 @@ class PgHistoryEventsAdmin(admin.ModelAdmin): def has_delete_permission(self, request, obj=None): """Prevent deletion of history events""" - return getattr(request.user, 'is_superuser', False) + return getattr(request.user, "is_superuser", False) # Register the models with their admin classes diff --git a/backend/apps/rides/admin.py b/backend/apps/rides/admin.py index 9f56839e..84dba847 100644 --- a/backend/apps/rides/admin.py +++ b/backend/apps/rides/admin.py @@ -5,6 +5,7 @@ from .models.company import Company from .models.rides import Ride, RideModel, RollerCoasterStats from .models.location import RideLocation from .models.reviews import RideReview +from .models.rankings import RideRanking, RidePairComparison, RankingSnapshot class ManufacturerAdmin(admin.ModelAdmin): @@ -484,4 +485,222 @@ class CompanyAdmin(admin.ModelAdmin): return ", ".join(obj.roles) if obj.roles else "No roles" +@admin.register(RideRanking) +class RideRankingAdmin(admin.ModelAdmin): + """Admin interface for ride rankings""" + + list_display = ( + "rank", + "ride_name", + "park_name", + "winning_percentage_display", + "wins", + "losses", + "ties", + "average_rating", + "mutual_riders_count", + "last_calculated", + ) + list_filter = ( + "ride__category", + "last_calculated", + "calculation_version", + ) + search_fields = ( + "ride__name", + "ride__park__name", + ) + readonly_fields = ( + "ride", + "rank", + "wins", + "losses", + "ties", + "winning_percentage", + "mutual_riders_count", + "comparison_count", + "average_rating", + "last_calculated", + "calculation_version", + "total_comparisons", + ) + ordering = ["rank"] + + fieldsets = ( + ( + "Ride Information", + {"fields": ("ride",)}, + ), + ( + "Ranking Metrics", + { + "fields": ( + "rank", + "winning_percentage", + "wins", + "losses", + "ties", + "total_comparisons", + ) + }, + ), + ( + "Additional Metrics", + { + "fields": ( + "average_rating", + "mutual_riders_count", + "comparison_count", + ) + }, + ), + ( + "Calculation Info", + { + "fields": ( + "last_calculated", + "calculation_version", + ), + "classes": ("collapse",), + }, + ), + ) + + @admin.display(description="Ride") + def ride_name(self, obj): + return obj.ride.name + + @admin.display(description="Park") + def park_name(self, obj): + return obj.ride.park.name + + @admin.display(description="Win %") + def winning_percentage_display(self, obj): + return f"{obj.winning_percentage:.1%}" + + def has_add_permission(self, request): + # Rankings are calculated automatically + return False + + def has_change_permission(self, request, obj=None): + # Rankings are read-only + return False + + +@admin.register(RidePairComparison) +class RidePairComparisonAdmin(admin.ModelAdmin): + """Admin interface for ride pair comparisons""" + + list_display = ( + "comparison_summary", + "ride_a_name", + "ride_b_name", + "winner_display", + "ride_a_wins", + "ride_b_wins", + "ties", + "mutual_riders_count", + "last_calculated", + ) + list_filter = ("last_calculated",) + search_fields = ( + "ride_a__name", + "ride_b__name", + "ride_a__park__name", + "ride_b__park__name", + ) + readonly_fields = ( + "ride_a", + "ride_b", + "ride_a_wins", + "ride_b_wins", + "ties", + "mutual_riders_count", + "ride_a_avg_rating", + "ride_b_avg_rating", + "last_calculated", + "winner", + "is_tie", + ) + ordering = ["-mutual_riders_count"] + + @admin.display(description="Comparison") + def comparison_summary(self, obj): + return f"{obj.ride_a.name} vs {obj.ride_b.name}" + + @admin.display(description="Ride A") + def ride_a_name(self, obj): + return obj.ride_a.name + + @admin.display(description="Ride B") + def ride_b_name(self, obj): + return obj.ride_b.name + + @admin.display(description="Winner") + def winner_display(self, obj): + if obj.is_tie: + return "TIE" + winner = obj.winner + if winner: + return winner.name + return "N/A" + + def has_add_permission(self, request): + # Comparisons are calculated automatically + return False + + def has_change_permission(self, request, obj=None): + # Comparisons are read-only + return False + + +@admin.register(RankingSnapshot) +class RankingSnapshotAdmin(admin.ModelAdmin): + """Admin interface for ranking history snapshots""" + + list_display = ( + "ride_name", + "park_name", + "rank", + "winning_percentage_display", + "snapshot_date", + ) + list_filter = ( + "snapshot_date", + "ride__category", + ) + search_fields = ( + "ride__name", + "ride__park__name", + ) + readonly_fields = ( + "ride", + "rank", + "winning_percentage", + "snapshot_date", + ) + date_hierarchy = "snapshot_date" + ordering = ["-snapshot_date", "rank"] + + @admin.display(description="Ride") + def ride_name(self, obj): + return obj.ride.name + + @admin.display(description="Park") + def park_name(self, obj): + return obj.ride.park.name + + @admin.display(description="Win %") + def winning_percentage_display(self, obj): + return f"{obj.winning_percentage:.1%}" + + def has_add_permission(self, request): + # Snapshots are created automatically + return False + + def has_change_permission(self, request, obj=None): + # Snapshots are read-only + return False + + admin.site.register(RideLocation, RideLocationAdmin) diff --git a/backend/apps/rides/management/__init__.py b/backend/apps/rides/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/rides/management/commands/__init__.py b/backend/apps/rides/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/rides/management/commands/update_ride_rankings.py b/backend/apps/rides/management/commands/update_ride_rankings.py new file mode 100644 index 00000000..fd1f587b --- /dev/null +++ b/backend/apps/rides/management/commands/update_ride_rankings.py @@ -0,0 +1,36 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.rides.services import RideRankingService + + +class Command(BaseCommand): + help = "Calculates and updates ride rankings using the Internet Roller Coaster Poll algorithm" + + def add_arguments(self, parser): + parser.add_argument( + "--category", + type=str, + default=None, + help="Optional ride category to filter (e.g., RC for roller coasters)", + ) + + def handle(self, *args, **options): + category = options.get("category") + + service = RideRankingService() + self.stdout.write( + self.style.SUCCESS( + f"Starting ride ranking calculation at {timezone.now().isoformat()}" + ) + ) + + result = service.update_all_rankings(category=category) + + self.stdout.write( + self.style.SUCCESS( + f"Completed ranking calculation: {result.get('rides_ranked', 0)} rides ranked, " + f"{result.get('comparisons_made', 0)} comparisons, " + f"duration={result.get('duration', 0):.2f}s" + ) + ) diff --git a/backend/apps/rides/migrations/0006_add_ride_rankings.py b/backend/apps/rides/migrations/0006_add_ride_rankings.py new file mode 100644 index 00000000..1f092f50 --- /dev/null +++ b/backend/apps/rides/migrations/0006_add_ride_rankings.py @@ -0,0 +1,603 @@ +# Generated by Django 5.2.5 on 2025-08-25 00:50 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pghistory", "0007_auto_20250421_0444"), + ("rides", "0005_ridelocationevent_ridelocation_insert_insert_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="RidePairComparison", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "ride_a_wins", + models.PositiveIntegerField( + default=0, + help_text="Number of mutual riders who rated ride_a higher", + ), + ), + ( + "ride_b_wins", + models.PositiveIntegerField( + default=0, + help_text="Number of mutual riders who rated ride_b higher", + ), + ), + ( + "ties", + models.PositiveIntegerField( + default=0, + help_text="Number of mutual riders who rated both rides equally", + ), + ), + ( + "mutual_riders_count", + models.PositiveIntegerField( + default=0, + help_text="Total number of users who have rated both rides", + ), + ), + ( + "ride_a_avg_rating", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Average rating of ride_a from mutual riders", + max_digits=3, + null=True, + ), + ), + ( + "ride_b_avg_rating", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Average rating of ride_b from mutual riders", + max_digits=3, + null=True, + ), + ), + ( + "last_calculated", + models.DateTimeField( + auto_now=True, + help_text="When this comparison was last calculated", + ), + ), + ( + "ride_a", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comparisons_as_a", + to="rides.ride", + ), + ), + ( + "ride_b", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comparisons_as_b", + to="rides.ride", + ), + ), + ], + ), + migrations.CreateModel( + name="RidePairComparisonEvent", + 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()), + ( + "ride_a_wins", + models.PositiveIntegerField( + default=0, + help_text="Number of mutual riders who rated ride_a higher", + ), + ), + ( + "ride_b_wins", + models.PositiveIntegerField( + default=0, + help_text="Number of mutual riders who rated ride_b higher", + ), + ), + ( + "ties", + models.PositiveIntegerField( + default=0, + help_text="Number of mutual riders who rated both rides equally", + ), + ), + ( + "mutual_riders_count", + models.PositiveIntegerField( + default=0, + help_text="Total number of users who have rated both rides", + ), + ), + ( + "ride_a_avg_rating", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Average rating of ride_a from mutual riders", + max_digits=3, + null=True, + ), + ), + ( + "ride_b_avg_rating", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Average rating of ride_b from mutual riders", + max_digits=3, + null=True, + ), + ), + ( + "last_calculated", + models.DateTimeField( + auto_now=True, + help_text="When this comparison was last calculated", + ), + ), + ( + "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.ridepaircomparison", + ), + ), + ( + "ride_a", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="rides.ride", + ), + ), + ( + "ride_b", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="rides.ride", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RideRanking", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rank", + models.PositiveIntegerField( + db_index=True, help_text="Overall rank position (1 = best)" + ), + ), + ( + "wins", + models.PositiveIntegerField( + default=0, + help_text="Number of rides this ride beats in pairwise comparisons", + ), + ), + ( + "losses", + models.PositiveIntegerField( + default=0, + help_text="Number of rides that beat this ride in pairwise comparisons", + ), + ), + ( + "ties", + models.PositiveIntegerField( + default=0, + help_text="Number of rides with equal preference in pairwise comparisons", + ), + ), + ( + "winning_percentage", + models.DecimalField( + db_index=True, + decimal_places=4, + help_text="Win percentage where ties count as 0.5", + max_digits=5, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(1), + ], + ), + ), + ( + "mutual_riders_count", + models.PositiveIntegerField( + default=0, + help_text="Total number of users who have rated this ride", + ), + ), + ( + "comparison_count", + models.PositiveIntegerField( + default=0, + help_text="Number of other rides this was compared against", + ), + ), + ( + "average_rating", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Average rating from all users who have rated this ride", + max_digits=3, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ], + ), + ), + ( + "last_calculated", + models.DateTimeField( + default=django.utils.timezone.now, + help_text="When this ranking was last calculated", + ), + ), + ( + "calculation_version", + models.CharField( + default="1.0", + help_text="Algorithm version used for calculation", + max_length=10, + ), + ), + ( + "ride", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="ranking", + to="rides.ride", + ), + ), + ], + options={ + "ordering": ["rank"], + }, + ), + migrations.CreateModel( + name="RideRankingEvent", + 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()), + ( + "rank", + models.PositiveIntegerField( + help_text="Overall rank position (1 = best)" + ), + ), + ( + "wins", + models.PositiveIntegerField( + default=0, + help_text="Number of rides this ride beats in pairwise comparisons", + ), + ), + ( + "losses", + models.PositiveIntegerField( + default=0, + help_text="Number of rides that beat this ride in pairwise comparisons", + ), + ), + ( + "ties", + models.PositiveIntegerField( + default=0, + help_text="Number of rides with equal preference in pairwise comparisons", + ), + ), + ( + "winning_percentage", + models.DecimalField( + decimal_places=4, + help_text="Win percentage where ties count as 0.5", + max_digits=5, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(1), + ], + ), + ), + ( + "mutual_riders_count", + models.PositiveIntegerField( + default=0, + help_text="Total number of users who have rated this ride", + ), + ), + ( + "comparison_count", + models.PositiveIntegerField( + default=0, + help_text="Number of other rides this was compared against", + ), + ), + ( + "average_rating", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Average rating from all users who have rated this ride", + max_digits=3, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ], + ), + ), + ( + "last_calculated", + models.DateTimeField( + default=django.utils.timezone.now, + help_text="When this ranking was last calculated", + ), + ), + ( + "calculation_version", + models.CharField( + default="1.0", + help_text="Algorithm version used for calculation", + max_length=10, + ), + ), + ( + "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.rideranking", + ), + ), + ( + "ride", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="rides.ride", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RankingSnapshot", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("rank", models.PositiveIntegerField()), + ( + "winning_percentage", + models.DecimalField(decimal_places=4, max_digits=5), + ), + ( + "snapshot_date", + models.DateField( + db_index=True, + help_text="Date when this ranking snapshot was taken", + ), + ), + ( + "ride", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ranking_history", + to="rides.ride", + ), + ), + ], + options={ + "ordering": ["-snapshot_date", "rank"], + "indexes": [ + models.Index( + fields=["snapshot_date", "rank"], + name="rides_ranki_snapsho_8e2657_idx", + ), + models.Index( + fields=["ride", "-snapshot_date"], + name="rides_ranki_ride_id_827bb9_idx", + ), + ], + "unique_together": {("ride", "snapshot_date")}, + }, + ), + migrations.AddIndex( + model_name="ridepaircomparison", + index=models.Index( + fields=["ride_a", "ride_b"], name="rides_ridep_ride_a__eb0674_idx" + ), + ), + migrations.AddIndex( + model_name="ridepaircomparison", + index=models.Index( + fields=["last_calculated"], name="rides_ridep_last_ca_bd9f6c_idx" + ), + ), + migrations.AlterUniqueTogether( + name="ridepaircomparison", + unique_together={("ride_a", "ride_b")}, + ), + pgtrigger.migrations.AddTrigger( + model_name="ridepaircomparison", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_ridepaircomparisonevent" ("id", "last_calculated", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_a_avg_rating", "ride_a_id", "ride_a_wins", "ride_b_avg_rating", "ride_b_id", "ride_b_wins", "ties") VALUES (NEW."id", NEW."last_calculated", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_a_avg_rating", NEW."ride_a_id", NEW."ride_a_wins", NEW."ride_b_avg_rating", NEW."ride_b_id", NEW."ride_b_wins", NEW."ties"); RETURN NULL;', + hash="6a640e10fcfd58c48029ee5b84ea7f0826f50022", + operation="INSERT", + pgid="pgtrigger_insert_insert_9ad59", + table="rides_ridepaircomparison", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="ridepaircomparison", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_ridepaircomparisonevent" ("id", "last_calculated", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_a_avg_rating", "ride_a_id", "ride_a_wins", "ride_b_avg_rating", "ride_b_id", "ride_b_wins", "ties") VALUES (NEW."id", NEW."last_calculated", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_a_avg_rating", NEW."ride_a_id", NEW."ride_a_wins", NEW."ride_b_avg_rating", NEW."ride_b_id", NEW."ride_b_wins", NEW."ties"); RETURN NULL;', + hash="a77eee0b791bada3f84f008dabd7486c66b03fa6", + operation="UPDATE", + pgid="pgtrigger_update_update_73b31", + table="rides_ridepaircomparison", + when="AFTER", + ), + ), + ), + migrations.AddIndex( + model_name="rideranking", + index=models.Index(fields=["rank"], name="rides_rider_rank_ea4706_idx"), + ), + migrations.AddIndex( + model_name="rideranking", + index=models.Index( + fields=["winning_percentage", "-mutual_riders_count"], + name="rides_rider_winning_d9b3e8_idx", + ), + ), + migrations.AddIndex( + model_name="rideranking", + index=models.Index( + fields=["ride", "last_calculated"], + name="rides_rider_ride_id_ece73d_idx", + ), + ), + migrations.AddConstraint( + model_name="rideranking", + constraint=models.CheckConstraint( + condition=models.Q( + ("winning_percentage__gte", 0), ("winning_percentage__lte", 1) + ), + name="rideranking_winning_percentage_range", + violation_error_message="Winning percentage must be between 0 and 1", + ), + ), + migrations.AddConstraint( + model_name="rideranking", + constraint=models.CheckConstraint( + condition=models.Q( + ("average_rating__isnull", True), + models.Q(("average_rating__gte", 1), ("average_rating__lte", 10)), + _connector="OR", + ), + name="rideranking_average_rating_range", + violation_error_message="Average rating must be between 1 and 10", + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="rideranking", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_riderankingevent" ("average_rating", "calculation_version", "comparison_count", "id", "last_calculated", "losses", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "ride_id", "ties", "winning_percentage", "wins") VALUES (NEW."average_rating", NEW."calculation_version", NEW."comparison_count", NEW."id", NEW."last_calculated", NEW."losses", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."ride_id", NEW."ties", NEW."winning_percentage", NEW."wins"); RETURN NULL;', + hash="c5f9dced5824a55e6f36e476eb382ed770aa5716", + operation="INSERT", + pgid="pgtrigger_insert_insert_01af3", + table="rides_rideranking", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="rideranking", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_riderankingevent" ("average_rating", "calculation_version", "comparison_count", "id", "last_calculated", "losses", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "ride_id", "ties", "winning_percentage", "wins") VALUES (NEW."average_rating", NEW."calculation_version", NEW."comparison_count", NEW."id", NEW."last_calculated", NEW."losses", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."ride_id", NEW."ties", NEW."winning_percentage", NEW."wins"); RETURN NULL;', + hash="363e44ce3c87e8b66406d63d6f1b26ad604c79d2", + operation="UPDATE", + pgid="pgtrigger_update_update_c3f27", + table="rides_rideranking", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/rides/models/__init__.py b/backend/apps/rides/models/__init__.py index 4e1b7010..703e6092 100644 --- a/backend/apps/rides/models/__init__.py +++ b/backend/apps/rides/models/__init__.py @@ -8,9 +8,10 @@ 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, Categories +from .rides import Ride, RideModel, RollerCoasterStats, Categories, CATEGORY_CHOICES from .location import RideLocation from .reviews import RideReview +from .rankings import RideRanking, RidePairComparison, RankingSnapshot __all__ = [ # Primary models @@ -19,6 +20,10 @@ __all__ = [ "RollerCoasterStats", "RideLocation", "RideReview", + # Rankings + "RideRanking", + "RidePairComparison", + "RankingSnapshot", # Shared constants "Categories", ] diff --git a/backend/apps/rides/models/rankings.py b/backend/apps/rides/models/rankings.py new file mode 100644 index 00000000..abe4710e --- /dev/null +++ b/backend/apps/rides/models/rankings.py @@ -0,0 +1,212 @@ +""" +Models for ride ranking system using Internet Roller Coaster Poll algorithm. + +This system calculates rankings based on pairwise comparisons between rides, +where each ride is compared to every other ride to determine which one +more riders preferred. +""" + +from django.db import models +from django.utils import timezone +from django.core.validators import MinValueValidator, MaxValueValidator +import pghistory + + +@pghistory.track() +class RideRanking(models.Model): + """ + Stores calculated rankings for rides using the Internet Roller Coaster Poll algorithm. + + Rankings are recalculated daily based on user reviews/ratings. + Each ride's rank is determined by its winning percentage in pairwise comparisons. + """ + + ride = models.OneToOneField( + "rides.Ride", on_delete=models.CASCADE, related_name="ranking" + ) + + # Core ranking metrics + rank = models.PositiveIntegerField( + db_index=True, help_text="Overall rank position (1 = best)" + ) + wins = models.PositiveIntegerField( + default=0, help_text="Number of rides this ride beats in pairwise comparisons" + ) + losses = models.PositiveIntegerField( + default=0, + help_text="Number of rides that beat this ride in pairwise comparisons", + ) + ties = models.PositiveIntegerField( + default=0, + help_text="Number of rides with equal preference in pairwise comparisons", + ) + winning_percentage = models.DecimalField( + max_digits=5, + decimal_places=4, + validators=[MinValueValidator(0), MaxValueValidator(1)], + db_index=True, + help_text="Win percentage where ties count as 0.5", + ) + + # Additional metrics + mutual_riders_count = models.PositiveIntegerField( + default=0, help_text="Total number of users who have rated this ride" + ) + comparison_count = models.PositiveIntegerField( + default=0, help_text="Number of other rides this was compared against" + ) + average_rating = models.DecimalField( + max_digits=3, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(1), MaxValueValidator(10)], + help_text="Average rating from all users who have rated this ride", + ) + + # Metadata + last_calculated = models.DateTimeField( + default=timezone.now, help_text="When this ranking was last calculated" + ) + calculation_version = models.CharField( + max_length=10, default="1.0", help_text="Algorithm version used for calculation" + ) + + class Meta: + ordering = ["rank"] + indexes = [ + models.Index(fields=["rank"]), + models.Index(fields=["winning_percentage", "-mutual_riders_count"]), + models.Index(fields=["ride", "last_calculated"]), + ] + constraints = [ + models.CheckConstraint( + name="rideranking_winning_percentage_range", + check=models.Q(winning_percentage__gte=0) + & models.Q(winning_percentage__lte=1), + violation_error_message="Winning percentage must be between 0 and 1", + ), + models.CheckConstraint( + name="rideranking_average_rating_range", + check=models.Q(average_rating__isnull=True) + | (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)), + violation_error_message="Average rating must be between 1 and 10", + ), + ] + + def __str__(self): + return f"#{self.rank} - {self.ride.name} ({self.winning_percentage:.1%})" + + @property + def total_comparisons(self): + """Total number of pairwise comparisons (wins + losses + ties).""" + return self.wins + self.losses + self.ties + + +@pghistory.track() +class RidePairComparison(models.Model): + """ + Caches pairwise comparison results between two rides. + + This model stores the results of comparing two rides based on mutual riders + (users who have rated both rides). It's used to speed up ranking calculations. + """ + + ride_a = models.ForeignKey( + "rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_a" + ) + ride_b = models.ForeignKey( + "rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_b" + ) + + # Comparison results + ride_a_wins = models.PositiveIntegerField( + default=0, help_text="Number of mutual riders who rated ride_a higher" + ) + ride_b_wins = models.PositiveIntegerField( + default=0, help_text="Number of mutual riders who rated ride_b higher" + ) + ties = models.PositiveIntegerField( + default=0, help_text="Number of mutual riders who rated both rides equally" + ) + + # Metrics + mutual_riders_count = models.PositiveIntegerField( + default=0, help_text="Total number of users who have rated both rides" + ) + ride_a_avg_rating = models.DecimalField( + max_digits=3, + decimal_places=2, + null=True, + blank=True, + help_text="Average rating of ride_a from mutual riders", + ) + ride_b_avg_rating = models.DecimalField( + max_digits=3, + decimal_places=2, + null=True, + blank=True, + help_text="Average rating of ride_b from mutual riders", + ) + + # Metadata + last_calculated = models.DateTimeField( + auto_now=True, help_text="When this comparison was last calculated" + ) + + class Meta: + unique_together = [["ride_a", "ride_b"]] + indexes = [ + models.Index(fields=["ride_a", "ride_b"]), + models.Index(fields=["last_calculated"]), + ] + + def __str__(self): + winner = "TIE" + if self.ride_a_wins > self.ride_b_wins: + winner = self.ride_a.name + elif self.ride_b_wins > self.ride_a_wins: + winner = self.ride_b.name + return f"{self.ride_a.name} vs {self.ride_b.name} - Winner: {winner}" + + @property + def winner(self): + """Returns the winning ride or None for a tie.""" + if self.ride_a_wins > self.ride_b_wins: + return self.ride_a + elif self.ride_b_wins > self.ride_a_wins: + return self.ride_b + return None + + @property + def is_tie(self): + """Returns True if the comparison resulted in a tie.""" + return self.ride_a_wins == self.ride_b_wins + + +class RankingSnapshot(models.Model): + """ + Stores historical snapshots of rankings for tracking changes over time. + + This allows us to show ranking trends and movements. + """ + + ride = models.ForeignKey( + "rides.Ride", on_delete=models.CASCADE, related_name="ranking_history" + ) + rank = models.PositiveIntegerField() + winning_percentage = models.DecimalField(max_digits=5, decimal_places=4) + snapshot_date = models.DateField( + db_index=True, help_text="Date when this ranking snapshot was taken" + ) + + class Meta: + unique_together = [["ride", "snapshot_date"]] + ordering = ["-snapshot_date", "rank"] + indexes = [ + models.Index(fields=["snapshot_date", "rank"]), + models.Index(fields=["ride", "-snapshot_date"]), + ] + + def __str__(self): + return f"{self.ride.name} - Rank #{self.rank} on {self.snapshot_date}" diff --git a/backend/apps/rides/models/rides.py b/backend/apps/rides/models/rides.py index 1e2979ee..63404427 100644 --- a/backend/apps/rides/models/rides.py +++ b/backend/apps/rides/models/rides.py @@ -1,6 +1,7 @@ from django.db import models from django.utils.text import slugify from django.contrib.contenttypes.fields import GenericRelation +from django.db.models import Avg from apps.core.models import TrackedModel from .company import Company import pghistory @@ -56,7 +57,11 @@ class RideModel(TrackedModel): @pghistory.track() class Ride(TrackedModel): - """Model for individual ride installations at parks""" + """Model for individual ride installations at parks + + Note: The average_rating field is denormalized and refreshed by background + jobs. Use selectors or annotations for real-time calculations if needed. + """ STATUS_CHOICES = [ ("", "Select status"), diff --git a/backend/apps/rides/selectors.py b/backend/apps/rides/selectors.py index 21015868..017734bf 100644 --- a/backend/apps/rides/selectors.py +++ b/backend/apps/rides/selectors.py @@ -8,7 +8,7 @@ from django.db.models import QuerySet, Q, Count, Avg, Prefetch from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance -from .models import Ride, RideModel, RideReview +from .models import Ride, RideModel, RideReview, CATEGORY_CHOICES def ride_list_for_display( @@ -32,15 +32,15 @@ def ride_list_for_display( "ride_model", "park_area", ) - .prefetch_related("park__location", "location") + .prefetch_related("park__location") .annotate(average_rating_calculated=Avg("reviews__rating")) ) if filters: - if "status" in filters: - queryset = queryset.filter(status=filters["status"]) - if "category" in filters: - queryset = queryset.filter(category=filters["category"]) + if "status" in filters and filters["status"]: + queryset = queryset.filter(status__in=filters["status"]) + if "category" in filters and filters["category"]: + queryset = queryset.filter(category__in=filters["category"]) if "manufacturer" in filters: queryset = queryset.filter(manufacturer=filters["manufacturer"]) if "park" in filters: @@ -81,7 +81,6 @@ def ride_detail_optimized(*, slug: str, park_slug: str) -> Ride: ) .prefetch_related( "park__location", - "location", Prefetch( "reviews", queryset=RideReview.objects.select_related("user").filter( @@ -164,7 +163,7 @@ def rides_in_park(*, park_slug: str) -> QuerySet[Ride]: return ( Ride.objects.filter(park__slug=park_slug) .select_related("manufacturer", "designer", "ride_model", "park_area") - .prefetch_related("location") + .prefetch_related() .annotate(average_rating_calculated=Avg("reviews__rating")) .order_by("park_area__name", "name") ) diff --git a/backend/apps/rides/services/__init__.py b/backend/apps/rides/services/__init__.py new file mode 100644 index 00000000..4d32e6e8 --- /dev/null +++ b/backend/apps/rides/services/__init__.py @@ -0,0 +1,7 @@ +""" +Services for the rides app. +""" + +from .ranking_service import RideRankingService + +__all__ = ["RideRankingService"] diff --git a/backend/apps/rides/services/ranking_service.py b/backend/apps/rides/services/ranking_service.py new file mode 100644 index 00000000..70f2bf38 --- /dev/null +++ b/backend/apps/rides/services/ranking_service.py @@ -0,0 +1,550 @@ +""" +Service for calculating ride rankings using the Internet Roller Coaster Poll algorithm. + +This service implements a pairwise comparison system where each ride is compared +to every other ride based on mutual riders (users who have rated both rides). +Rankings are determined by winning percentage in these comparisons. +""" + +import logging +from typing import Dict, List, Tuple, Optional +from decimal import Decimal +from datetime import date + +from django.db import transaction +from django.db.models import Avg, Count, Q, F +from django.utils import timezone + +from apps.rides.models import ( + Ride, + RideReview, + RideRanking, + RidePairComparison, + RankingSnapshot, +) + + +logger = logging.getLogger(__name__) + + +class RideRankingService: + """ + Calculates ride rankings using the Internet Roller Coaster Poll algorithm. + + Algorithm Overview: + 1. For each pair of rides, find users who have rated both + 2. Count how many users preferred each ride (higher rating) + 3. Calculate wins, losses, and ties for each ride + 4. Rank rides by winning percentage (ties count as 0.5 wins) + 5. Break ties by head-to-head comparison + """ + + def __init__(self): + 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]: + """ + Main entry point to update all ride rankings. + + Args: + category: Optional ride category to filter ('RC' for roller coasters, etc.) + If None, ranks all rides. + + Returns: + Dictionary with statistics about the ranking calculation + """ + start_time = timezone.now() + self.logger.info( + f"Starting ranking calculation for category: {category or 'ALL'}" + ) + + try: + with transaction.atomic(): + # Get rides to rank + rides = self._get_eligible_rides(category) + if not rides: + self.logger.warning("No eligible rides found for ranking") + return { + "status": "skipped", + "message": "No eligible rides found", + "duration": (timezone.now() - start_time).total_seconds(), + } + + self.logger.info(f"Found {len(rides)} rides to rank") + + # Calculate pairwise comparisons + comparisons = self._calculate_all_comparisons(rides) + + # Calculate rankings from comparisons + rankings = self._calculate_rankings_from_comparisons(rides, comparisons) + + # Save rankings + self._save_rankings(rankings) + + # Save snapshots for historical tracking + self._save_ranking_snapshots(rankings) + + # Clean up old data + self._cleanup_old_data() + + duration = (timezone.now() - start_time).total_seconds() + self.logger.info( + f"Ranking calculation completed in {duration:.2f} seconds" + ) + + return { + "status": "success", + "rides_ranked": len(rides), + "comparisons_made": len(comparisons), + "duration": duration, + "timestamp": timezone.now(), + } + + except Exception as e: + self.logger.error(f"Error updating rankings: {e}", exc_info=True) + raise + + def _get_eligible_rides(self, category: Optional[str] = None) -> List[Ride]: + """ + Get rides that are eligible for ranking. + + Only includes rides that: + - Are currently operating + - Have at least one review/rating + """ + queryset = ( + Ride.objects.filter(status="OPERATING", reviews__is_published=True) + .annotate( + review_count=Count("reviews", filter=Q(reviews__is_published=True)) + ) + .filter(review_count__gt=0) + ) + + if category: + queryset = queryset.filter(category=category) + + return list(queryset.distinct()) + + def _calculate_all_comparisons( + self, rides: List[Ride] + ) -> Dict[Tuple[int, int], RidePairComparison]: + """ + Calculate pairwise comparisons for all ride pairs. + + Returns a dictionary keyed by (ride_a_id, ride_b_id) tuples. + """ + comparisons = {} + total_pairs = len(rides) * (len(rides) - 1) // 2 + processed = 0 + + for i, ride_a in enumerate(rides): + for ride_b in rides[i + 1 :]: + comparison = self._calculate_pairwise_comparison(ride_a, ride_b) + if comparison: + # Store both directions for easy lookup + comparisons[(ride_a.id, ride_b.id)] = comparison + comparisons[(ride_b.id, ride_a.id)] = comparison + + processed += 1 + if processed % 100 == 0: + self.logger.debug( + f"Processed {processed}/{total_pairs} comparisons" + ) + + return comparisons + + def _calculate_pairwise_comparison( + self, ride_a: Ride, ride_b: Ride + ) -> Optional[RidePairComparison]: + """ + Calculate the pairwise comparison between two rides. + + Finds users who have rated both rides and determines which ride + they preferred based on their ratings. + """ + # Get mutual riders (users who have rated both rides) + ride_a_reviewers = set( + RideReview.objects.filter(ride=ride_a, is_published=True).values_list( + "user_id", flat=True + ) + ) + + ride_b_reviewers = set( + RideReview.objects.filter(ride=ride_b, is_published=True).values_list( + "user_id", flat=True + ) + ) + + mutual_riders = ride_a_reviewers & ride_b_reviewers + + if not mutual_riders: + # No mutual riders, no comparison possible + return None + + # Get ratings from mutual riders + ride_a_ratings = { + review.user_id: review.rating + for review in RideReview.objects.filter( + ride=ride_a, user_id__in=mutual_riders, is_published=True + ) + } + + ride_b_ratings = { + review.user_id: review.rating + for review in RideReview.objects.filter( + ride=ride_b, user_id__in=mutual_riders, is_published=True + ) + } + + # Count wins and ties + ride_a_wins = 0 + ride_b_wins = 0 + ties = 0 + + for user_id in mutual_riders: + rating_a = ride_a_ratings.get(user_id, 0) + rating_b = ride_b_ratings.get(user_id, 0) + + if rating_a > rating_b: + ride_a_wins += 1 + elif rating_b > rating_a: + ride_b_wins += 1 + else: + ties += 1 + + # Calculate average ratings from mutual riders + ride_a_avg = ( + sum(ride_a_ratings.values()) / len(ride_a_ratings) if ride_a_ratings else 0 + ) + ride_b_avg = ( + sum(ride_b_ratings.values()) / len(ride_b_ratings) if ride_b_ratings else 0 + ) + + # Create or update comparison record + comparison, created = RidePairComparison.objects.update_or_create( + ride_a=ride_a if ride_a.id < ride_b.id else ride_b, + ride_b=ride_b if ride_a.id < ride_b.id else ride_a, + defaults={ + "ride_a_wins": ride_a_wins if ride_a.id < ride_b.id else ride_b_wins, + "ride_b_wins": ride_b_wins if ride_a.id < ride_b.id else ride_a_wins, + "ties": ties, + "mutual_riders_count": len(mutual_riders), + "ride_a_avg_rating": ( + Decimal(str(ride_a_avg)) + if ride_a.id < ride_b.id + else Decimal(str(ride_b_avg)) + ), + "ride_b_avg_rating": ( + Decimal(str(ride_b_avg)) + if ride_a.id < ride_b.id + else Decimal(str(ride_a_avg)) + ), + }, + ) + + return comparison + + def _calculate_rankings_from_comparisons( + self, rides: List[Ride], comparisons: Dict[Tuple[int, int], RidePairComparison] + ) -> List[Dict]: + """ + Calculate final rankings from pairwise comparisons. + + Returns a list of dictionaries containing ranking data for each ride. + """ + rankings = [] + + for ride in rides: + wins = 0 + losses = 0 + ties = 0 + comparison_count = 0 + + # Count wins, losses, and ties + for other_ride in rides: + if ride.id == other_ride.id: + continue + + comparison_key = ( + min(ride.id, other_ride.id), + max(ride.id, other_ride.id), + ) + comparison = comparisons.get(comparison_key) + + if not comparison: + continue + + comparison_count += 1 + + # Determine win/loss/tie for this ride + if comparison.ride_a_id == ride.id: + if comparison.ride_a_wins > comparison.ride_b_wins: + wins += 1 + elif comparison.ride_a_wins < comparison.ride_b_wins: + losses += 1 + else: + ties += 1 + else: # ride_b_id == ride.id + if comparison.ride_b_wins > comparison.ride_a_wins: + wins += 1 + elif comparison.ride_b_wins < comparison.ride_a_wins: + losses += 1 + else: + ties += 1 + + # Calculate winning percentage (ties count as 0.5) + total_comparisons = wins + losses + ties + if total_comparisons > 0: + winning_percentage = Decimal( + str((wins + 0.5 * ties) / total_comparisons) + ) + else: + winning_percentage = Decimal("0.5") + + # Get average rating and reviewer count + ride_stats = RideReview.objects.filter( + ride=ride, is_published=True + ).aggregate( + avg_rating=Avg("rating"), reviewer_count=Count("user", distinct=True) + ) + + rankings.append( + { + "ride": ride, + "wins": wins, + "losses": losses, + "ties": ties, + "winning_percentage": winning_percentage, + "comparison_count": comparison_count, + "average_rating": ride_stats["avg_rating"], + "mutual_riders_count": ride_stats["reviewer_count"] or 0, + } + ) + + # Sort by winning percentage (descending), then by mutual riders count for ties + rankings.sort( + key=lambda x: ( + x["winning_percentage"], + x["mutual_riders_count"], + x["average_rating"] or 0, + ), + reverse=True, + ) + + # Handle tie-breaking with head-to-head comparisons + rankings = self._apply_tiebreakers(rankings, comparisons) + + # Assign final ranks + for i, ranking_data in enumerate(rankings, 1): + ranking_data["rank"] = i + + return rankings + + def _apply_tiebreakers( + self, + rankings: List[Dict], + comparisons: Dict[Tuple[int, int], RidePairComparison], + ) -> List[Dict]: + """ + Apply head-to-head tiebreaker for rides with identical winning percentages. + + If two rides have the same winning percentage, the one that beat the other + in their head-to-head comparison gets the higher rank. + """ + i = 0 + while i < len(rankings) - 1: + # Find rides with same winning percentage + tied_group = [rankings[i]] + j = i + 1 + + while ( + j < len(rankings) + and rankings[j]["winning_percentage"] + == rankings[i]["winning_percentage"] + ): + tied_group.append(rankings[j]) + j += 1 + + if len(tied_group) > 1: + # Apply head-to-head tiebreaker within the group + tied_group = self._sort_tied_group(tied_group, comparisons) + + # Replace the tied section with sorted group + rankings[i:j] = tied_group + + i = j + + return rankings + + def _sort_tied_group( + self, + tied_group: List[Dict], + comparisons: Dict[Tuple[int, int], RidePairComparison], + ) -> List[Dict]: + """ + Sort a group of tied rides using head-to-head comparisons. + """ + # Create mini-rankings within the tied group + for ride_data in tied_group: + mini_wins = 0 + mini_losses = 0 + + for other_data in tied_group: + if ride_data["ride"].id == other_data["ride"].id: + continue + + comparison_key = ( + min(ride_data["ride"].id, other_data["ride"].id), + max(ride_data["ride"].id, other_data["ride"].id), + ) + comparison = comparisons.get(comparison_key) + + if comparison: + if comparison.ride_a_id == ride_data["ride"].id: + if comparison.ride_a_wins > comparison.ride_b_wins: + mini_wins += 1 + elif comparison.ride_a_wins < comparison.ride_b_wins: + mini_losses += 1 + else: + if comparison.ride_b_wins > comparison.ride_a_wins: + mini_wins += 1 + elif comparison.ride_b_wins < comparison.ride_a_wins: + mini_losses += 1 + + ride_data["tiebreaker_score"] = mini_wins - mini_losses + + # Sort by tiebreaker score, then by mutual riders count, then by average rating + tied_group.sort( + key=lambda x: ( + x["tiebreaker_score"], + x["mutual_riders_count"], + x["average_rating"] or 0, + ), + reverse=True, + ) + + return tied_group + + def _save_rankings(self, rankings: List[Dict]): + """Save calculated rankings to the database.""" + for ranking_data in rankings: + RideRanking.objects.update_or_create( + ride=ranking_data["ride"], + defaults={ + "rank": ranking_data["rank"], + "wins": ranking_data["wins"], + "losses": ranking_data["losses"], + "ties": ranking_data["ties"], + "winning_percentage": ranking_data["winning_percentage"], + "mutual_riders_count": ranking_data["mutual_riders_count"], + "comparison_count": ranking_data["comparison_count"], + "average_rating": ranking_data["average_rating"], + "last_calculated": timezone.now(), + "calculation_version": self.calculation_version, + }, + ) + + def _save_ranking_snapshots(self, rankings: List[Dict]): + """Save ranking snapshots for historical tracking.""" + today = date.today() + + for ranking_data in rankings: + RankingSnapshot.objects.update_or_create( + ride=ranking_data["ride"], + snapshot_date=today, + defaults={ + "rank": ranking_data["rank"], + "winning_percentage": ranking_data["winning_percentage"], + }, + ) + + def _cleanup_old_data(self, days_to_keep: int = 365): + """Clean up old comparison and snapshot data.""" + cutoff_date = timezone.now() - timezone.timedelta(days=days_to_keep) + + # Delete old snapshots + deleted_snapshots = RankingSnapshot.objects.filter( + snapshot_date__lt=cutoff_date.date() + ).delete() + + 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]: + """ + Get detailed ranking information for a specific ride. + + Returns dictionary with ranking details or None if not ranked. + """ + try: + ranking = RideRanking.objects.get(ride=ride) + + # Get recent head-to-head comparisons + comparisons = ( + RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride)) + .select_related("ride_a", "ride_b") + .order_by("-mutual_riders_count")[:10] + ) + + # Get ranking history + history = RankingSnapshot.objects.filter(ride=ride).order_by( + "-snapshot_date" + )[:30] + + return { + "current_rank": ranking.rank, + "winning_percentage": ranking.winning_percentage, + "wins": ranking.wins, + "losses": ranking.losses, + "ties": ranking.ties, + "average_rating": ranking.average_rating, + "mutual_riders_count": ranking.mutual_riders_count, + "last_calculated": ranking.last_calculated, + "head_to_head": [ + { + "opponent": ( + comp.ride_b if comp.ride_a_id == ride.id else comp.ride_a + ), + "result": ( + "win" + if ( + ( + comp.ride_a_id == ride.id + and comp.ride_a_wins > comp.ride_b_wins + ) + or ( + comp.ride_b_id == ride.id + and comp.ride_b_wins > comp.ride_a_wins + ) + ) + else ( + "loss" + if ( + ( + comp.ride_a_id == ride.id + and comp.ride_a_wins < comp.ride_b_wins + ) + or ( + comp.ride_b_id == ride.id + and comp.ride_b_wins < comp.ride_a_wins + ) + ) + else "tie" + ) + ), + "mutual_riders": comp.mutual_riders_count, + } + for comp in comparisons + ], + "ranking_history": [ + { + "date": snapshot.snapshot_date, + "rank": snapshot.rank, + "winning_percentage": snapshot.winning_percentage, + } + for snapshot in history + ], + } + except RideRanking.DoesNotExist: + return None diff --git a/backend/apps/rides/urls.py b/backend/apps/rides/urls.py index 59d37801..2e14068f 100644 --- a/backend/apps/rides/urls.py +++ b/backend/apps/rides/urls.py @@ -53,6 +53,23 @@ urlpatterns = [ views.get_search_suggestions, name="search_suggestions", ), + # Ranking endpoints + path("rankings/", views.RideRankingsView.as_view(), name="rankings"), + path( + "rankings//", + views.RideRankingDetailView.as_view(), + name="ranking_detail", + ), + path( + "rankings//history-chart/", + views.ranking_history_chart, + name="ranking_history_chart", + ), + path( + "rankings//comparisons/", + views.ranking_comparisons, + name="ranking_comparisons", + ), # Park-specific URLs path("create/", views.RideCreateView.as_view(), name="ride_create"), path("/", views.RideDetailView.as_view(), name="ride_detail"), diff --git a/backend/apps/rides/views.py b/backend/apps/rides/views.py index 1f542cce..8d0d0f72 100644 --- a/backend/apps/rides/views.py +++ b/backend/apps/rides/views.py @@ -12,6 +12,8 @@ from .forms import RideForm, RideSearchForm from apps.parks.models import Park from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin from apps.moderation.models import EditSubmission +from .models.rankings import RideRanking, RankingSnapshot +from .services.ranking_service import RideRankingService class ParkContextRequired: @@ -452,3 +454,166 @@ class RideSearchView(ListView): context = super().get_context_data(**kwargs) context["search_form"] = RideSearchForm(self.request.GET) return context + + +class RideRankingsView(ListView): + """View for displaying ride rankings using the Internet Roller Coaster Poll algorithm.""" + + model = RideRanking + template_name = "rides/rankings.html" + context_object_name = "rankings" + paginate_by = 50 + + def get_queryset(self): + """Get rankings with optimized queries.""" + queryset = RideRanking.objects.select_related( + "ride", "ride__park", "ride__manufacturer", "ride__ride_model" + ).order_by("rank") + + # Filter by category if specified + category = self.request.GET.get("category") + if category and category != "all": + queryset = queryset.filter(ride__category=category) + + # Filter by minimum mutual riders + min_riders = self.request.GET.get("min_riders") + if min_riders: + try: + min_riders = int(min_riders) + queryset = queryset.filter(mutual_riders_count__gte=min_riders) + except ValueError: + pass + + return queryset + + def get_template_names(self): + """Return appropriate template based on request type.""" + if self.request.htmx: + return ["rides/partials/rankings_table.html"] + return [self.template_name] + + def get_context_data(self, **kwargs): + """Add context for rankings view.""" + context = super().get_context_data(**kwargs) + context["category_choices"] = Categories + context["selected_category"] = self.request.GET.get("category", "all") + context["min_riders"] = self.request.GET.get("min_riders", "") + + # Add statistics + if self.object_list: + context["total_ranked"] = RideRanking.objects.count() + context["last_updated"] = ( + self.object_list[0].last_calculated if self.object_list else None + ) + + return context + + +class RideRankingDetailView(DetailView): + """View for displaying detailed ranking information for a specific ride.""" + + model = Ride + template_name = "rides/ranking_detail.html" + slug_url_kwarg = "ride_slug" + + def get_queryset(self): + """Get ride with ranking data.""" + return Ride.objects.select_related( + "park", "manufacturer", "ranking" + ).prefetch_related("comparisons_as_a", "comparisons_as_b", "ranking_history") + + def get_context_data(self, **kwargs): + """Add ranking details to context.""" + context = super().get_context_data(**kwargs) + + # Get ranking details from service + service = RideRankingService() + ranking_details = service.get_ride_ranking_details(self.object) + + if ranking_details: + context.update(ranking_details) + + # Get recent movement + recent_snapshots = RankingSnapshot.objects.filter( + ride=self.object + ).order_by("-snapshot_date")[:7] + + if len(recent_snapshots) >= 2: + context["rank_change"] = ( + recent_snapshots[0].rank - recent_snapshots[1].rank + ) + context["previous_rank"] = recent_snapshots[1].rank + else: + context["not_ranked"] = True + + return context + + +def ranking_history_chart(request: HttpRequest, ride_slug: str) -> HttpResponse: + """HTMX endpoint for ranking history chart data.""" + ride = get_object_or_404(Ride, slug=ride_slug) + + # Get last 30 days of ranking history + history = RankingSnapshot.objects.filter(ride=ride).order_by("-snapshot_date")[:30] + + # Prepare data for chart + chart_data = [ + { + "date": snapshot.snapshot_date.isoformat(), + "rank": snapshot.rank, + "win_pct": float(snapshot.winning_percentage) * 100, + } + for snapshot in reversed(history) + ] + + return render( + request, + "rides/partials/ranking_chart.html", + {"chart_data": chart_data, "ride": ride}, + ) + + +def ranking_comparisons(request: HttpRequest, ride_slug: str) -> HttpResponse: + """HTMX endpoint for ride head-to-head comparisons.""" + ride = get_object_or_404(Ride, slug=ride_slug) + + # Get head-to-head comparisons + from django.db.models import Q + from .models.rankings import RidePairComparison + + comparisons = ( + RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride)) + .select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park") + .order_by("-mutual_riders_count")[:20] + ) + + # Format comparisons for display + comparison_data = [] + for comp in comparisons: + if comp.ride_a == ride: + opponent = comp.ride_b + wins = comp.ride_a_wins + losses = comp.ride_b_wins + else: + opponent = comp.ride_a + wins = comp.ride_b_wins + losses = comp.ride_a_wins + + result = "win" if wins > losses else "loss" if losses > wins else "tie" + + comparison_data.append( + { + "opponent": opponent, + "wins": wins, + "losses": losses, + "ties": comp.ties, + "result": result, + "mutual_riders": comp.mutual_riders_count, + } + ) + + return render( + request, + "rides/partials/ranking_comparisons.html", + {"comparisons": comparison_data, "ride": ride}, + ) diff --git a/backend/config/django/base.py b/backend/config/django/base.py index 6b1345aa..0cbe507d 100644 --- a/backend/config/django/base.py +++ b/backend/config/django/base.py @@ -119,7 +119,6 @@ MIDDLEWARE = [ "allauth.account.middleware.AccountMiddleware", "django.middleware.cache.FetchFromCacheMiddleware", "django_htmx.middleware.HtmxMiddleware", - "core.middleware.PageViewMiddleware", # Add our page view tracking ] ROOT_URLCONF = "thrillwiki.urls" @@ -299,9 +298,14 @@ SPECTACULAR_SETTINGS = { "TAGS": [ {"name": "Parks", "description": "Theme park operations"}, {"name": "Rides", "description": "Ride information and management"}, - {"name": "Statistics", - "description": "Statistical endpoints providing aggregated data and insights"}, - {"name": "Reviews", "description": "User reviews and ratings for parks and rides"}, + { + "name": "Statistics", + "description": "Statistical endpoints providing aggregated data and insights", + }, + { + "name": "Reviews", + "description": "User reviews and ratings for parks and rides", + }, {"name": "locations", "description": "Geographic location services"}, {"name": "accounts", "description": "User account management"}, {"name": "media", "description": "Media and image management"}, diff --git a/backend/config/django/local.py b/backend/config/django/local.py index 77a2e086..66b220c5 100644 --- a/backend/config/django/local.py +++ b/backend/config/django/local.py @@ -5,6 +5,7 @@ Local development settings for thrillwiki project. import logging from .base import * from ..settings import database +from pythonjsonlogger import jsonlogger # Import the module and use its members, e.g., email.EMAIL_HOST diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6ed2fe5a..929a1d98 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ "django-extensions>=4.1", "werkzeug>=3.1.3", "django-widget-tweaks>=1.5.0", + "redis>=6.4.0", ] [dependency-groups] @@ -70,3 +71,6 @@ typeCheckingMode = "basic" [tool.pylance] stubPath = "stubs" + +[tool.uv.sources] +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" } diff --git a/backend/setup_social_providers.py b/backend/setup_social_providers.py index dc9d0173..5c0d17fd 100644 --- a/backend/setup_social_providers.py +++ b/backend/setup_social_providers.py @@ -17,22 +17,22 @@ print("Cleared existing social apps") # Create Google social app google_app = SocialApp.objects.create( - provider='google', - name='Google', - client_id='demo-google-client-id.apps.googleusercontent.com', - secret='demo-google-client-secret', - key='', # Not used for Google + provider="google", + name="Google", + client_id="demo-google-client-id.apps.googleusercontent.com", + secret="demo-google-client-secret", + key="", # Not used for Google ) google_app.sites.add(site) print("✅ Created Google social app") # Create Discord social app discord_app = SocialApp.objects.create( - provider='discord', - name='Discord', - client_id='demo-discord-client-id', - secret='demo-discord-client-secret', - key='', # Not used for Discord + provider="discord", + name="Discord", + client_id="demo-discord-client-id", + secret="demo-discord-client-secret", + key="", # Not used for Discord ) discord_app.sites.add(site) print("✅ Created Discord social app") diff --git a/backend/thrillwiki/settings.py b/backend/thrillwiki/settings_OLD_USE_CONFIG_DIR.py similarity index 95% rename from backend/thrillwiki/settings.py rename to backend/thrillwiki/settings_OLD_USE_CONFIG_DIR.py index 93e1a72d..87330606 100644 --- a/backend/thrillwiki/settings.py +++ b/backend/thrillwiki/settings_OLD_USE_CONFIG_DIR.py @@ -77,7 +77,7 @@ MIDDLEWARE = [ "allauth.account.middleware.AccountMiddleware", "django.middleware.cache.FetchFromCacheMiddleware", "django_htmx.middleware.HtmxMiddleware", - "apps.core.middleware.PageViewMiddleware", # Add our page view tracking + "apps.core.middleware.view_tracking.ViewTrackingMiddleware", # Add our page view tracking ] ROOT_URLCONF = "thrillwiki.urls" @@ -119,13 +119,18 @@ DATABASES = { # Cache settings CACHES = { "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - "LOCATION": "unique-snowflake", + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, "TIMEOUT": 300, # 5 minutes - "OPTIONS": {"MAX_ENTRIES": 1000}, } } +# Redis settings for trending cache +REDIS_URL = "redis://127.0.0.1:6379/0" + CACHE_MIDDLEWARE_SECONDS = 1 # 5 minutes CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki" diff --git a/backend/uv.lock b/backend/uv.lock index 64993818..8aa7423e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1548,13 +1548,22 @@ wheels = [ [[package]] name = "python-json-logger" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/de/d3144a0bceede957f961e975f3752760fbe390d57fbe194baf709d8f1f7b/python_json_logger-3.3.0.tar.gz", hash = "sha256:12b7e74b17775e7d565129296105bbe3910842d9d0eb083fc83a6a617aa8df84", size = 16642, upload-time = "2025-03-07T07:08:27.301Z" } +version = "3.0.0" +source = { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163, upload-time = "2025-03-07T07:08:25.627Z" }, + { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl", hash = "sha256:45c59c69d4a4e398b37e77c6b6f0f1663c829516a5063ff4c2bc0ba314b1f6f7" }, ] +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'lint'" }, + { name = "mypy", marker = "extra == 'lint'" }, + { name = "pylint", marker = "extra == 'lint'" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "validate-pyproject", extras = ["all"], marker = "extra == 'lint'" }, +] +provides-extras = ["lint", "test"] + [[package]] name = "python-slugify" version = "8.0.4" @@ -1914,6 +1923,7 @@ dependencies = [ { name = "pytest-playwright" }, { name = "python-dotenv" }, { name = "python-json-logger" }, + { name = "redis" }, { name = "requests" }, { name = "sentry-sdk" }, { name = "werkzeug" }, @@ -1975,7 +1985,8 @@ requires-dist = [ { name = "pytest-django", specifier = ">=4.9.0" }, { name = "pytest-playwright", specifier = ">=0.4.3" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "python-json-logger", specifier = ">=2.0.7" }, + { 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 = "redis", specifier = ">=6.4.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "sentry-sdk", specifier = ">=1.40.0" }, { name = "werkzeug", specifier = ">=3.1.3" }, @@ -2067,11 +2078,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] diff --git a/context_portal/conport_vector_data/422ad254-2341-4dce-9133-98aaf02fdb1b/length.bin b/context_portal/conport_vector_data/422ad254-2341-4dce-9133-98aaf02fdb1b/length.bin index 500df026..5d229f1f 100644 Binary files a/context_portal/conport_vector_data/422ad254-2341-4dce-9133-98aaf02fdb1b/length.bin and b/context_portal/conport_vector_data/422ad254-2341-4dce-9133-98aaf02fdb1b/length.bin differ diff --git a/context_portal/conport_vector_data/chroma.sqlite3 b/context_portal/conport_vector_data/chroma.sqlite3 index e0a9ad99..99a80112 100644 Binary files a/context_portal/conport_vector_data/chroma.sqlite3 and b/context_portal/conport_vector_data/chroma.sqlite3 differ diff --git a/context_portal/context.db b/context_portal/context.db index 71692f00..8492a4cc 100644 Binary files a/context_portal/context.db and b/context_portal/context.db differ diff --git a/docs/ride-ranking-implementation.md b/docs/ride-ranking-implementation.md new file mode 100644 index 00000000..2962e6ef --- /dev/null +++ b/docs/ride-ranking-implementation.md @@ -0,0 +1,716 @@ +# Ride Ranking System - Complete Implementation Documentation + +## Table of Contents +1. [Overview](#overview) +2. [Backend Implementation](#backend-implementation) +3. [Frontend Implementation](#frontend-implementation) +4. [API Reference](#api-reference) +5. [Usage Examples](#usage-examples) +6. [Deployment & Maintenance](#deployment--maintenance) + +## Overview + +The ThrillWiki Ride Ranking System implements the Internet Roller Coaster Poll (IRCP) algorithm to provide fair, data-driven rankings of theme park rides based on user ratings. This document covers the complete implementation across both backend (Django) and frontend (Vue.js/TypeScript) components. + +### Key Features +- **Pairwise Comparison Algorithm**: Compares every ride against every other ride based on mutual riders +- **Web Interface**: Browse rankings with filtering and detailed views +- **REST API**: Comprehensive API for programmatic access +- **Historical Tracking**: Track ranking changes over time +- **Statistical Analysis**: Head-to-head comparisons and win/loss records + +## Backend Implementation + +### Database Models + +#### Location: `apps/rides/models/rankings.py` + +```python +# Core ranking models +- RideRanking: Current ranking data for each ride +- RidePairComparison: Cached pairwise comparison results +- RankingSnapshot: Historical ranking data +``` + +#### Key Fields + +**RideRanking Model**: +- `rank` (Integer): Overall ranking position +- `wins` (Integer): Number of head-to-head wins +- `losses` (Integer): Number of head-to-head losses +- `ties` (Integer): Number of tied comparisons +- `winning_percentage` (Decimal): Win percentage (ties count as 0.5) +- `mutual_riders_count` (Integer): Total users who rated this ride +- `average_rating` (Decimal): Average user rating +- `last_calculated` (DateTime): Timestamp of last calculation + +### Service Layer + +#### Location: `apps/rides/services/ranking_service.py` + +The `RideRankingService` class implements the core ranking algorithm: + +```python +class RideRankingService: + def update_all_rankings(category=None): + """Main entry point for ranking calculation""" + + def _calculate_pairwise_comparison(ride_a, ride_b): + """Compare two rides based on mutual riders""" + + def _calculate_rankings_from_comparisons(): + """Convert comparisons to rankings""" + + def _apply_tiebreakers(): + """Resolve ties using head-to-head comparisons""" +``` + +### Django Views + +#### Location: `apps/rides/views.py` + +**Web Views**: +```python +class RideRankingsView(ListView): + """Main rankings list page with filtering""" + template_name = 'rides/rankings.html' + paginate_by = 50 + +class RideRankingDetailView(DetailView): + """Detailed ranking view for a specific ride""" + template_name = 'rides/ranking_detail.html' +``` + +**HTMX Endpoints**: +- `ranking_history_chart`: Returns chart data for ranking history +- `ranking_comparisons`: Returns head-to-head comparison data + +### URL Configuration + +#### Location: `apps/rides/urls.py` + +```python +urlpatterns = [ + path('rankings/', RideRankingsView.as_view(), name='ride-rankings'), + path('rankings//', RideRankingDetailView.as_view(), name='ride-ranking-detail'), + path('rankings//history-chart/', ranking_history_chart, name='ranking-history-chart'), + path('rankings//comparisons/', ranking_comparisons, name='ranking-comparisons'), +] +``` + +### API Implementation + +#### Serializers +**Location**: `apps/api/v1/serializers_rankings.py` + +```python +class RideRankingSerializer(serializers.ModelSerializer): + """Basic ranking data serialization""" + +class RideRankingDetailSerializer(serializers.ModelSerializer): + """Detailed ranking with relationships""" + +class RankingSnapshotSerializer(serializers.ModelSerializer): + """Historical ranking data""" + +class RankingStatsSerializer(serializers.Serializer): + """System-wide statistics""" +``` + +#### ViewSets +**Location**: `apps/api/v1/viewsets_rankings.py` + +```python +class RideRankingViewSet(viewsets.ReadOnlyModelViewSet): + """ + REST API endpoint for ride rankings + Supports filtering, ordering, and custom actions + """ + + @action(detail=True, methods=['get']) + def history(self, request, pk=None): + """Get historical ranking data""" + + @action(detail=True, methods=['get']) + def comparisons(self, request, pk=None): + """Get head-to-head comparisons""" + + @action(detail=False, methods=['get']) + def statistics(self, request): + """Get system-wide statistics""" + +class TriggerRankingCalculationView(APIView): + """Admin endpoint to trigger manual calculation""" +``` + +#### API URLs +**Location**: `apps/api/v1/urls.py` + +```python +router.register(r'rankings', RideRankingViewSet, basename='ranking') + +urlpatterns = [ + path('', include(router.urls)), + path('rankings/calculate/', TriggerRankingCalculationView.as_view()), +] +``` + +### Management Commands + +#### Location: `apps/rides/management/commands/update_ride_rankings.py` + +```bash +# Update all rankings +python manage.py update_ride_rankings + +# Update specific category +python manage.py update_ride_rankings --category RC +``` + +### Admin Interface + +#### Location: `apps/rides/admin.py` + +```python +@admin.register(RideRanking) +class RideRankingAdmin(admin.ModelAdmin): + list_display = ['rank', 'ride', 'winning_percentage', 'wins', 'losses'] + list_filter = ['ride__category', 'last_calculated'] + search_fields = ['ride__name'] + ordering = ['rank'] +``` + +## Frontend Implementation + +### TypeScript Type Definitions + +#### Location: `frontend/src/types/index.ts` + +```typescript +// Core ranking types +export interface RideRanking { + id: number + rank: number + ride: { + id: number + name: string + slug: string + park: { + id: number + name: string + slug: string + } + category: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT' + } + wins: number + losses: number + ties: number + winning_percentage: number + mutual_riders_count: number + comparison_count: number + average_rating: number + last_calculated: string + rank_change?: number + previous_rank?: number | null +} + +export interface RideRankingDetail extends RideRanking { + ride: { + // Extended ride information + description?: string + manufacturer?: { id: number; name: string } + opening_date?: string + status: string + } + calculation_version?: string + head_to_head_comparisons?: HeadToHeadComparison[] + ranking_history?: RankingSnapshot[] +} + +export interface HeadToHeadComparison { + opponent: { + id: number + name: string + slug: string + park: string + } + wins: number + losses: number + ties: number + result: 'win' | 'loss' | 'tie' + mutual_riders: number +} + +export interface RankingSnapshot { + date: string + rank: number + winning_percentage: number +} + +export interface RankingStatistics { + total_ranked_rides: number + total_comparisons: number + last_calculation_time: string + calculation_duration: number + top_rated_ride?: RideInfo + most_compared_ride?: RideInfo + biggest_rank_change?: RankChangeInfo +} +``` + +### API Service Class + +#### Location: `frontend/src/services/api.ts` + +```typescript +export class RankingsApi { + // Core API methods + async getRankings(params?: RankingParams): Promise> + async getRankingDetail(rideSlug: string): Promise + async getRankingHistory(rideSlug: string): Promise + async getHeadToHeadComparisons(rideSlug: string): Promise + async getRankingStatistics(): Promise + async calculateRankings(category?: string): Promise + + // Convenience methods + async getTopRankings(limit: number, category?: string): Promise + async getParkRankings(parkSlug: string, params?: Params): Promise> + async searchRankings(query: string): Promise + async getRankChange(rideSlug: string): Promise +} +``` + +### Integration with Main API + +```typescript +// Singleton instance with all API services +export const api = new ThrillWikiApi() + +// Direct access to rankings API +export const rankingsApi = api.rankings +``` + +## API Reference + +### REST Endpoints + +#### Get Rankings List +```http +GET /api/v1/rankings/ +``` + +**Query Parameters**: +- `page` (integer): Page number +- `page_size` (integer): Results per page (default: 20) +- `category` (string): Filter by category (RC, DR, FR, WR, TR, OT) +- `min_riders` (integer): Minimum mutual riders +- `park` (string): Filter by park slug +- `ordering` (string): Sort order (rank, -rank, winning_percentage, -winning_percentage) + +**Response**: +```json +{ + "count": 523, + "next": "http://api.example.com/api/v1/rankings/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "rank": 1, + "ride": { + "id": 123, + "name": "Steel Vengeance", + "slug": "steel-vengeance", + "park": { + "id": 45, + "name": "Cedar Point", + "slug": "cedar-point" + }, + "category": "RC" + }, + "wins": 487, + "losses": 23, + "ties": 13, + "winning_percentage": 0.9405, + "mutual_riders_count": 1543, + "comparison_count": 523, + "average_rating": 9.4, + "last_calculated": "2024-01-15T02:00:00Z" + } + ] +} +``` + +#### Get Ranking Details +```http +GET /api/v1/rankings/{ride-slug}/ +``` + +**Response**: Extended ranking data with full ride details, comparisons, and history + +#### Get Ranking History +```http +GET /api/v1/rankings/{ride-slug}/history/ +``` + +**Response**: Array of ranking snapshots (last 90 days) + +#### Get Head-to-Head Comparisons +```http +GET /api/v1/rankings/{ride-slug}/comparisons/ +``` + +**Response**: Array of comparison results with all other rides + +#### Get Statistics +```http +GET /api/v1/rankings/statistics/ +``` + +**Response**: System-wide ranking statistics + +#### Trigger Calculation (Admin) +```http +POST /api/v1/rankings/calculate/ +``` + +**Request Body**: +```json +{ + "category": "RC" // Optional +} +``` + +**Response**: +```json +{ + "status": "success", + "rides_ranked": 523, + "comparisons_made": 136503, + "duration": 45.23, + "timestamp": "2024-01-15T02:00:45Z" +} +``` + +## Usage Examples + +### Frontend (Vue.js/TypeScript) + +#### Display Top Rankings +```typescript +import { rankingsApi } from '@/services/api' + +export default { + async mounted() { + try { + // Get top 10 rankings + const topRides = await rankingsApi.getTopRankings(10) + this.rankings = topRides + + // Get roller coasters only + const response = await rankingsApi.getRankings({ + category: 'RC', + page_size: 20, + ordering: 'rank' + }) + this.rollerCoasters = response.results + } catch (error) { + console.error('Failed to load rankings:', error) + } + } +} +``` + +#### Display Ranking Details +```typescript +// In a Vue component +async loadRankingDetails(rideSlug: string) { + const [details, history, comparisons] = await Promise.all([ + rankingsApi.getRankingDetail(rideSlug), + rankingsApi.getRankingHistory(rideSlug), + rankingsApi.getHeadToHeadComparisons(rideSlug) + ]) + + this.rankingDetails = details + this.chartData = this.prepareChartData(history) + this.comparisons = comparisons +} +``` + +#### Search Rankings +```typescript +async searchRides(query: string) { + const results = await rankingsApi.searchRankings(query) + this.searchResults = results +} +``` + +### Backend (Python/Django) + +#### Access Rankings in Views +```python +from apps.rides.models import RideRanking + +# Get top 10 rides +top_rides = RideRanking.objects.select_related('ride', 'ride__park').order_by('rank')[:10] + +# Get rankings for a specific category +coaster_rankings = RideRanking.objects.filter( + ride__category='RC' +).order_by('rank') + +# Get ranking with change indicator +ranking = RideRanking.objects.get(ride__slug='millennium-force') +if ranking.previous_rank: + change = ranking.previous_rank - ranking.rank + direction = 'up' if change > 0 else 'down' if change < 0 else 'same' +``` + +#### Trigger Ranking Update +```python +from apps.rides.services.ranking_service import RideRankingService + +# Update all rankings +service = RideRankingService() +result = service.update_all_rankings() + +# Update specific category +result = service.update_all_rankings(category='RC') + +print(f"Ranked {result['rides_ranked']} rides") +print(f"Made {result['comparisons_made']} comparisons") +print(f"Duration: {result['duration']:.2f} seconds") +``` + +### Command Line + +```bash +# Update rankings via management command +uv run python manage.py update_ride_rankings + +# Update only roller coasters +uv run python manage.py update_ride_rankings --category RC + +# Schedule daily updates with cron +0 2 * * * cd /path/to/project && uv run python manage.py update_ride_rankings +``` + +## Deployment & Maintenance + +### Initial Setup + +1. **Run Migrations**: +```bash +uv run python manage.py migrate +``` + +2. **Initial Ranking Calculation**: +```bash +uv run python manage.py update_ride_rankings +``` + +3. **Verify in Admin**: +- Navigate to `/admin/rides/rideranking/` +- Verify rankings are populated + +### Scheduled Updates + +Add to crontab for daily updates: +```bash +# Update rankings daily at 2 AM +0 2 * * * cd /path/to/thrillwiki && uv run python manage.py update_ride_rankings + +# Optional: Update different categories at different times +0 2 * * * cd /path/to/thrillwiki && uv run python manage.py update_ride_rankings --category RC +0 3 * * * cd /path/to/thrillwiki && uv run python manage.py update_ride_rankings --category DR +``` + +### Monitoring + +1. **Check Logs**: +```bash +tail -f /path/to/logs/ranking_updates.log +``` + +2. **Monitor Performance**: +- Track calculation duration via API statistics endpoint +- Monitor database query performance +- Check comparison cache hit rates + +3. **Data Validation**: +```python +# Check for ranking anomalies +from apps.rides.models import RideRanking + +# Verify all ranks are unique +ranks = RideRanking.objects.values_list('rank', flat=True) +assert len(ranks) == len(set(ranks)) + +# Check winning percentage calculation +for ranking in RideRanking.objects.all(): + expected = (ranking.wins + 0.5 * ranking.ties) / ranking.comparison_count + assert abs(ranking.winning_percentage - expected) < 0.001 +``` + +### Performance Optimization + +1. **Database Indexes**: +```sql +-- Ensure these indexes exist +CREATE INDEX idx_ranking_rank ON rides_rideranking(rank); +CREATE INDEX idx_ranking_ride ON rides_rideranking(ride_id); +CREATE INDEX idx_comparison_rides ON rides_ridepaircomparison(ride_a_id, ride_b_id); +``` + +2. **Cache Configuration**: +```python +# settings.py +CACHES = { + 'rankings': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': 'redis://127.0.0.1:6379/2', + 'TIMEOUT': 3600, # 1 hour + } +} +``` + +3. **Batch Processing**: +- Process comparisons in batches of 1000 +- Use bulk_create for database inserts +- Consider parallel processing for large datasets + +### Troubleshooting + +**Common Issues**: + +1. **Rankings not updating**: + - Check cron job is running + - Verify database connectivity + - Check for lock files preventing concurrent runs + +2. **Incorrect rankings**: + - Clear comparison cache and recalculate + - Verify rating data integrity + - Check for duplicate user ratings + +3. **Performance issues**: + - Analyze slow queries with Django Debug Toolbar + - Consider increasing database resources + - Implement incremental updates for large datasets + +### API Rate Limiting + +Configure in `settings.py`: +```python +REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle' + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/hour', + 'user': '1000/hour' + } +} +``` + +## Architecture Decisions + +### Why Pairwise Comparison? +- **Fairness**: Only compares rides among users who've experienced both +- **Reduces Bias**: Popular rides aren't advantaged over less-ridden ones +- **Head-to-Head Logic**: Direct comparisons matter for tie-breaking +- **Robust to Outliers**: One extreme rating doesn't skew results + +### Caching Strategy +- **Comparison Cache**: Store pairwise results to avoid recalculation +- **Snapshot History**: Keep 365 days of historical data +- **API Response Cache**: Cache ranking lists for 1 hour + +### Scalability Considerations +- **O(n²) Complexity**: Scales quadratically with number of rides +- **Batch Processing**: Process in chunks to manage memory +- **Incremental Updates**: Future enhancement for real-time updates + +## Testing + +### Unit Tests +```python +# apps/rides/tests/test_ranking_service.py +class RankingServiceTestCase(TestCase): + def test_pairwise_comparison(self): + """Test comparison logic between two rides""" + + def test_ranking_calculation(self): + """Test overall ranking calculation""" + + def test_tiebreaker_logic(self): + """Test head-to-head tiebreaker""" +``` + +### Integration Tests +```python +# apps/api/tests/test_ranking_api.py +class RankingAPITestCase(APITestCase): + def test_get_rankings_list(self): + """Test ranking list endpoint""" + + def test_filtering_and_ordering(self): + """Test query parameters""" + + def test_calculation_trigger(self): + """Test admin calculation endpoint""" +``` + +### Frontend Tests +```typescript +// frontend/tests/api/rankings.test.ts +describe('Rankings API', () => { + it('should fetch top rankings', async () => { + const rankings = await rankingsApi.getTopRankings(10) + expect(rankings).toHaveLength(10) + expect(rankings[0].rank).toBe(1) + }) + + it('should filter by category', async () => { + const response = await rankingsApi.getRankings({ category: 'RC' }) + expect(response.results.every(r => r.ride.category === 'RC')).toBe(true) + }) +}) +``` + +## Future Enhancements + +### Planned Features +1. **Real-time Updates**: Update rankings immediately after new reviews +2. **Regional Rankings**: Rankings by geographic region +3. **Time-Period Rankings**: Best new rides, best classic rides +4. **User Preferences**: Personalized rankings based on user history +5. **Confidence Intervals**: Statistical confidence for rankings +6. **Mobile App API**: Optimized endpoints for mobile applications + +### Potential Optimizations +1. **Incremental Updates**: Only recalculate affected comparisons +2. **Parallel Processing**: Distribute calculation across workers +3. **Machine Learning**: Predict rankings for new rides +4. **GraphQL API**: More flexible data fetching +5. **WebSocket Updates**: Real-time ranking changes + +## Support & Documentation + +### Additional Resources +- [Original IRCP Algorithm](https://ushsho.com/ridesurvey.py) +- [Django REST Framework Documentation](https://www.django-rest-framework.org/) +- [Vue.js Documentation](https://vuejs.org/) +- [TypeScript Documentation](https://www.typescriptlang.org/) + +### Contact +For questions or issues related to the ranking system: +- Create an issue in the project repository +- Contact the development team +- Check the troubleshooting section above + +--- + +*Last Updated: January 2025* +*Version: 1.0* diff --git a/docs/ride-ranking-system.md b/docs/ride-ranking-system.md new file mode 100644 index 00000000..b41e282a --- /dev/null +++ b/docs/ride-ranking-system.md @@ -0,0 +1,608 @@ +# Ride Ranking System Documentation + +## Overview + +The ThrillWiki ride ranking system implements the **Internet Roller Coaster Poll (IRCP) algorithm** to calculate fair, accurate rankings for all rides based on user ratings. This system provides daily-updated rankings that reflect the collective preferences of the community. + +## Algorithm Description + +### Original Internet Roller Coaster Poll Algorithm + +The system implements the exact methodology from the Internet Roller Coaster Poll: + +> "Each coaster is compared one at a time to every other coaster to see whether more people who have ridden both of them preferred one or the other. A coaster is given a 'Win' for each coaster that more mutual riders ranked behind it, given a 'Loss' for each coaster that more mutual riders ranked ahead of it, and given a 'Tie' for each coaster that the same number of mutual riders ranked above it and below it. Coasters are ranked by their overall winning percentage (where ties count as half of a win and half of a loss). In the event that two coasters end up with identical winning percentages, the tie is broken (if possible) by determining which of the two won the mutual rider comparison between those two coasters." + +### Our Implementation + +The ThrillWiki implementation adapts this algorithm to work with our existing rating system: + +1. **"Have You Ridden" Detection**: A user is considered to have ridden a ride if they have submitted a rating/review for it +2. **Preference Determination**: Higher ratings indicate preference (e.g., a user rating Ride A as 8/10 and Ride B as 6/10 prefers Ride A) +3. **Pairwise Comparisons**: Every ride is compared to every other ride based on mutual riders +4. **Winning Percentage**: Calculated as `(wins + 0.5 * ties) / total_comparisons` +5. **Tie Breaking**: Head-to-head comparisons resolve ties in winning percentage + +## System Architecture + +### Database Models + +#### RideRanking +Stores the calculated ranking for each ride: +- `rank`: Overall rank position (1 = best) +- `wins`: Number of rides this ride beats in pairwise comparisons +- `losses`: Number of rides that beat this ride +- `ties`: Number of rides with equal preference +- `winning_percentage`: Win percentage where ties count as 0.5 +- `mutual_riders_count`: Total users who have rated this ride +- `average_rating`: Average rating from all users +- `last_calculated`: Timestamp of last calculation + +#### RidePairComparison +Caches pairwise comparison results between two rides: +- `ride_a`, `ride_b`: The two rides being compared +- `ride_a_wins`: Number of mutual riders who rated ride_a higher +- `ride_b_wins`: Number of mutual riders who rated ride_b higher +- `ties`: Number of mutual riders who rated both equally +- `mutual_riders_count`: Total users who rated both rides + +#### RankingSnapshot +Historical tracking of rankings: +- `ride`: The ride being tracked +- `rank`: Rank on the snapshot date +- `winning_percentage`: Win percentage on the snapshot date +- `snapshot_date`: Date of the snapshot + +### Service Layer + +The `RideRankingService` (`apps/rides/services/ranking_service.py`) implements the core algorithm: + +```python +service = RideRankingService() +result = service.update_all_rankings(category='RC') # Optional category filter +``` + +Key methods: +- `update_all_rankings()`: Main entry point for ranking calculation +- `_calculate_pairwise_comparison()`: Compares two rides +- `_calculate_rankings_from_comparisons()`: Converts comparisons to rankings +- `_apply_tiebreakers()`: Resolves ties using head-to-head comparisons + +## Usage + +### Manual Ranking Update + +Run the management command to update rankings: + +```bash +# Update all ride rankings +uv run python manage.py update_ride_rankings + +# Update only roller coaster rankings +uv run python manage.py update_ride_rankings --category RC +``` + +### Scheduled Updates (Cron) + +Add to crontab for daily updates at 2 AM: + +```bash +0 2 * * * cd /Users/talor/thrillwiki_django_no_react/backend && uv run python manage.py update_ride_rankings +``` + +### Accessing Rankings + +#### Django Admin +View rankings at `/admin/rides/rideranking/` + +#### In Code +```python +from apps.rides.models import RideRanking + +# Get top 10 rides +top_rides = RideRanking.objects.select_related('ride').order_by('rank')[:10] + +# Get ranking for specific ride +ranking = RideRanking.objects.get(ride__slug='millennium-force') +print(f"Rank: #{ranking.rank}, Win%: {ranking.winning_percentage:.1%}") +``` + +## Web Interface + +### Views + +The ranking system provides comprehensive web views for browsing and analyzing rankings. + +#### Rankings List View +**URL**: `/rides/rankings/` + +**Features**: +- Paginated list of all ranked rides (50 per page) +- Filter by ride category (Roller Coasters, Dark Rides, etc.) +- Filter by minimum mutual riders to ensure statistical significance +- HTMX support for dynamic updates without page refresh +- Shows key metrics: rank, ride name, park, wins/losses/ties, winning percentage + +**Query Parameters**: +- `category`: Filter by ride type (RC, DR, FR, WR, TR, OT) +- `min_riders`: Minimum number of mutual riders required (e.g., 100) +- `page`: Page number for pagination + +**Example URLs**: +``` +/rides/rankings/ # All rankings +/rides/rankings/?category=RC # Only roller coasters +/rides/rankings/?category=RC&min_riders=50 # Roller coasters with 50+ mutual riders +``` + +#### Ranking Detail View +**URL**: `/rides/rankings//` + +**Features**: +- Comprehensive ranking metrics for a specific ride +- Head-to-head comparison results with other rides +- Historical ranking chart showing trends over time +- Rank movement indicators (up/down from previous calculation) +- Detailed breakdown of wins, losses, and ties + +**HTMX Endpoints**: +- `/rides/rankings//history-chart/` - Returns ranking history chart data +- `/rides/rankings//comparisons/` - Returns detailed comparison table + +### Template Integration + +The views use Django templates with HTMX for dynamic updates: + +```django +{# rankings.html - Main rankings page #} +{% extends "base.html" %} +{% block content %} +
+

Ride Rankings

+ + {# Filters #} +
+ + + +
+ + {# Rankings table #} +
+ {% include "rides/partials/rankings_table.html" %} +
+
+{% endblock %} +``` + +## API Endpoints + +### RESTful API + +The ranking system exposes a comprehensive REST API for programmatic access. + +#### List Rankings +**Endpoint**: `GET /api/v1/rankings/` + +**Description**: Get paginated list of ride rankings + +**Query Parameters**: +- `category` (string): Filter by ride category (RC, DR, FR, WR, TR, OT) +- `min_riders` (integer): Minimum mutual riders required +- `park` (string): Filter by park slug +- `ordering` (string): Sort order (rank, -rank, winning_percentage, -winning_percentage) +- `page` (integer): Page number +- `page_size` (integer): Results per page (default: 20) + +**Response Example**: +```json +{ + "count": 523, + "next": "http://api.example.com/api/v1/rankings/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "rank": 1, + "ride": { + "id": 123, + "name": "Steel Vengeance", + "slug": "steel-vengeance", + "park": { + "id": 45, + "name": "Cedar Point", + "slug": "cedar-point" + }, + "category": "RC" + }, + "wins": 487, + "losses": 23, + "ties": 13, + "winning_percentage": 0.9405, + "mutual_riders_count": 1543, + "comparison_count": 523, + "average_rating": 9.4, + "last_calculated": "2024-01-15T02:00:00Z", + "rank_change": 0, + "previous_rank": 1 + } + ] +} +``` + +#### Get Ranking Details +**Endpoint**: `GET /api/v1/rankings//` + +**Description**: Get detailed ranking information for a specific ride + +**Response**: Includes full ride details, head-to-head comparisons, and ranking history + +```json +{ + "id": 1, + "rank": 1, + "ride": { + "id": 123, + "name": "Steel Vengeance", + "slug": "steel-vengeance", + "description": "Hybrid roller coaster...", + "park": { + "id": 45, + "name": "Cedar Point", + "slug": "cedar-point", + "location": { + "city": "Sandusky", + "state": "Ohio", + "country": "United States" + } + }, + "category": "RC", + "manufacturer": { + "id": 12, + "name": "Rocky Mountain Construction" + }, + "opening_date": "2018-05-05", + "status": "OPERATING" + }, + "wins": 487, + "losses": 23, + "ties": 13, + "winning_percentage": 0.9405, + "mutual_riders_count": 1543, + "comparison_count": 523, + "average_rating": 9.4, + "last_calculated": "2024-01-15T02:00:00Z", + "calculation_version": "1.0", + "head_to_head_comparisons": [ + { + "opponent": { + "id": 124, + "name": "Fury 325", + "slug": "fury-325", + "park": "Carowinds" + }, + "wins": 234, + "losses": 189, + "ties": 23, + "result": "win", + "mutual_riders": 446 + } + ], + "ranking_history": [ + { + "date": "2024-01-15", + "rank": 1, + "winning_percentage": 0.9405 + }, + { + "date": "2024-01-14", + "rank": 1, + "winning_percentage": 0.9398 + } + ] +} +``` + +#### Get Ranking History +**Endpoint**: `GET /api/v1/rankings//history/` + +**Description**: Get historical ranking data for a specific ride (last 90 days) + +**Response**: Array of ranking snapshots + +#### Get Head-to-Head Comparisons +**Endpoint**: `GET /api/v1/rankings//comparisons/` + +**Description**: Get detailed head-to-head comparison data for a ride + +**Response**: Array of comparison results with all rides + +#### Get Ranking Statistics +**Endpoint**: `GET /api/v1/rankings/statistics/` + +**Description**: Get system-wide ranking statistics + +**Response Example**: +```json +{ + "total_ranked_rides": 523, + "total_comparisons": 136503, + "last_calculation_time": "2024-01-15T02:00:00Z", + "calculation_duration": 45.23, + "top_rated_ride": { + "id": 123, + "name": "Steel Vengeance", + "slug": "steel-vengeance", + "park": "Cedar Point", + "rank": 1, + "winning_percentage": 0.9405, + "average_rating": 9.4 + }, + "most_compared_ride": { + "id": 456, + "name": "Millennium Force", + "slug": "millennium-force", + "park": "Cedar Point", + "comparison_count": 521 + }, + "biggest_rank_change": { + "ride": { + "id": 789, + "name": "Iron Gwazi", + "slug": "iron-gwazi" + }, + "current_rank": 3, + "previous_rank": 8, + "change": 5 + } +} +``` + +#### Trigger Ranking Calculation (Admin) +**Endpoint**: `POST /api/v1/rankings/calculate/` + +**Description**: Manually trigger ranking calculation (requires admin authentication) + +**Request Body**: +```json +{ + "category": "RC" // Optional - filter to specific category +} +``` + +**Response**: +```json +{ + "status": "success", + "rides_ranked": 523, + "comparisons_made": 136503, + "duration": 45.23, + "timestamp": "2024-01-15T02:00:45Z" +} +``` + +### API Authentication + +- Read endpoints (GET): No authentication required +- Calculation endpoint (POST): Requires admin authentication via token or session + +### API Rate Limiting + +- Anonymous users: 100 requests per hour +- Authenticated users: 1000 requests per hour +- Admin users: No rate limiting + +### Python Client Example + +```python +import requests + +# Get top 10 rankings +response = requests.get( + "https://api.thrillwiki.com/api/v1/rankings/", + params={"page_size": 10} +) +rankings = response.json()["results"] + +for ranking in rankings: + print(f"#{ranking['rank']}: {ranking['ride']['name']} - {ranking['winning_percentage']:.1%}") + +# Get specific ride ranking +response = requests.get( + "https://api.thrillwiki.com/api/v1/rankings/steel-vengeance/" +) +details = response.json() +print(f"{details['ride']['name']} is ranked #{details['rank']}") +print(f"Wins: {details['wins']}, Losses: {details['losses']}, Ties: {details['ties']}") +``` + +### JavaScript/TypeScript Client Example + +```typescript +// Using fetch API +async function getTopRankings(limit: number = 10) { + const response = await fetch( + `https://api.thrillwiki.com/api/v1/rankings/?page_size=${limit}` + ); + const data = await response.json(); + return data.results; +} + +// Using axios +import axios from 'axios'; + +const api = axios.create({ + baseURL: 'https://api.thrillwiki.com/api/v1', +}); + +async function getRideRanking(slug: string) { + const { data } = await api.get(`/rankings/${slug}/`); + return data; +} +``` + +## Calculation Example + +### Scenario +Three rides with the following ratings from users: + +**Ride A** (Millennium Force): +- User 1: 10/10 +- User 2: 9/10 +- User 3: 8/10 + +**Ride B** (Maverick): +- User 1: 9/10 +- User 2: 10/10 +- User 4: 8/10 + +**Ride C** (Top Thrill 2): +- User 1: 8/10 +- User 3: 9/10 +- User 4: 10/10 + +### Pairwise Comparisons + +**A vs B** (mutual riders: Users 1, 2): +- User 1: A(10) > B(9) → A wins +- User 2: A(9) < B(10) → B wins +- Result: 1 win each, 0 ties + +**A vs C** (mutual riders: Users 1, 3): +- User 1: A(10) > C(8) → A wins +- User 3: A(8) < C(9) → C wins +- Result: 1 win each, 0 ties + +**B vs C** (mutual riders: Users 1, 4): +- User 1: B(9) > C(8) → B wins +- User 4: B(8) < C(10) → C wins +- Result: 1 win each, 0 ties + +### Final Rankings +All three rides have: +- Wins: 1 +- Losses: 1 +- Ties: 0 +- Winning Percentage: 50% + +Since all have identical winning percentages, the system would use additional criteria (mutual rider count, average rating) to determine final order. + +## Performance Considerations + +### Optimization Strategies + +1. **Caching**: Pairwise comparisons are cached in `RidePairComparison` table +2. **Batch Processing**: Comparisons calculated in bulk to minimize database queries +3. **Indexes**: Database indexes on frequently queried fields: + - `rank` for ordering + - `winning_percentage` for sorting + - `ride_id` for lookups + - Composite indexes for complex queries + +### Scalability + +The algorithm complexity is O(n²) where n is the number of rides, as each ride must be compared to every other ride. For 1,000 rides: +- Comparisons needed: 499,500 +- Estimated processing time: 30-60 seconds +- Database storage: ~20MB for comparison cache + +## Monitoring and Maintenance + +### Admin Interface + +Access Django admin to: +- View current rankings +- Inspect pairwise comparisons +- Track historical changes +- Monitor calculation performance + +### Logging + +The system logs: +- Calculation start/end times +- Number of rides ranked +- Number of comparisons made +- Any errors or warnings + +Example log output: +``` +INFO: Starting ranking calculation for category: RC +INFO: Found 523 rides to rank +DEBUG: Processed 100/136503 comparisons +INFO: Ranking calculation completed in 45.23 seconds +``` + +### Data Cleanup + +Old ranking snapshots are automatically cleaned up after 365 days to manage database size. + +## Algorithm Validity + +### Mathematical Properties + +1. **Transitivity**: Not enforced (A > B and B > C doesn't guarantee A > C) +2. **Consistency**: Same input always produces same output +3. **Fairness**: Every mutual rider's opinion counts equally +4. **Completeness**: All possible comparisons are considered + +### Advantages Over Simple Averaging + +1. **Reduces Selection Bias**: Only compares rides among users who've experienced both +2. **Fair Comparisons**: Popular rides aren't advantaged over less-ridden ones +3. **Head-to-Head Logic**: Direct comparisons matter for tie-breaking +4. **Robust to Outliers**: One extremely high/low rating doesn't skew results + +## Future Enhancements + +### Planned Features + +1. **Real-time Updates**: Update rankings immediately after new reviews +2. **Category-Specific Rankings**: Separate rankings for different ride types +3. **Regional Rankings**: Rankings by geographic region +4. **Time-Period Rankings**: Best new rides, best classic rides +5. **API Endpoints**: RESTful API for ranking data +6. **Ranking Trends**: Visualizations of ranking changes over time + +### Potential Optimizations + +1. **Incremental Updates**: Only recalculate affected comparisons +2. **Parallel Processing**: Distribute comparison calculations +3. **Machine Learning**: Predict rankings for new rides +4. **Weighted Ratings**: Consider recency or reviewer credibility + +## Technical Details + +### File Structure +``` +apps/rides/ +├── models/ +│ └── rankings.py # Ranking data models +├── services/ +│ └── ranking_service.py # Algorithm implementation +├── management/ +│ └── commands/ +│ └── update_ride_rankings.py # CLI command +└── admin.py # Admin interface configuration +``` + +### Database Tables +- `rides_rideranking`: Current rankings +- `rides_ridepaircomparison`: Cached comparisons +- `rides_rankingsnapshot`: Historical data +- `rides_riderankingevent`: Audit trail (pghistory) +- `rides_ridepaircomparisonevent`: Audit trail (pghistory) + +### Dependencies +- Django 5.2+ +- PostgreSQL with pghistory +- Python 3.11+ + +## References + +- [Internet Roller Coaster Poll](https://ushsho.com/ridesurvey.py) - Original algorithm source +- [Condorcet Method](https://en.wikipedia.org/wiki/Condorcet_method) - Similar voting system theory +- [Pairwise Comparison](https://en.wikipedia.org/wiki/Pairwise_comparison) - Mathematical foundation diff --git a/docs/system-architecture-diagram.md b/docs/system-architecture-diagram.md new file mode 100644 index 00000000..37ce356c --- /dev/null +++ b/docs/system-architecture-diagram.md @@ -0,0 +1,164 @@ +# ThrillWiki Trending System - Technical Architecture + +## System Components Overview + +```mermaid +graph TB + subgraph "Frontend Layer" + A[Home.vue Component] --> B[API Service Layer] + B --> C[Trending Content Display] + B --> D[New Content Display] + end + + subgraph "API Layer" + E[/api/v1/trending/] --> F[TrendingViewSet] + G[/api/v1/new-content/] --> H[NewContentViewSet] + F --> I[TrendingSerializer] + H --> J[NewContentSerializer] + end + + subgraph "Business Logic" + K[Trending Algorithm] --> L[Score Calculator] + L --> M[Weight Processor] + M --> N[Ranking Engine] + end + + subgraph "Data Layer" + O[PageView Model] --> P[View Tracker] + Q[Park Model] --> R[Content Source] + S[Ride Model] --> R + T[pghistory Events] --> U[Change Tracker] + end + + subgraph "Caching Layer" + V[Redis Cache] --> W[Trending Cache] + V --> X[New Content Cache] + W --> Y[6hr TTL] + X --> Z[24hr TTL] + end + + subgraph "Background Tasks" + AA[Management Command] --> BB[Calculate Trending] + CC[Celery/Cron Scheduler] --> AA + BB --> K + BB --> V + end + + subgraph "Middleware" + DD[View Tracking Middleware] --> O + EE[User Request] --> DD + end + + A --> E + A --> G + F --> K + H --> U + K --> O + K --> Q + K --> S + F --> V + H --> V + EE --> A +``` + +## Data Flow Architecture + +```mermaid +sequenceDiagram + participant U as User + participant F as Frontend + participant API as API Layer + participant C as Cache + participant BG as Background Job + participant DB as Database + participant M as Middleware + + Note over U,M: Page View Tracking + U->>F: Visit Park/Ride Page + F->>M: HTTP Request + M->>DB: Store PageView Record + + Note over U,M: Trending Content Request + U->>F: Load Home Page + F->>API: GET /api/v1/trending/?tab=rides + API->>C: Check Cache + alt Cache Hit + C->>API: Return Cached Data + else Cache Miss + API->>DB: Query Trending Data + DB->>API: Raw Data + API->>API: Apply Algorithm + API->>C: Store in Cache + end + API->>F: Trending Response + F->>U: Display Trending Content + + Note over U,M: Background Processing + BG->>DB: Aggregate PageViews + BG->>DB: Calculate Scores + BG->>C: Update Cache +``` + +## Algorithm Flow + +```mermaid +flowchart TD + A[Start Trending Calculation] --> B[Fetch Recent PageViews] + B --> C[Group by Content Type/ID] + C --> D[Calculate View Score] + D --> E[Fetch Content Ratings] + E --> F[Calculate Rating Score] + F --> G[Calculate Recency Score] + G --> H[Apply Weighted Formula] + H --> I{Score > Threshold?} + I -->|Yes| J[Add to Trending List] + I -->|No| K[Skip Item] + J --> L[Sort by Final Score] + K --> L + L --> M[Cache Results] + M --> N[End] +``` + +## Database Schema Relationships + +```mermaid +erDiagram + PageView ||--o{ ContentType : references + PageView { + id bigint PK + content_type_id int FK + object_id int + user_session varchar + ip_address inet + user_agent text + timestamp datetime + } + + Park ||--o{ PageView : tracked_in + Park { + id int PK + name varchar + slug varchar + average_rating decimal + status varchar + opening_date date + closing_date date + } + + Ride ||--o{ PageView : tracked_in + Ride { + id int PK + name varchar + slug varchar + park_id int FK + average_rating decimal + category varchar + status varchar + opening_date date + } + + TrendingCache { + key varchar PK + data json + expires_at datetime + } \ No newline at end of file diff --git a/docs/trending-system-architecture.md b/docs/trending-system-architecture.md new file mode 100644 index 00000000..4ae9f61d --- /dev/null +++ b/docs/trending-system-architecture.md @@ -0,0 +1,140 @@ +# ThrillWiki Trending & New Content System Architecture + +## System Overview + +This document outlines the architecture for implementing real trending and new content functionality to replace the current mock data implementation on the ThrillWiki home page. + +## Current State Analysis + +### Frontend Structure (Vue 3 + TypeScript) +- **Home.vue** expects specific data formats: + - **Trending Content**: `{id, name, location, category, rating, rank, views, views_change, slug}` + - **New Content**: `{id, name, location, category, date_added, slug}` +- **Tabs Supported**: + - Trending: Rides, Parks, Reviews + - New: Recently Added, Newly Opened, Upcoming + +### Backend Infrastructure +- **Django REST Framework** with comprehensive ViewSets +- **pghistory** already tracking model changes +- **Existing endpoints** for recent changes, openings, closures +- **Models**: Park and Ride with ratings, status, dates + +## Proposed Architecture + +### 1. Data Flow Architecture + +```mermaid +flowchart TD + A[User Views Page] --> B[View Tracking Middleware] + B --> C[PageView Model] + + D[Trending Calculation Job] --> E[Trending Algorithm] + E --> F[Cache Layer] + + G[Frontend Request] --> H[API Endpoints] + H --> F + F --> I[Serialized Response] + I --> J[Frontend Display] + + K[Management Command] --> D + L[Celery/Cron Schedule] --> K +``` + +### 2. Database Schema Design + +#### PageView Model +```python +class PageView(models.Model): + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + user_session = models.CharField(max_length=40) + ip_address = models.GenericIPAddressField() + user_agent = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=['content_type', 'object_id', 'timestamp']), + models.Index(fields=['timestamp']), + ] +``` + +### 3. Trending Algorithm + +#### Calculation Components +- **View Count Weight**: Recent page views (configurable time window) +- **Rating Weight**: Average rating from Park/Ride models +- **Recency Boost**: Recently added/updated content bonus +- **Category Balancing**: Ensure diverse content across categories + +#### Formula +``` +Trending Score = (View Score × 0.4) + (Rating Score × 0.3) + (Recency Score × 0.2) + (Engagement Score × 0.1) +``` + +### 4. API Endpoints Design + +#### Trending Endpoint +``` +GET /api/v1/trending/?tab={rides|parks|reviews}&limit=6 +``` + +#### New Content Endpoint +``` +GET /api/v1/new-content/?tab={recently-added|newly-opened|upcoming}&limit=4 +``` + +### 5. Caching Strategy + +#### Cache Keys +- `trending_rides_6h`: Trending rides cache (6 hour TTL) +- `trending_parks_6h`: Trending parks cache (6 hour TTL) +- `new_content_24h`: New content cache (24 hour TTL) + +#### Cache Invalidation +- Manual refresh via management command +- Automatic refresh on schedule +- Cache warming during low-traffic periods + +### 6. Performance Considerations + +#### View Tracking Optimization +- Async middleware for non-blocking view tracking +- Batch insert for high-volume periods +- IP-based rate limiting to prevent spam + +#### Database Optimization +- Proper indexing on PageView model +- Aggregate tables for trending calculations +- Periodic cleanup of old PageView records + +## Implementation Plan + +The implementation follows the todo list with these key phases: + +1. **Database Layer**: PageView model and migrations +2. **Algorithm Design**: Trending calculation logic +3. **API Layer**: New endpoints and serializers +4. **Tracking System**: Middleware for view capture +5. **Caching Layer**: Performance optimization +6. **Automation**: Management commands and scheduling +7. **Frontend Integration**: Replace mock data +8. **Testing & Monitoring**: Comprehensive coverage + +## Security & Privacy + +- Anonymous view tracking (no personal data) +- Session-based rate limiting +- User agent validation +- IP address anonymization options + +## Monitoring & Analytics + +- View tracking success rates +- Trending calculation performance +- Cache hit/miss ratios +- API response times +- Algorithm effectiveness metrics \ No newline at end of file diff --git a/frontend/src/components/entity/AuthPrompt.vue b/frontend/src/components/entity/AuthPrompt.vue new file mode 100644 index 00000000..0a0b6815 --- /dev/null +++ b/frontend/src/components/entity/AuthPrompt.vue @@ -0,0 +1,175 @@ + + + diff --git a/frontend/src/components/entity/EntitySuggestionCard.vue b/frontend/src/components/entity/EntitySuggestionCard.vue new file mode 100644 index 00000000..975cb1f5 --- /dev/null +++ b/frontend/src/components/entity/EntitySuggestionCard.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/frontend/src/components/entity/EntitySuggestionManager.vue b/frontend/src/components/entity/EntitySuggestionManager.vue new file mode 100644 index 00000000..bebed21f --- /dev/null +++ b/frontend/src/components/entity/EntitySuggestionManager.vue @@ -0,0 +1,235 @@ + + + diff --git a/frontend/src/components/entity/EntitySuggestionModal.vue b/frontend/src/components/entity/EntitySuggestionModal.vue new file mode 100644 index 00000000..6a1f7fea --- /dev/null +++ b/frontend/src/components/entity/EntitySuggestionModal.vue @@ -0,0 +1,226 @@ + + + diff --git a/frontend/src/components/entity/index.ts b/frontend/src/components/entity/index.ts new file mode 100644 index 00000000..4aa763f8 --- /dev/null +++ b/frontend/src/components/entity/index.ts @@ -0,0 +1,7 @@ +// Entity suggestion components +export { default as EntitySuggestionModal } from './EntitySuggestionModal.vue' +export { default as EntitySuggestionCard } from './EntitySuggestionCard.vue' +export { default as AuthPrompt } from './AuthPrompt.vue' + +// Main integration component +export { default as EntitySuggestionManager } from './EntitySuggestionManager.vue' \ No newline at end of file diff --git a/frontend/src/components/ui/Card.vue b/frontend/src/components/ui/Card.vue index 769c9cd9..05b91609 100644 --- a/frontend/src/components/ui/Card.vue +++ b/frontend/src/components/ui/Card.vue @@ -22,144 +22,160 @@ diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 02f8dd54..2d9fb734 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -12,8 +12,58 @@ import type { PasswordResetRequest, PasswordChangeRequest, SocialAuthProvider, + TrendingResponse, + NewContentResponse, + TrendingItem, + NewContentItem, + RideRanking, + RideRankingDetail, + HeadToHeadComparison, + RankingSnapshot, + RankingStatistics, } from '@/types' +// Entity fuzzy matching types +export interface EntitySuggestion { + name: string + slug: string + entity_type: 'park' | 'ride' | 'company' + match_type: 'exact' | 'fuzzy' | 'partial' + confidence_score: number + additional_info?: { + park_name?: string + opened_date?: string + location?: string + } +} + +export interface FuzzyMatchResult { + query: string + exact_matches: EntitySuggestion[] + fuzzy_matches: EntitySuggestion[] + suggestions: EntitySuggestion[] + total_matches: number + search_time_ms: number + authentication_required: boolean + can_add_entity: boolean +} + +export interface EntityNotFoundResponse { + entity_name: string + entity_type?: string + suggestions: EntitySuggestion[] + can_add_entity: boolean + authentication_required: boolean + add_entity_url?: string + login_url: string + signup_url: string +} + +export interface QuickSuggestionResponse { + suggestions: EntitySuggestion[] + total_available: number +} + // History-specific types export interface HistoryEvent { id: string @@ -311,7 +361,7 @@ export class ParksApi { count: number days: number parks: Park[] - }>('/api/v1/parks/recent_changes/', params) + }>('/api/parks/recent_changes/', params) } /** @@ -328,7 +378,7 @@ export class ParksApi { count: number days: number parks: Park[] - }>('/api/v1/parks/recent_openings/', params) + }>('/api/parks/recent_openings/', params) } /** @@ -345,7 +395,7 @@ export class ParksApi { count: number days: number parks: Park[] - }>('/api/v1/parks/recent_closures/', params) + }>('/api/parks/recent_closures/', params) } /** @@ -362,12 +412,12 @@ export class ParksApi { count: number days: number parks: Park[] - }>('/api/v1/parks/recent_name_changes/', params) + }>('/api/parks/recent_name_changes/', params) } } /** - * Rides API service + * Rides API service with context-aware endpoint selection */ export class RidesApi { private client: ApiClient @@ -377,7 +427,22 @@ export class RidesApi { } /** - * Get all rides with pagination + * Choose appropriate endpoint based on context + * @param parkSlug - If provided, use nested endpoint for park context + * @param operation - The operation being performed + * @returns The appropriate base URL + */ + private getEndpointUrl(parkSlug?: string, operation: 'list' | 'detail' | 'search' = 'list'): string { + if (parkSlug && (operation === 'list' || operation === 'detail')) { + // Use nested endpoint for park-contextual operations + return `/api/parks/${parkSlug}/rides` + } + // Use global endpoint for cross-park operations or when no park context + return '/api/rides' + } + + /** + * Get all rides with pagination - uses global endpoint for cross-park view */ async getRides(params?: { page?: number @@ -390,28 +455,52 @@ export class RidesApi { if (params?.search) queryParams.search = params.search if (params?.ordering) queryParams.ordering = params.ordering - return this.client.get>('/api/rides/', queryParams) + return this.client.get>(`${this.getEndpointUrl()}/`, queryParams) } /** - * Get a single ride by park and ride slug + * Get rides for a specific park - uses nested endpoint for park context + */ + async getRidesByPark(parkSlug: string, params?: { + page?: number + search?: string + ordering?: string + }): Promise> { + const queryParams: Record = {} + + if (params?.page) queryParams.page = params.page.toString() + if (params?.search) queryParams.search = params.search + if (params?.ordering) queryParams.ordering = params.ordering + + return this.client.get>(`${this.getEndpointUrl(parkSlug, 'list')}/`, queryParams) + } + + /** + * Get a single ride by park and ride slug - uses nested endpoint for park context */ async getRide(parkSlug: string, rideSlug: string): Promise { - return this.client.get(`/api/rides/${parkSlug}/${rideSlug}/`) + return this.client.get(`${this.getEndpointUrl(parkSlug, 'detail')}/${rideSlug}/`) } /** - * Search rides + * Get a ride by global ID (fallback method) - uses global endpoint + */ + async getRideById(rideId: string): Promise { + return this.client.get(`${this.getEndpointUrl()}/${rideId}/`) + } + + /** + * Search rides globally - uses global endpoint for cross-park search */ async searchRides(query: string): Promise> { - return this.client.get>('/api/rides/search/', { q: query }) + return this.client.get>(`${this.getEndpointUrl()}/search/`, { q: query }) } /** - * Get rides by park + * Search rides within a specific park - uses nested endpoint for park context */ - async getRidesByPark(parkSlug: string): Promise> { - return this.client.get>(`/api/rides/by-park/${parkSlug}/`) + async searchRidesInPark(parkSlug: string, query: string): Promise> { + return this.client.get>(`${this.getEndpointUrl(parkSlug, 'search')}/search/`, { q: query }) } /** @@ -435,7 +524,7 @@ export class RidesApi { } /** - * Get recently changed rides + * Get recently changed rides - uses global endpoint for cross-park analysis */ async getRecentChanges(days?: number): Promise<{ count: number @@ -448,11 +537,11 @@ export class RidesApi { count: number days: number rides: Ride[] - }>('/api/v1/rides/recent_changes/', params) + }>('/api/rides/recent_changes/', params) } /** - * Get recently opened rides + * Get recently opened rides - uses global endpoint for cross-park analysis */ async getRecentOpenings(days?: number): Promise<{ count: number @@ -465,11 +554,11 @@ export class RidesApi { count: number days: number rides: Ride[] - }>('/api/v1/rides/recent_openings/', params) + }>('/api/rides/recent_openings/', params) } /** - * Get recently closed rides + * Get recently closed rides - uses global endpoint for cross-park analysis */ async getRecentClosures(days?: number): Promise<{ count: number @@ -482,11 +571,11 @@ export class RidesApi { count: number days: number rides: Ride[] - }>('/api/v1/rides/recent_closures/', params) + }>('/api/rides/recent_closures/', params) } /** - * Get rides with recent name changes + * Get rides with recent name changes - uses global endpoint for cross-park analysis */ async getRecentNameChanges(days?: number): Promise<{ count: number @@ -499,11 +588,11 @@ export class RidesApi { count: number days: number rides: Ride[] - }>('/api/v1/rides/recent_name_changes/', params) + }>('/api/rides/recent_name_changes/', params) } /** - * Get rides that have been relocated recently + * Get rides that have been relocated recently - uses global endpoint for cross-park analysis */ async getRecentRelocations(days?: number): Promise<{ count: number @@ -516,7 +605,7 @@ export class RidesApi { count: number days: number rides: Ride[] - }>('/api/v1/rides/recent_relocations/', params) + }>('/api/rides/recent_relocations/', params) } } @@ -530,11 +619,25 @@ export class AuthApi { this.client = client } + /** + * Set authentication token + */ + setAuthToken(token: string | null): void { + this.client.setAuthToken(token) + } + + /** + * Get authentication token + */ + getAuthToken(): string | null { + return this.client.getAuthToken() + } + /** * Login with username/email and password */ async login(credentials: LoginCredentials): Promise { - const response = await this.client.post('/api/accounts/login/', credentials) + const response = await this.client.post('/api/auth/login/', credentials) if (response.token) { this.client.setAuthToken(response.token) } @@ -620,7 +723,7 @@ export class HistoryApi { if (params?.model_type) queryParams.model_type = params.model_type if (params?.significance) queryParams.significance = params.significance - return this.client.get('/api/v1/history/timeline/', queryParams) + return this.client.get('/api/history/timeline/', queryParams) } /** @@ -635,14 +738,14 @@ export class HistoryApi { if (params?.start_date) queryParams.start_date = params.start_date if (params?.end_date) queryParams.end_date = params.end_date - return this.client.get(`/api/v1/parks/${parkSlug}/history/`, queryParams) + return this.client.get(`/api/parks/${parkSlug}/history/`, queryParams) } /** * Get complete park history with current state and summary */ async getParkHistoryDetail(parkSlug: string): Promise { - return this.client.get(`/api/v1/parks/${parkSlug}/history/detail/`) + return this.client.get(`/api/parks/${parkSlug}/history/detail/`) } /** @@ -662,7 +765,7 @@ export class HistoryApi { if (params?.end_date) queryParams.end_date = params.end_date return this.client.get( - `/api/v1/parks/${parkSlug}/rides/${rideSlug}/history/`, + `/api/parks/${parkSlug}/rides/${rideSlug}/history/`, queryParams ) } @@ -672,7 +775,7 @@ export class HistoryApi { */ async getRideHistoryDetail(parkSlug: string, rideSlug: string): Promise { return this.client.get( - `/api/v1/parks/${parkSlug}/rides/${rideSlug}/history/detail/` + `/api/parks/${parkSlug}/rides/${rideSlug}/history/detail/` ) } @@ -726,6 +829,300 @@ export class HistoryApi { } } +/** + * Trending API service for trending content and new content + */ +export class TrendingApi { + private client: ApiClient + + constructor(client: ApiClient = new ApiClient()) { + this.client = client + } + + /** + * Get trending content (rides, parks, reviews) + */ + async getTrendingContent(): Promise { + return this.client.get('/api/trending/content/') + } + + /** + * Get new content (recently added, newly opened, upcoming) + */ + async getNewContent(): Promise { + return this.client.get('/api/trending/new/') + } + + /** + * Get trending rides specifically + */ + async getTrendingRides(): Promise { + const response = await this.getTrendingContent() + return response.trending_rides + } + + /** + * Get trending parks specifically + */ + async getTrendingParks(): Promise { + const response = await this.getTrendingContent() + return response.trending_parks + } + + /** + * Get latest reviews specifically + */ + async getLatestReviews(): Promise { + const response = await this.getTrendingContent() + return response.latest_reviews + } + + /** + * Get recently added content specifically + */ + async getRecentlyAdded(): Promise { + const response = await this.getNewContent() + return response.recently_added + } + + /** + * Get newly opened content specifically + */ + async getNewlyOpened(): Promise { + const response = await this.getNewContent() + return response.newly_opened + } + + /** + * Get upcoming content specifically + */ + async getUpcoming(): Promise { + const response = await this.getNewContent() + return response.upcoming + } +} + +/** + * Ride Rankings API service + */ +export class RankingsApi { + private client: ApiClient + + constructor(client: ApiClient = new ApiClient()) { + this.client = client + } + + /** + * Get paginated list of ride rankings + */ + async getRankings(params?: { + page?: number + page_size?: number + category?: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT' + min_riders?: number + park?: string + ordering?: 'rank' | '-rank' | 'winning_percentage' | '-winning_percentage' + }): Promise> { + const queryParams: Record = {} + + if (params?.page) queryParams.page = params.page.toString() + if (params?.page_size) queryParams.page_size = params.page_size.toString() + if (params?.category) queryParams.category = params.category + if (params?.min_riders) queryParams.min_riders = params.min_riders.toString() + if (params?.park) queryParams.park = params.park + if (params?.ordering) queryParams.ordering = params.ordering + + return this.client.get>('/api/rankings/', queryParams) + } + + /** + * Get detailed ranking information for a specific ride + */ + async getRankingDetail(rideSlug: string): Promise { + return this.client.get(`/api/rankings/${rideSlug}/`) + } + + /** + * Get historical ranking data for a specific ride (last 90 days) + */ + async getRankingHistory(rideSlug: string): Promise { + return this.client.get(`/api/rankings/${rideSlug}/history/`) + } + + /** + * Get head-to-head comparison data for a ride + */ + async getHeadToHeadComparisons(rideSlug: string): Promise { + return this.client.get(`/api/rankings/${rideSlug}/comparisons/`) + } + + /** + * Get system-wide ranking statistics + */ + async getRankingStatistics(): Promise { + return this.client.get('/api/rankings/statistics/') + } + + /** + * Trigger ranking calculation (Admin only) + */ + async calculateRankings(category?: string): Promise<{ + status: string + rides_ranked: number + comparisons_made: number + duration: number + timestamp: string + }> { + const data = category ? { category } : {} + return this.client.post<{ + status: string + rides_ranked: number + comparisons_made: number + duration: number + timestamp: string + }>('/api/rankings/calculate/', data) + } + + /** + * Get top N rankings (convenience method) + */ + async getTopRankings(limit: number = 10, category?: string): Promise { + const response = await this.getRankings({ + page_size: limit, + category: category as any, + ordering: 'rank' + }) + return response.results + } + + /** + * Get rankings for a specific park + */ + async getParkRankings(parkSlug: string, params?: { + page?: number + page_size?: number + category?: string + }): Promise> { + return this.getRankings({ + ...params, + park: parkSlug, + category: params?.category as any + }) + } + + /** + * Search rankings by ride name + */ + async searchRankings(query: string): Promise { + // This would ideally have a dedicated search endpoint, but for now + // we'll fetch all and filter client-side (not ideal for large datasets) + const response = await this.getRankings({ page_size: 100 }) + return response.results.filter(ranking => + ranking.ride.name.toLowerCase().includes(query.toLowerCase()) + ) + } + + /** + * Get rank change information for a ride + */ + async getRankChange(rideSlug: string): Promise<{ + current_rank: number + previous_rank: number | null + change: number + direction: 'up' | 'down' | 'same' + }> { + const detail = await this.getRankingDetail(rideSlug) + const change = detail.rank_change || 0 + return { + current_rank: detail.rank, + previous_rank: detail.previous_rank, + change: Math.abs(change), + direction: change > 0 ? 'down' : change < 0 ? 'up' : 'same' + } + } +} + +/** + * Entity Search API service for fuzzy matching and suggestions + */ +export class EntitySearchApi { + private client: ApiClient + + constructor(client: ApiClient = new ApiClient()) { + this.client = client + } + + /** + * Perform fuzzy search for entities + */ + async fuzzySearch(query: string, params?: { + entity_types?: string[] + context_park?: string + limit?: number + min_confidence?: number + }): Promise { + const queryParams: Record = { q: query } + + if (params?.entity_types) queryParams.entity_types = params.entity_types.join(',') + if (params?.context_park) queryParams.context_park = params.context_park + if (params?.limit) queryParams.limit = params.limit.toString() + if (params?.min_confidence) queryParams.min_confidence = params.min_confidence.toString() + + return this.client.get('/api/entities/fuzzy-search/', queryParams) + } + + /** + * Handle entity not found scenarios with suggestions + */ + async handleNotFound(entityName: string, entityType?: string, context?: { + park_slug?: string + path?: string + }): Promise { + const data: Record = { entity_name: entityName } + + if (entityType) data.entity_type = entityType + if (context?.park_slug) data.park_slug = context.park_slug + if (context?.path) data.path = context.path + + return this.client.post('/api/entities/not-found/', data) + } + + /** + * Get quick suggestions for autocomplete + */ + async getQuickSuggestions(query: string, params?: { + entity_types?: string[] + limit?: number + park_context?: string + }): Promise { + const queryParams: Record = { q: query } + + if (params?.entity_types) queryParams.entity_types = params.entity_types.join(',') + if (params?.limit) queryParams.limit = params.limit.toString() + if (params?.park_context) queryParams.park_context = params.park_context + + return this.client.get('/api/entities/quick-suggestions/', queryParams) + } + + /** + * Check if user is authenticated and can add entities + */ + isAuthenticated(): boolean { + return !!this.client.getAuthToken() + } + + /** + * Get authentication URLs for redirects + */ + getAuthUrls(): { login: string; signup: string } { + const baseUrl = this.client['baseUrl'] + return { + login: `${baseUrl}/auth/login/`, + signup: `${baseUrl}/auth/signup/` + } + } +} + /** * Main API service that combines all endpoints */ @@ -734,6 +1131,9 @@ export class ThrillWikiApi { public rides: RidesApi public auth: AuthApi public history: HistoryApi + public trending: TrendingApi + public rankings: RankingsApi + public entitySearch: EntitySearchApi private client: ApiClient constructor() { @@ -742,6 +1142,9 @@ export class ThrillWikiApi { this.rides = new RidesApi(this.client) this.auth = new AuthApi(this.client) this.history = new HistoryApi(this.client) + this.trending = new TrendingApi(this.client) + this.rankings = new RankingsApi(this.client) + this.entitySearch = new EntitySearchApi(this.client) } /** @@ -785,6 +1188,9 @@ export const parksApi = api.parks export const ridesApi = api.rides export const authApi = api.auth export const historyApi = api.history +export const trendingApi = api.trending +export const rankingsApi = api.rankings +export const entitySearchApi = api.entitySearch // Export types for use in components export type { @@ -796,5 +1202,9 @@ export type { ParkHistoryResponse, RideHistoryResponse, UnifiedHistoryTimeline, - HistoryParams + HistoryParams, + EntitySuggestion, + FuzzyMatchResult, + EntityNotFoundResponse, + QuickSuggestionResponse } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 44b343aa..7569daf4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -179,3 +179,163 @@ export interface SocialAuthProvider { iconUrl?: string authUrl: string } + +// Trending and New Content types +export interface TrendingItem { + id: number + name: string + location: string + category: string + rating: number + rank: number + views: number + views_change: number + slug: string +} + +export interface NewContentItem { + id: number + name: string + location: string + category: string + date_added: string + slug: string +} + +export interface TrendingResponse { + trending_rides: TrendingItem[] + trending_parks: TrendingItem[] + latest_reviews: TrendingItem[] +} + +export interface NewContentResponse { + recently_added: NewContentItem[] + newly_opened: NewContentItem[] + upcoming: NewContentItem[] +} + +// Enhanced Park and Ride interfaces for trending support +export interface ParkWithTrending extends Park { + rating?: number + rank?: number + views?: number + views_change?: number + date_added?: string +} + +export interface RideWithTrending extends Ride { + rating?: number + rank?: number + views?: number + views_change?: number + date_added?: string +} + +// Ride Ranking types +export interface RideRanking { + id: number + rank: number + ride: { + id: number + name: string + slug: string + park: { + id: number + name: string + slug: string + } + category: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT' + } + wins: number + losses: number + ties: number + winning_percentage: number + mutual_riders_count: number + comparison_count: number + average_rating: number + last_calculated: string + rank_change?: number + previous_rank?: number | null +} + +export interface RideRankingDetail extends RideRanking { + ride: { + id: number + name: string + slug: string + description?: string + park: { + id: number + name: string + slug: string + location?: { + city?: string + state?: string + country?: string + } + } + category: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT' + manufacturer?: { + id: number + name: string + } + opening_date?: string + status: string + } + calculation_version?: string + head_to_head_comparisons?: HeadToHeadComparison[] + ranking_history?: RankingSnapshot[] +} + +export interface HeadToHeadComparison { + opponent: { + id: number + name: string + slug: string + park: string + } + wins: number + losses: number + ties: number + result: 'win' | 'loss' | 'tie' + mutual_riders: number +} + +export interface RankingSnapshot { + date: string + rank: number + winning_percentage: number +} + +export interface RankingStatistics { + total_ranked_rides: number + total_comparisons: number + last_calculation_time: string + calculation_duration: number + top_rated_ride?: { + id: number + name: string + slug: string + park: string + rank: number + winning_percentage: number + average_rating: number + } + most_compared_ride?: { + id: number + name: string + slug: string + park: string + comparison_count: number + } + biggest_rank_change?: { + ride: { + id: number + name: string + slug: string + } + current_rank: number + previous_rank: number + change: number + } +} diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 09ca88ae..58378817 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -8,13 +8,16 @@

Discover Your Next Thrill

- Search through thousands of amusement rides and parks in an expansive community database + Search through thousands of amusement rides and parks in an expansive + community database

-
+
- - + +

What's Trending @@ -109,19 +118,70 @@

-
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + +

+ Failed to Load Trending Content +

+

{{ trendingError }}

+ +
+
+ + +
+
-
- +
+ #{{ item.rank }}
-
+
- + @@ -156,19 +222,28 @@

{{ item.name }}

-

- {{ item.location }} - • {{ item.category }} +

+ {{ + item.location + }} + + • {{ item.category }}

- + - {{ item.views }} views + {{ item.views.toLocaleString() }} views
-
+
@@ -195,10 +270,18 @@
- - + + -

What's New

+

+ What's New +

Stay up to date with the latest additions and upcoming attractions @@ -225,19 +308,70 @@

-
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + +

+ Failed to Load New Content +

+

{{ newError }}

+ +
+
+ + +
+
-
- +
+ New
@@ -259,26 +395,30 @@

{{ item.name }}

-

- {{ item.location }} - • {{ item.category }} +

+ {{ + item.location + }} + + • {{ item.category }}

- + Added {{ item.date_added }} - View All New Additions → + Learn More →
-
+
@@ -290,8 +430,8 @@ Join the ThrillWiki Community

- Share your experiences, contribute to our database, and connect with fellow theme park - enthusiasts. + Share your experiences, contribute to our database, and connect with fellow + theme park enthusiasts.