Task 4.7: Update template helpers to use key identifier

- Add series context helpers: prepare_series_context, get_series_by_key, filter_series_by_missing_episodes
- Update module docstring with identifier convention documentation
- Add unit tests for new series context helper functions
- Update infrastructure.md with template helpers documentation
- Mark Phase 4 (API Layer) as complete
This commit is contained in:
Lukas 2025-11-28 16:00:15 +01:00
parent 014e22390e
commit 5934c7666c
4 changed files with 329 additions and 55 deletions

View File

@ -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

View File

@ -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

View File

@ -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))
@ -94,3 +106,112 @@ def list_available_templates() -> list[str]:
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())
]

View File

@ -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)"