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:
parent
014e22390e
commit
5934c7666c
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
]
|
||||
|
||||
@ -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)"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user