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 |
|
| `validate_config_data` | Configuration data structure validation |
|
||||||
| `sanitize_filename` | Sanitize filenames for safe filesystem use |
|
| `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
|
## Frontend
|
||||||
|
|
||||||
### Static Files
|
### Static Files
|
||||||
|
|||||||
@ -101,7 +101,7 @@ For each task completed:
|
|||||||
- [ ] Code reviewed
|
- [ ] Code reviewed
|
||||||
- [ ] Task marked as complete in instructions.md
|
- [ ] Task marked as complete in instructions.md
|
||||||
- [ ] Infrastructure.md updated
|
- [ ] Infrastructure.md updated
|
||||||
- [ ] Changes committed to git
|
- [ ] Changes committed to git; keep your messages in git short and clear
|
||||||
- [ ] Take the next task
|
- [ ] Take the next task
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -158,49 +158,16 @@ For each task completed:
|
|||||||
|
|
||||||
## Task Series: Identifier Standardization
|
## 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)
|
All API layer tasks completed:
|
||||||
|
- Task 4.1: Update Anime API Endpoints ✅
|
||||||
#### Task 4.2: Update Download API Endpoints to Use Key ✅ (November 27, 2025)
|
- Task 4.2: Update Download API Endpoints ✅
|
||||||
|
- Task 4.3: Update Queue API Endpoints ✅
|
||||||
#### Task 4.3: Update Queue API Endpoints to Use Key ✅ (November 27, 2025)
|
- Task 4.4: Update WebSocket API Endpoints ✅
|
||||||
|
- Task 4.5: Update Pydantic Models ✅
|
||||||
#### Task 4.4: Update WebSocket API Endpoints to Use Key ✅ (November 27, 2025)
|
- Task 4.6: Update Validators ✅
|
||||||
|
- Task 4.7: Update Template Helpers ✅
|
||||||
#### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -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 1: Core Models and Data Layer ✅
|
||||||
- [x] Phase 2: Core Application Layer ✅
|
- [x] Phase 2: Core Application Layer ✅
|
||||||
- [x] Phase 3: Service Layer ✅
|
- [x] Phase 3: Service Layer ✅
|
||||||
- [ ] Phase 4: API Layer
|
- [x] Phase 4: API Layer ✅ **Completed November 28, 2025**
|
||||||
- [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
|
|
||||||
- [ ] Phase 5: Frontend
|
- [ ] Phase 5: Frontend
|
||||||
- [ ] Task 5.1: Update Frontend JavaScript
|
- [ ] Task 5.1: Update Frontend JavaScript
|
||||||
- [ ] Task 5.2: Update WebSocket Events
|
- [ ] 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
|
This module provides utilities for template rendering with common context
|
||||||
and helper functions.
|
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 pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Configure templates directory
|
# Configure templates directory
|
||||||
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
|
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
@ -94,3 +106,112 @@ def list_available_templates() -> list[str]:
|
|||||||
for f in TEMPLATES_DIR.glob("*.html")
|
for f in TEMPLATES_DIR.glob("*.html")
|
||||||
if f.is_file()
|
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.
|
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
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.utils.template_helpers import (
|
from src.server.utils.template_helpers import (
|
||||||
|
filter_series_by_missing_episodes,
|
||||||
get_base_context,
|
get_base_context,
|
||||||
|
get_series_by_key,
|
||||||
list_available_templates,
|
list_available_templates,
|
||||||
|
prepare_series_context,
|
||||||
validate_template_exists,
|
validate_template_exists,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -84,3 +88,156 @@ class TestTemplateHelpers:
|
|||||||
"""Test that all required templates exist."""
|
"""Test that all required templates exist."""
|
||||||
assert validate_template_exists(template_name), \
|
assert validate_template_exists(template_name), \
|
||||||
f"Required template {template_name} does not exist"
|
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