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
- 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()`.
### 2. Fragile A() URL resolution
Tries `reverse(url)` first, then falls back to literal string. Uses `type(url) is str`
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.
### Fragile A() URL resolution
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()`).
**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:
```python
safe_message = message.replace("\\", "\\\\").replace("`", "\\`")
@@ -35,13 +33,13 @@ Alpine expression early).
**Fix**: Use proper HTML escaping + JSON serialization for safe template interpolation.
### 4. No tests
### No tests
Zero test coverage for the entire component system.
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
and cache hit/miss verification.
### 5. Default mutable arguments
### Default mutable arguments
`attributes: list[HTMLAttribute] = []` is a classic Python gotcha (though harmless
here since the list is only read, never mutated in place).
+18 -21
View File
@@ -1,13 +1,13 @@
import hashlib
import json
from functools import lru_cache
from typing import Any, Callable
from typing import Any
from django.conf import settings
from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
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 common.utils import truncate
@@ -129,27 +129,24 @@ def PopoverTruncated(
def A(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
url: str | Callable[..., Any] = "",
url_name: str | None = None,
href: str | None = None,
) -> SafeText:
"""
Returns the HTML tag "a".
"url" can either be:
- URL (string)
- path name passed to reverse() (string)
- function
Returns an anchor <a> tag.
Accepts one of two mutually-exclusive URL specifications:
- url_name: URL pattern name, resolved via reverse()
- 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 = []
if url:
if type(url) is str:
try:
url_result = reverse(url)
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)]
if url_name is not None:
additional_attributes = [("href", reverse(url_name))]
elif href is not None:
additional_attributes = [("href", href)]
return Component(
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(
@@ -299,7 +296,7 @@ def NameWithIcon(
return (
A(
url=link,
href=link,
children=[content],
)
if create_link