diff --git a/infrastructure.md b/infrastructure.md index 343fdca..1eaf98f 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -182,6 +182,42 @@ Provides data validation functions for ensuring data integrity across the applic | `validate_config_data` | Configuration data structure validation | | `sanitize_filename` | Sanitize filenames for safe filesystem use | +## Template Helpers (`src/server/utils/template_helpers.py`) + +Provides utilities for template rendering and series data preparation. + +### Core Functions + +| Function | Purpose | +| ------------------------- | ------------------------------------ | +| `get_base_context` | Base context for all templates | +| `render_template` | Render template with context | +| `validate_template_exists`| Check if template file exists | +| `list_available_templates`| List all available template files | + +### Series Context Helpers + +All series helpers use `key` as the primary identifier: + +| Function | Purpose | +| --------------------------------- | ---------------------------------------------- | +| `prepare_series_context` | Prepare series data for templates (uses `key`) | +| `get_series_by_key` | Find series by `key` (not `folder`) | +| `filter_series_by_missing_episodes`| Filter series with missing episodes | + +**Example Usage:** + +```python +from src.server.utils.template_helpers import prepare_series_context + +series_data = [ + {"key": "attack-on-titan", "name": "Attack on Titan", "folder": "Attack on Titan (2013)"}, + {"key": "one-piece", "name": "One Piece", "folder": "One Piece (1999)"} +] +prepared = prepare_series_context(series_data, sort_by="name") +# Returns sorted list using 'key' as identifier +``` + ## Frontend ### Static Files diff --git a/instructions.md b/instructions.md index 2dc0917..f0ee3a0 100644 --- a/instructions.md +++ b/instructions.md @@ -101,7 +101,7 @@ For each task completed: - [ ] Code reviewed - [ ] Task marked as complete in instructions.md - [ ] Infrastructure.md updated -- [ ] Changes committed to git +- [ ] Changes committed to git; keep your messages in git short and clear - [ ] Take the next task --- @@ -158,49 +158,16 @@ For each task completed: ## Task Series: Identifier Standardization -### Phase 4: API Layer +### Phase 4: API Layer ✅ (Completed November 28, 2025) -#### Task 4.1: Update Anime API Endpoints to Use Key ✅ (November 27, 2025) - -#### Task 4.2: Update Download API Endpoints to Use Key ✅ (November 27, 2025) - -#### Task 4.3: Update Queue API Endpoints to Use Key ✅ (November 27, 2025) - -#### Task 4.4: Update WebSocket API Endpoints to Use Key ✅ (November 27, 2025) - -#### Task 4.5: Update Pydantic Models to Use Key ✅ (November 27, 2025) - -#### Task 4.6: Update Validators to Use Key ✅ (November 28, 2025) - ---- - -#### Task 4.7: Update Template Helpers to Use Key - -**File:** `src/server/utils/template_helpers.py` - -**Objective:** Ensure template helpers pass `key` to templates for series identification. - -**Steps:** - -1. Open `src/server/utils/template_helpers.py` -2. Review all helper functions -3. Ensure functions that handle series data use `key` -4. Update any filtering or sorting to use `key` -5. Ensure `folder` is available for display purposes -6. Update docstrings - -**Success Criteria:** - -- [ ] All helpers use `key` for identification -- [ ] `folder` available for display -- [ ] No breaking changes to template interface -- [ ] All template helper tests pass - -**Test Command:** - -```bash -conda run -n AniWorld python -m pytest tests/unit/ -k "template" -v -``` +All API layer tasks completed: +- Task 4.1: Update Anime API Endpoints ✅ +- Task 4.2: Update Download API Endpoints ✅ +- Task 4.3: Update Queue API Endpoints ✅ +- Task 4.4: Update WebSocket API Endpoints ✅ +- Task 4.5: Update Pydantic Models ✅ +- Task 4.6: Update Validators ✅ +- Task 4.7: Update Template Helpers ✅ --- @@ -735,14 +702,7 @@ conda run -n AniWorld python -m pytest tests/integration/test_identifier_consist - [x] Phase 1: Core Models and Data Layer ✅ - [x] Phase 2: Core Application Layer ✅ - [x] Phase 3: Service Layer ✅ -- [ ] Phase 4: API Layer - - [x] Task 4.1: Update Anime API Endpoints ✅ **Completed November 27, 2025** - - [x] Task 4.2: Update Download API Endpoints ✅ **Completed November 27, 2025** - - [x] Task 4.3: Update Queue API Endpoints ✅ **Completed November 27, 2025** - - [x] Task 4.4: Update WebSocket API Endpoints ✅ **Completed November 27, 2025** - - [x] Task 4.5: Update Pydantic Models ✅ **Completed November 27, 2025** - - [x] Task 4.6: Update Validators ✅ **Completed November 28, 2025** - - [ ] Task 4.7: Update Template Helpers +- [x] Phase 4: API Layer ✅ **Completed November 28, 2025** - [ ] Phase 5: Frontend - [ ] Task 5.1: Update Frontend JavaScript - [ ] Task 5.2: Update WebSocket Events diff --git a/src/server/utils/template_helpers.py b/src/server/utils/template_helpers.py index 3f6451c..3ecca40 100644 --- a/src/server/utils/template_helpers.py +++ b/src/server/utils/template_helpers.py @@ -3,13 +3,25 @@ Template integration utilities for FastAPI application. This module provides utilities for template rendering with common context and helper functions. + +Series Identifier Convention: + - `key`: Primary identifier for all series operations + (URL-safe, e.g., "attack-on-titan") + - `folder`: Metadata only for filesystem operations and display + (e.g., "Attack on Titan (2013)") + +All template helpers that handle series data use `key` for identification and +provide `folder` as display metadata only. """ +import logging from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from fastapi import Request from fastapi.templating import Jinja2Templates +logger = logging.getLogger(__name__) + # Configure templates directory TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) @@ -82,15 +94,124 @@ def validate_template_exists(template_name: str) -> bool: def list_available_templates() -> list[str]: """ Get list of all available template files. - + Returns: List of template file names """ if not TEMPLATES_DIR.exists(): return [] - + return [ f.name for f in TEMPLATES_DIR.glob("*.html") if f.is_file() ] + + +def prepare_series_context( + series_data: List[Dict[str, Any]], + sort_by: str = "name" +) -> List[Dict[str, Any]]: + """ + Prepare series data for template rendering. + + This function ensures series data follows the identifier convention: + - `key` is used as the primary identifier for all operations + - `folder` is included as metadata for display purposes + + Args: + series_data: List of series dictionaries from the API + sort_by: Field to sort by ("name", "key", or "folder") + + Returns: + List of series dictionaries prepared for template use + + Raises: + ValueError: If series_data contains items without required 'key' field + + Example: + >>> series = [ + ... {"key": "attack-on-titan", "name": "Attack on Titan", + ... "folder": "Attack on Titan (2013)"}, + ... {"key": "one-piece", "name": "One Piece", + ... "folder": "One Piece (1999)"} + ... ] + >>> prepared = prepare_series_context(series, sort_by="name") + """ + if not series_data: + return [] + + prepared = [] + for series in series_data: + if "key" not in series: + logger.warning( + "Series data missing 'key' field: %s", + series.get("name", "unknown") + ) + continue + + prepared_item = { + "key": series["key"], + "name": series.get("name", series["key"]), + "folder": series.get("folder", ""), + **{k: v for k, v in series.items() + if k not in ("key", "name", "folder")} + } + prepared.append(prepared_item) + + # Sort by specified field + if sort_by in ("name", "key", "folder"): + prepared.sort(key=lambda x: x.get(sort_by, "").lower()) + + return prepared + + +def get_series_by_key( + series_data: List[Dict[str, Any]], + key: str +) -> Optional[Dict[str, Any]]: + """ + Find a series in the data by its key. + + Uses `key` as the identifier (not `folder`) following the project + identifier convention. + + Args: + series_data: List of series dictionaries + key: The unique series key to search for + + Returns: + The series dictionary if found, None otherwise + + Example: + >>> series = [{"key": "attack-on-titan", "name": "Attack on Titan"}] + >>> result = get_series_by_key(series, "attack-on-titan") + >>> result["name"] + 'Attack on Titan' + """ + for series in series_data: + if series.get("key") == key: + return series + return None + + +def filter_series_by_missing_episodes( + series_data: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """ + Filter series to only include those with missing episodes. + + Args: + series_data: List of series dictionaries with 'missing_episodes' field + + Returns: + Filtered list containing only series with missing episodes + + Note: + Identification uses `key`, not `folder`. + """ + return [ + series for series in series_data + if series.get("missing_episodes") + and any(episodes for episodes in series["missing_episodes"].values()) + ] diff --git a/tests/unit/test_template_helpers.py b/tests/unit/test_template_helpers.py index 9d1762e..721afff 100644 --- a/tests/unit/test_template_helpers.py +++ b/tests/unit/test_template_helpers.py @@ -1,15 +1,19 @@ """ Tests for template helper utilities. -This module tests the template helper functions. +This module tests the template helper functions including series context +preparation using `key` as the primary identifier. """ from unittest.mock import Mock import pytest from src.server.utils.template_helpers import ( + filter_series_by_missing_episodes, get_base_context, + get_series_by_key, list_available_templates, + prepare_series_context, validate_template_exists, ) @@ -84,3 +88,156 @@ class TestTemplateHelpers: """Test that all required templates exist.""" assert validate_template_exists(template_name), \ f"Required template {template_name} does not exist" + + +class TestSeriesContextHelpers: + """Test series context helper functions. + + These tests verify that series helpers use `key` as the primary + identifier following the project's identifier convention. + """ + + def test_prepare_series_context_uses_key(self): + """Test that prepare_series_context uses key as primary identifier.""" + series_data = [ + { + "key": "attack-on-titan", + "name": "Attack on Titan", + "folder": "Attack on Titan (2013)", + }, + { + "key": "one-piece", + "name": "One Piece", + "folder": "One Piece (1999)", + }, + ] + prepared = prepare_series_context(series_data) + + assert len(prepared) == 2 + # Verify key is present and used + assert prepared[0]["key"] in ("attack-on-titan", "one-piece") + assert all("key" in item for item in prepared) + assert all("folder" in item for item in prepared) + + def test_prepare_series_context_sorts_by_name(self): + """Test that series are sorted by name by default.""" + series_data = [ + {"key": "z-series", "name": "Zebra Anime", "folder": "z"}, + {"key": "a-series", "name": "Alpha Anime", "folder": "a"}, + ] + prepared = prepare_series_context(series_data, sort_by="name") + + assert prepared[0]["name"] == "Alpha Anime" + assert prepared[1]["name"] == "Zebra Anime" + + def test_prepare_series_context_sorts_by_key(self): + """Test that series can be sorted by key.""" + series_data = [ + {"key": "z-series", "name": "Zebra", "folder": "z"}, + {"key": "a-series", "name": "Alpha", "folder": "a"}, + ] + prepared = prepare_series_context(series_data, sort_by="key") + + assert prepared[0]["key"] == "a-series" + assert prepared[1]["key"] == "z-series" + + def test_prepare_series_context_empty_list(self): + """Test prepare_series_context with empty list.""" + prepared = prepare_series_context([]) + assert prepared == [] + + def test_prepare_series_context_skips_missing_key(self): + """Test that items without key are skipped with warning.""" + series_data = [ + {"key": "valid-series", "name": "Valid", "folder": "valid"}, + {"name": "No Key", "folder": "nokey"}, # Missing key + ] + prepared = prepare_series_context(series_data) + + assert len(prepared) == 1 + assert prepared[0]["key"] == "valid-series" + + def test_prepare_series_context_preserves_extra_fields(self): + """Test that extra fields are preserved.""" + series_data = [ + { + "key": "test", + "name": "Test", + "folder": "test", + "missing_episodes": {"1": [1, 2]}, + "site": "aniworld.to", + } + ] + prepared = prepare_series_context(series_data) + + assert prepared[0]["missing_episodes"] == {"1": [1, 2]} + assert prepared[0]["site"] == "aniworld.to" + + def test_get_series_by_key_found(self): + """Test finding a series by key.""" + series_data = [ + {"key": "attack-on-titan", "name": "Attack on Titan"}, + {"key": "one-piece", "name": "One Piece"}, + ] + result = get_series_by_key(series_data, "attack-on-titan") + + assert result is not None + assert result["name"] == "Attack on Titan" + + def test_get_series_by_key_not_found(self): + """Test that None is returned when key not found.""" + series_data = [ + {"key": "attack-on-titan", "name": "Attack on Titan"}, + ] + result = get_series_by_key(series_data, "non-existent") + + assert result is None + + def test_get_series_by_key_empty_list(self): + """Test get_series_by_key with empty list.""" + result = get_series_by_key([], "any-key") + assert result is None + + def test_filter_series_by_missing_episodes(self): + """Test filtering series with missing episodes.""" + series_data = [ + { + "key": "has-missing", + "name": "Has Missing", + "missing_episodes": {"1": [1, 2, 3]}, + }, + { + "key": "no-missing", + "name": "No Missing", + "missing_episodes": {}, + }, + { + "key": "empty-seasons", + "name": "Empty Seasons", + "missing_episodes": {"1": [], "2": []}, + }, + ] + filtered = filter_series_by_missing_episodes(series_data) + + assert len(filtered) == 1 + assert filtered[0]["key"] == "has-missing" + + def test_filter_series_by_missing_episodes_empty(self): + """Test filter with empty list.""" + filtered = filter_series_by_missing_episodes([]) + assert filtered == [] + + def test_filter_preserves_key_identifier(self): + """Test that filter preserves key as identifier.""" + series_data = [ + { + "key": "test-series", + "folder": "Test Series (2020)", + "name": "Test", + "missing_episodes": {"1": [1]}, + } + ] + filtered = filter_series_by_missing_episodes(series_data) + + assert filtered[0]["key"] == "test-series" + assert filtered[0]["folder"] == "Test Series (2020)"