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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user