Fix A() component

Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Fixes:
- Silent fallback (typos like `"ad_puchase"` silently became broken links) → now raises `NoReverseMatch` at render time
- `type(url) is str` gate → removed (implicit dual-mode eliminated entirely)
- Callable parameter (`url: Callable`) dead code → removed
- Implicit dual-mode (`url="name"` vs `url=reverse("name")`) → `url_name` vs `href` are now mutually exclusive params
- Inconsistent type annotation mixing `Callable` with string default → cleaned up
- Added `ValueError` when both `url_name` and `href` are provided
- Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`)
This commit is contained in:
2026-05-12 09:01:05 +02:00
parent 8c3e819a5f
commit 656a96f55c
9 changed files with 50 additions and 40 deletions
+7 -9
View File
@@ -15,17 +15,15 @@
- `games/templatetags/randomid.py` uses the same hash-based approach - `games/templatetags/randomid.py` uses the same hash-based approach
- Fixes: caching (Popover output now cacheable), page consistency, thread safety - Fixes: caching (Popover output now cacheable), page consistency, thread safety
### 1. Inconsistent return types (completed) ### Inconsistent return types
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`. All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
### 2. Fragile A() URL resolution ### Fragile A() URL resolution
Tries `reverse(url)` first, then falls back to literal string. Uses `type(url) is str` Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
instead of `isinstance()`. Intentional but error-prone — a string matching a URL name
will be reversed, while one that doesn't pass through as-is.
**Fix**: Add explicit parameter like `url_name="view_game"` vs `href="/literal/path"`. ## Incomplete
### 3. Toast XSS vulnerability ### Toast XSS vulnerability
Custom string escaping for Alpine.js interpolation: Custom string escaping for Alpine.js interpolation:
```python ```python
safe_message = message.replace("\\", "\\\\").replace("`", "\\`") safe_message = message.replace("\\", "\\\\").replace("`", "\\`")
@@ -35,13 +33,13 @@ Alpine expression early).
**Fix**: Use proper HTML escaping + JSON serialization for safe template interpolation. **Fix**: Use proper HTML escaping + JSON serialization for safe template interpolation.
### 4. No tests ### No tests
Zero test coverage for the entire component system. Zero test coverage for the entire component system.
**Fix**: Add unit tests for each component function — basic rendering, edge cases, **Fix**: Add unit tests for each component function — basic rendering, edge cases,
and cache hit/miss verification. and cache hit/miss verification.
### 5. Default mutable arguments ### Default mutable arguments
`attributes: list[HTMLAttribute] = []` is a classic Python gotcha (though harmless `attributes: list[HTMLAttribute] = []` is a classic Python gotcha (though harmless
here since the list is only read, never mutated in place). here since the list is only read, never mutated in place).
+18 -21
View File
@@ -1,13 +1,13 @@
import hashlib import hashlib
import json import json
from functools import lru_cache from functools import lru_cache
from typing import Any, Callable from typing import Any
from django.conf import settings from django.conf import settings
from django.template import TemplateDoesNotExist from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate from common.utils import truncate
@@ -129,27 +129,24 @@ def PopoverTruncated(
def A( def A(
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag = [],
url: str | Callable[..., Any] = "", url_name: str | None = None,
href: str | None = None,
) -> SafeText: ) -> SafeText:
""" """
Returns the HTML tag "a". Returns an anchor <a> tag.
"url" can either be:
- URL (string) Accepts one of two mutually-exclusive URL specifications:
- path name passed to reverse() (string) - url_name: URL pattern name, resolved via reverse()
- function - href: Literal path string passed through as-is
""" """
if url_name is not None and href is not None:
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
additional_attributes = [] additional_attributes = []
if url: if url_name is not None:
if type(url) is str: additional_attributes = [("href", reverse(url_name))]
try: elif href is not None:
url_result = reverse(url) additional_attributes = [("href", href)]
except NoReverseMatch:
url_result = url
elif callable(url):
url_result = url()
else:
raise TypeError("'url' is neither str nor function.")
additional_attributes = [("href", url_result)]
return Component( return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children tag_name="a", attributes=attributes + additional_attributes, children=children
) )
@@ -254,7 +251,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
), ),
], ],
) )
return A(url=link, children=[a_content]) return A(href=link, children=[a_content])
def NameWithIcon( def NameWithIcon(
@@ -299,7 +296,7 @@ def NameWithIcon(
return ( return (
A( A(
url=link, href=link,
children=[content], children=[content],
) )
if create_link if create_link
+1 -1
View File
@@ -36,7 +36,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add device"), url="add_device"), "header_action": A([], Button([], "Add device"), url_name="add_device"),
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
+3 -3
View File
@@ -89,7 +89,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
) )
] ]
), ),
A([], Button([], "Add game"), url="add_game"), A([], Button([], "Add game"), url_name="add_game"),
], ],
attributes=[("class", "flex justify-between")], attributes=[("class", "flex justify-between")],
), ),
@@ -274,7 +274,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"header_action": Div( "header_action": Div(
children=[ children=[
A( A(
url="add_session", url_name="add_session",
children=Button( children=Button(
icon=True, icon=True,
size="xs", size="xs",
@@ -282,7 +282,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
), ),
), ),
A( A(
url=reverse( href=reverse(
"list_sessions_start_session_from_session", "list_sessions_start_session_from_session",
args=[last_session.pk], args=[last_session.pk],
), ),
+1 -1
View File
@@ -37,7 +37,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add platform"), url="add_platform"), "header_action": A([], Button([], "Add platform"), url_name="add_platform"),
"columns": [ "columns": [
"Name", "Name",
"Icon", "Icon",
+1 -1
View File
@@ -78,7 +78,7 @@ def create_playevent_tabledata(
for row in row_list for row in row_list
] ]
return { return {
"header_action": A([], Button([], "Add play event"), url="add_playevent"), "header_action": A([], Button([], "Add play event"), url_name="add_playevent"),
"columns": list(filtered_column_list), "columns": list(filtered_column_list),
"rows": filtered_row_list, "rows": filtered_row_list,
} }
+1 -1
View File
@@ -100,7 +100,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add purchase"), url="add_purchase"), "header_action": A([], Button([], "Add purchase"), url_name="add_purchase"),
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
+2 -2
View File
@@ -81,7 +81,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
Div( Div(
children=[ children=[
A( A(
url="add_session", url_name="add_session",
children=Button( children=Button(
icon=True, icon=True,
size="xs", size="xs",
@@ -89,7 +89,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
), ),
), ),
A( A(
url=reverse( href=reverse(
"list_sessions_start_session_from_session", "list_sessions_start_session_from_session",
args=[last_session.pk], args=[last_session.pk],
), ),
+16 -1
View File
@@ -376,9 +376,24 @@ class ComponentReturnTypeTest(unittest.TestCase):
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_a_literal_href(self): def test_a_literal_href(self):
result = components.A([], "x", url="/literal/path") result = components.A([], "x", href="/literal/path")
self.assertIn('href="/literal/path"', result) self.assertIn('href="/literal/path"', result)
def test_a_url_name_reversed(self):
from unittest.mock import patch
with patch("common.components.reverse", return_value="/resolved/url"):
result = components.A([], "link", url_name="some_name")
self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self):
result = components.A([], "link")
self.assertIn('<a>link</a>', result)
self.assertNotIn('href=', result)
def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError):
components.A(href="/path", url_name="some_name")
def test_button_returns_safe_text(self): def test_button_returns_safe_text(self):
result = components.Button([], "click") result = components.Button([], "click")
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)