Skip to content

Commit c5bc2cc

Browse files
feat(spa): read --dar-primary from AdminSite.site_primary_color (#631)
Mirrors how `BRAND_TITLE` / `BRAND_LOGO_URL` already fall through to `AdminSite.site_header` / `site_logo`: a consumer with a custom `AdminSite` subclass can now brand the whole admin (legacy + SPA) from one place — set `site_primary_color` on the AdminSite, skip the `settings.DJANGO_ADMIN_REACT["PRIMARY_COLOR"]` dance. Resolution order (per-deployment override → structural default → built-in fallback): 1. `DJANGO_ADMIN_REACT["PRIMARY_COLOR"]` setting, when set. 2. `admin_site.site_primary_color` attr. 3. `DEFAULT_PRIMARY_COLOR` (= `#2563eb`, unchanged). Every layer runs through the existing hex-regex gate, so CSS injection is impossible at any source — same trust boundary as before. DEFAULTS["PRIMARY_COLOR"] flipped from `"#2563eb"` to `None` so the resolver can distinguish "consumer set this" from "default is in effect"; the actual fallback hex now lives in a re-exported `DEFAULT_PRIMARY_COLOR` constant. Locked by three new tests in `test_spa_index.py`: - AdminSite attr is honoured when no setting is configured. - Explicit setting wins over the AdminSite attr. - Non-hex AdminSite attr can't inject CSS — falls through to default. Closes #631. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 949aaa9 commit c5bc2cc

3 files changed

Lines changed: 97 additions & 10 deletions

File tree

django_admin_react/conf.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020

2121
from django.conf import settings as django_settings
2222

23+
# Built-in fallback for the ``--dar-primary`` accent color when the
24+
# consumer hasn't set ``PRIMARY_COLOR`` AND their ``AdminSite`` has no
25+
# ``site_primary_color`` attribute. Re-exported so ``views.py`` can
26+
# pick up the same constant instead of stringifying its own.
27+
DEFAULT_PRIMARY_COLOR = "#2563eb"
28+
2329
DEFAULTS: dict[str, Any] = {
2430
"ADMIN_SITE": "django.contrib.admin.site",
2531
# The list page size derives from the model's
@@ -59,9 +65,16 @@
5965
# ``--dar-primary`` CSS variable so a consumer can brand the admin with
6066
# no React rebuild. Must be a hex color (``#rgb`` / ``#rgba`` /
6167
# ``#rrggbb`` / ``#rrggbbaa``); anything else is rejected at render and
62-
# falls back to this default, since the value is written into a
63-
# ``<style>`` block and must not be able to inject CSS.
64-
"PRIMARY_COLOR": "#2563eb",
68+
# falls back to ``DEFAULT_PRIMARY_COLOR`` below, since the value is
69+
# written into a ``<style>`` block and must not be able to inject CSS.
70+
#
71+
# ``None`` (default) means "consumer didn't explicitly set this" — the
72+
# SPA reads ``site_primary_color`` off the configured ``AdminSite``
73+
# next, then falls back to ``DEFAULT_PRIMARY_COLOR``. Mirrors
74+
# ``BRAND_TITLE`` / ``BRAND_LOGO_URL``: setting wins as the
75+
# per-deployment override, AdminSite attr is the structural default,
76+
# built-in default last (#631).
77+
"PRIMARY_COLOR": None,
6578
# ``REACT_LOGIN`` — React-rendered login is the **default** so the
6679
# SPA fully replaces the Django admin URL surface end-to-end (owner
6780
# directive 2026-05-28). ``SpaIndexView`` serves the React shell to
@@ -149,7 +162,7 @@ class _PackageSettings:
149162
ENABLE_PROFILING: bool = DEFAULTS["ENABLE_PROFILING"]
150163
BRAND_TITLE: str | None = DEFAULTS["BRAND_TITLE"]
151164
BRAND_LOGO_URL: str | None = DEFAULTS["BRAND_LOGO_URL"]
152-
PRIMARY_COLOR: str = DEFAULTS["PRIMARY_COLOR"]
165+
PRIMARY_COLOR: str | None = DEFAULTS["PRIMARY_COLOR"]
153166
REACT_LOGIN: bool = DEFAULTS["REACT_LOGIN"]
154167
API_URL_PREFIX: str | None = DEFAULTS["API_URL_PREFIX"]
155168
LEGACY_ADMIN_URL_PREFIX: str | None = DEFAULTS["LEGACY_ADMIN_URL_PREFIX"]

django_admin_react/views.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
115115
"brand_title": _resolve_brand_title(admin_site),
116116
"tab_title": _resolve_tab_title(admin_site),
117117
"brand_logo_url": _resolve_brand_logo(admin_site),
118-
"primary_color": _resolve_primary_color(),
118+
"primary_color": _resolve_primary_color(admin_site),
119119
"initial_theme": _resolve_initial_theme(request),
120120
},
121121
)
@@ -315,19 +315,34 @@ def _resolve_brand_logo(admin_site: Any) -> str | None:
315315
_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
316316

317317

318-
def _resolve_primary_color() -> str:
318+
def _resolve_primary_color(admin_site: Any) -> str:
319319
"""The validated accent color injected as ``--dar-primary``.
320320
321+
Resolution order — matches ``BRAND_TITLE`` / ``BRAND_LOGO_URL`` so a
322+
consumer with a custom ``AdminSite`` can brand the whole admin
323+
(legacy + SPA) from one place without a settings entry (#631):
324+
325+
1. ``DJANGO_ADMIN_REACT["PRIMARY_COLOR"]`` — explicit per-deployment
326+
override.
327+
2. ``admin_site.site_primary_color`` — convention for shops with a
328+
custom ``AdminSite`` subclass (``site_header`` / ``site_logo``
329+
pattern). Stock Django has no such attribute; consumers add it.
330+
3. The package default (``#2563eb``).
331+
321332
The value lands inside a ``<style>`` block in the SPA template, where
322333
HTML-escaping does NOT prevent CSS injection (``}``/``;`` aren't
323-
HTML-special). So only a strict hex color is allowed; anything else
324-
(or a non-string) falls back to the default. This is a trust boundary
325-
even though the value comes from the consumer's own settings.
334+
HTML-special). So only a strict hex color is allowed at every layer;
335+
anything else (or a non-string) falls through to the next step and
336+
eventually the default. This is a trust boundary even though the
337+
value comes from the consumer's own settings / site attribute.
326338
"""
327339
configured = dar_conf.PRIMARY_COLOR
328340
if isinstance(configured, str) and _HEX_COLOR_RE.match(configured.strip()):
329341
return configured.strip()
330-
return dar_conf.DEFAULTS["PRIMARY_COLOR"]
342+
site_color = getattr(admin_site, "site_primary_color", None)
343+
if isinstance(site_color, str) and _HEX_COLOR_RE.match(site_color.strip()):
344+
return site_color.strip()
345+
return dar_conf.DEFAULT_PRIMARY_COLOR
331346

332347

333348
def _resolve_initial_theme(request: HttpRequest) -> str | None:

tests/test_spa_index.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,65 @@ def test_primary_color_non_hex_value_cannot_inject_css(superuser_client: Client)
508508
assert "--dar-primary: #2563eb;" in html
509509

510510

511+
@pytest.mark.django_db
512+
def test_primary_color_falls_back_to_admin_site_attr(superuser_client: Client) -> None:
513+
"""When `PRIMARY_COLOR` is unset, the SPA reads `site_primary_color`
514+
off the AdminSite — mirrors `site_header` / `site_logo` so a consumer
515+
with a custom AdminSite can brand from one place (#631)."""
516+
with override_settings(DJANGO_ADMIN_REACT={}):
517+
_reload_conf()
518+
original = getattr(default_admin_site, "site_primary_color", None)
519+
default_admin_site.site_primary_color = "#10b981" # emerald
520+
try:
521+
html = superuser_client.get(ROOT_URL).content.decode("utf-8")
522+
assert "--dar-primary: #10b981;" in html
523+
finally:
524+
if original is None:
525+
del default_admin_site.site_primary_color
526+
else:
527+
default_admin_site.site_primary_color = original
528+
529+
530+
@pytest.mark.django_db
531+
def test_primary_color_setting_wins_over_admin_site_attr(superuser_client: Client) -> None:
532+
"""Explicit `PRIMARY_COLOR` setting overrides `site_primary_color` —
533+
the setting is the per-deployment override, the attr is the
534+
structural default. Same precedence as `BRAND_TITLE` (#631)."""
535+
with override_settings(DJANGO_ADMIN_REACT={"PRIMARY_COLOR": "#ff8800"}):
536+
_reload_conf()
537+
original = getattr(default_admin_site, "site_primary_color", None)
538+
default_admin_site.site_primary_color = "#10b981"
539+
try:
540+
html = superuser_client.get(ROOT_URL).content.decode("utf-8")
541+
assert "--dar-primary: #ff8800;" in html
542+
assert "#10b981" not in html
543+
finally:
544+
if original is None:
545+
del default_admin_site.site_primary_color
546+
else:
547+
default_admin_site.site_primary_color = original
548+
549+
550+
@pytest.mark.django_db
551+
def test_primary_color_admin_site_non_hex_is_rejected(superuser_client: Client) -> None:
552+
"""A non-hex `site_primary_color` on the AdminSite still can't inject
553+
CSS — same regex gate as `PRIMARY_COLOR`. Falls through to the
554+
default (#437 / #631)."""
555+
with override_settings(DJANGO_ADMIN_REACT={}):
556+
_reload_conf()
557+
original = getattr(default_admin_site, "site_primary_color", None)
558+
default_admin_site.site_primary_color = "red; } body { display: none } :root {"
559+
try:
560+
html = superuser_client.get(ROOT_URL).content.decode("utf-8")
561+
assert "display: none" not in html
562+
assert "--dar-primary: #2563eb;" in html
563+
finally:
564+
if original is None:
565+
del default_admin_site.site_primary_color
566+
else:
567+
default_admin_site.site_primary_color = original
568+
569+
511570
@pytest.mark.django_db
512571
def test_brand_logo_url_renders_favicon_and_meta(superuser_client: Client) -> None:
513572
"""`BRAND_LOGO_URL` populates both the `<link rel="icon">` and the

0 commit comments

Comments
 (0)