djangoplay-web / Apps / DjangoPlay — frontend App
DocsDjangoPlay WebAppsDjangoPlay — frontend App

DjangoPlay — frontend App

frontend (AppConfig.name = "frontend", no custom ready(), no models, no views, no urls.py, no admin.py) is DjangoPlay's shared presentation layer — the single place every other app's HTML, CSS, JS,...

22 min readApplies to v1.2.2
On this page ▾
  1. 1. Summary
  2. 2. Architecture
  3. 2.1 Directory layout (as extracted)
  4. 2.2 How Django finds these files
  5. 2.3 The four global context processors
  6. 2.4 Template composition — base/base.html as the shell
  7. 2.5 Template Registry — the indirection layer other apps depend on
  8. 3. Templatetags (the only executable logic in this app)
  9. 3.1 templatetags/actions.py
  10. 3.2 templatetags/filter_config_tags.py
  11. 3.3 templatetags/privacy_filters.py
  12. 4. Static assets & the manual build pipeline
  13. 4.1 No JS framework, no bundler-as-a-tool — a hand-written bash pipeline
  14. 4.2 Vendor assets are deliberately excluded from the bundles
  15. 4.3 CSS architecture
  16. 4.4 JS architecture (source files, pre-bundle)
  17. 5. Third-party vendor libraries (static/vendor/)
  18. 6. Integration with mailer (email templates)
  19. 7. Security-relevant observations
  20. 7.1 Client-side auth token stored in localStorage
  21. 7.2 Client-side permission gating is cosmetic, not enforcement
  22. 7.3 mask_email fully hides the domain, not just part of it
  23. 7.4 Admin-namespace detection for JS bundle loading is a string match on request.path
  24. 8. Known Gaps / Things Worth Confirming With the Team
  25. 9. Integration Points (cross-app)
  26. 10. Quick Reference — Common Tasks

Doc generated by direct inspection of every file in webapp/frontend/ (Python, HTML, CSS, and the minify.sh asset pipeline — CSS/JS/image binaries were listed and spot-checked, not fully read line-by-line), plus its wiring points: paystream/app_settings/templates.py, paystream/app_settings/static_media.py, paystream/app_settings/cdn.py, paystream/services/context_processors/*, utilities/context_processors/report_bug.py, utilities/constants/template_registry.py, utilities/admin/base_admin_page.py, and mailer/engine/*.

Revision note: this pass adds Cloudflare R2/CDN asset delivery (dist/, assets/, vendor/ now optionally served from R2 instead of the Django box) and supersedes the prior revision's description of the static pipeline as purely local. See docs/releases/djangoplay/frontend-r2-requirements.md for the original design doc and decision log, and docs/system/asset-builder-cloudflare-r2.md for the brand-asset generator this ships alongside.

1. Summary

  • It is registered as the sole entry in both STATICFILES_DIRS and TEMPLATES[0]['DIRS'] (paystream/app_settings/static_media.py, paystream/app_settings/templates.py) — the comment in static_media.py is explicit: "Only frontend/static should be used now".
  • Every other app's views (users, apidocs, helpdesk's issue-tracker integration, teamcentral, industries, locations, etc.) render templates that physically live under frontend/templates/, resolved indirectly through utilities.constants.template_registry.TemplateRegistry rather than hard-coded template paths scattered per-app.
  • It carries three small template-tag modules (actions.py, filter_config_tags.py, privacy_filters.py) that are the only executable Python in the app — everything else is markup, CSS, and hand-written vanilla JS (no frontend build framework — no React/Vue/webpack; a bespoke bash script (packaging/minify.sh) concatenates and minifies plain <script>-tag JS with terser/cleancss).
  • It provides the BaseAdminPage-driven custom Django-admin skin (templates/admin/...) used across nearly every app's ModelAdmin, replacing Django's default admin templates wholesale.
  • It hosts the HTML/text email templates consumed by mailer via TemplateRegistry's email-prefix constants, and the issue-tracker UI templates (templates/integrations/issuetracker/) consumed by helpdesk's genericissuetracker integration.

In short: frontend is DjangoPlay's "static/templates app" in the Django sense (comparable to a project-level templates/ + static/ directory, but formalized as its own installed app) plus a thin templatetag layer and a manual JS/CSS bundler — it owns presentation, not business logic, and is depended on by essentially every other app that renders HTML.


2. Architecture

2.1 Directory layout (as extracted)

plaintext
frontend/
├── __init__.py
├── apps.py                     # trivial AppConfig — no models, no ready()
├── build/
│   └── minify.sh                 # manual CSS/JS bundling (cleancss + terser)
├── static/
│   ├── assets/                  # source CSS + JS (pre-bundle)
│   │   ├── css/{admin,public}/  # numbered ITCSS-style partials (01_tokens.css … 09_utilities.css)
│   │   └── js/{core,admin,api,components,pages,shared}/
│   ├── dist/                    # minify.sh OUTPUT — bundle.min.{css,js}, bundle.admin.min.js, bundle.api.min.js, favicon/logo images
│   └── vendor/                  # third-party libs checked into the repo (see §5)
├── templates/
│   ├── account/                 # allauth-style auth pages + HTML/text email templates
│   ├── admin/                   # full custom Django-admin template override tree
│   ├── apidocs/                 # Swagger / ReDoc / API-stats pages
│   ├── base/                    # base.html shell + header/footer/scripts/styles partials
│   ├── components/              # small standalone includes (bug report modal)
│   ├── errors/                  # single generic error page reused for 401/403/404
│   ├── integrations/issuetracker/  # full UI for the genericissuetracker integration
│   └── support/                 # empty directory — see §8 gaps
└── templatetags/
    ├── actions.py
    ├── filter_config_tags.py
    └── privacy_filters.py

2.2 How Django finds these files

paystream/app_settings/templates.py:

python
TEMPLATES = [{
    'BACKEND': 'django.template.backends.django.DjangoTemplates',
    'DIRS': [BASE_DIR / 'frontend' / 'templates'],
    'OPTIONS': {
        'context_processors': [
            'django.template.context_processors.debug',
            'django.template.context_processors.request',
            'django.contrib.auth.context_processors.auth',
            'django.contrib.messages.context_processors.messages',
            'utilities.context_processors.report_bug.report_bug_context',
            'paystream.services.context_processors.site_context.site_context',
            'paystream.services.context_processors.site_context.global_urls',
            'paystream.services.context_processors.site_context.admin_urls',
            'paystream.services.context_processors.app_version.app_version',
        ],
        'loaders': [
            ('django.template.loaders.cached.Loader', [
                'django.template.loaders.filesystem.Loader',
                'django.template.loaders.app_directories.Loader',
            ]),
        ],
    },
}]

Because APP_DIRS isn't set and a filesystem.Loader pointed at frontend/templates is listed ahead of app_directories.Loader (wrapped in the caching loader), frontend/templates is checked first for every template lookup in the project, including Django's own admin/*.html names — this is how the app is able to fully replace Django admin's stock templates (admin/base_site.html, admin/change_form.html, admin/submit_line.html) just by shipping files at the matching relative paths.

paystream/app_settings/static_media.py does the equivalent for static assets:

python
STATICFILES_DIRS = [BASE_DIR / 'frontend' / 'static', admin_static.__path__[0]]
STATICFILES_FINDERS = ['django.contrib.staticfiles.finders.FileSystemFinder']
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'

FileSystemFinder-only (no AppDirectoriesFinder) means no other app can ship its own static/ directory and have it picked up — frontend/static plus Django's own admin static path are the only two static roots in the project. ManifestStaticFilesStorage means production collectstatic produces hashed filenames for cache-busting.

2.3 The four global context processors

Every template rendered anywhere in the project (not just frontend's own) gets these variables for free, which is why templates across many apps can reference things like SITE_URL, HOME_URL, or schema_url without each view passing them explicitly:

Processor Source Injects
report_bug_context utilities.context_processors.report_bug show_bug_report (bool) — gates the bug-report widget (see §2.4.4) based on REPORT_BUG_ENABLED / REPORT_BUG_ENABLED_ON_URLS settings and whether the current view is in the Django-admin namespace
site_context paystream.services.context_processors.site_context HOME_URL (resolved from the request's subdomain via a hardcoded SUBDOMAIN_HOME_MAP = {"issues": "issues_ui:list", "docs": "docs", "console": "console_dashboard"}), CURRENT_SUBDOMAIN, SITE_URL, SITE_NAME, WEBSITE_URL, AI_ENABLED, and one <NAME>_URL variable per subdomain (via utilities.admin.url_utils.get_all_subdomains())
global_urls same module schema_url (reverse of apidocs:schema, empty string if unresolvable), TURNSTILE_SITE_KEY (Cloudflare Turnstile site key, used in base/scripts.html)
admin_urls same module console_dashboard_url
app_version paystream.services.context_processors.app_version APP_VERSION (from Django settings)

2.4 Template composition — base/base.html as the shell

Nearly every page-rendering template in the project ultimately extends base/base.html (directly, or via admin/base_site.html → base/base.html). Its structure:

  1. <head>: favicon links, {% include "base/styles.html" %} (vendor CSS with CDN onerror fallbacks + the built bundle.min.css), the HTMX <script> tag (also with a CDN fallback), then a {% block extra_head %}.
  2. <body> carries data-authenticated, data-site-url, and conditionally data-schema-url attributes — these are read by the vanilla-JS layer (core/app.js, core/api.js) rather than passed through Django template logic at runtime.
  3. Body content: base/header.html → {% block messages %} (Django messages via base/django_messages.html) → {% block content_wrapper %} (wraps {% block content %} in a .page-center div) → base/footer.html.
  4. Conditionally authenticated-only includes: base/support_drawer.html, components/bug_report_modal.html, and (only if AI_ENABLED) base/chat_widget.html.
  5. {% include "base/scripts.html" %} at the very end (vendor JS with CDN fallbacks, the app/admin/api bundles conditionally loaded by path, Cloudflare Turnstile) followed by {% block extra_js %} and base/partials/offline.html (the offline-monitor UI).

2.4.1 Conditional script/style loading is path-based, not template-based

base/scripts.html and base/styles.html decide whether to load Swagger/ReDoc vendor bundles by inspecting request.resolver_match.url_name (== 'swagger-ui' / == 'redoc'), and whether to load the admin JS bundle by checking '/admin/' in request.path or '/console/' in request.path as a raw string match — not Django's app namespace or a {% block %} override. This is a global include shared by literally every page, so any future URL containing the literal substring /admin/ or /console/ (even outside the real Django-admin namespace) would also trigger the admin JS bundle to load.

2.4.2 Admin skin: admin/base_site.html → admin/layouts/*

admin/base_site.html extends base/base.html, adds the admin-page body class, and renders admin/components/header.html (app label/title breadcrumb) inside a {% block content %}. Three layout templates then extend admin/base_site.html for the three page archetypes used throughout the admin:

  • admin/layouts/changelist.html — extends admin/common/changelist.html; renders the Select2-styled filter bar (see §2.4.3), search box, and the changelist table via admin/components/table.html, with a data-features="select2" body attribute that the JS engine (admin/engine/*, §4) reads to know which enhancements to bootstrap.
  • admin/layouts/form.html — the add/change form: tabbed layout driven entirely by a form_layout context variable (a list of {id, label, groups: [{fields: [...]}]} dicts) supplied by BaseAdminPage.get_form_fields()/the per-app form_layouts config (owned by utilities.admin, not frontend); conditionally adds Audit and History tabs (show_audit / show_history) rendering admin/components/audit_section.html and admin/components/history_section.html.
  • admin/layouts/page.html — a generic "single app landing page" layout (title + optional app_icon + page_actions/page_toolbar/page_content blocks) used for non-model admin pages (e.g. admin/pages/single_app.html).

Several apps override the changelist further under admin/custom/<app>/changelist.html (apidocs, audit, entities, helpdesk, industry, locations (+ a global_region sub-variant), teamcentral, users) — these are app-specific tweaks layered on top of the shared admin/layouts/changelist.html.

2.4.3 AJAX-driven filter bar (admin/common/filters.html)

Documented in-file: any ModelAdmin (in practice, any subclass of utilities.admin.base_admin_page.BaseAdminPage) can set a filter_config dict keyed by parameter_name, each entry declaring "type": "ajax"|"static", "ajax_url", and optionally "parent_param" for cascading filters (country → region → subregion → city, etc.). BaseAdminPage.get_filter_config() / changelist_view() inject this into the template context automatically. frontend/templatetags/filter_config_tags.get_item is the only templatetag this relies on — a null-safe dict lookup so a missing key degrades to {} rather than raising in the template. Django's native filter widgets are still rendered (inside a visually hidden .django-filter-data div) purely so their state/links remain functional; the visible control is a Select2 dropdown populated by JS.

2.4.4 Support/report-bug widgets are global but permission-gated

base/support_drawer.html and components/bug_report_modal.html are included on every authenticated page render (from base.html, unconditional on auth) but report_bug_context further gates visibility of the bug-report entry point specifically (show_bug_report) based on an allow-list of URL names or being inside the admin app namespace — i.e., the drawer markup always ships to authenticated users, but the bug-report trigger is conditionally rendered inside it depending on where they are.

2.5 Template Registry — the indirection layer other apps depend on

utilities/constants/template_registry.py (TemplateRegistry, not part of frontend itself, but the single consumer-facing map to nearly all of frontend/templates/) is how other apps resolve which frontend template to render, instead of hardcoding paths:

python
CONSOLE_DASHBOARD = "account/site_pages/dashboard.html"
CONSOLE_SIGNIN_FORM = "account/site_pages/signin.html"
APIDOCS_SWAGGER = "apidocs/swagger.html"
ISSUES_LIST = "integrations/issuetracker/issues/list.html"
CHANGE_FORM_TEMPLATE = "admin/layouts/form.html"
CHANGELIST_TEMPLATE = "admin/pages/changelist.html"
ACCOUNT_401 = ACCOUNT_403 = ACCOUNT_404 = "errors/base.html"   # same file, three error types
# ...and email-template prefixes, see §6

One dynamic exception: TemplateRegistry.get_api_stats_template(user) picks between APIDOCS_STATS_PUBLIC and APIDOCS_STATS_PERSONAL based on user.has_perm("policyengine.view_apidocs_stats") — this is the one spot template selection logic (not just naming) leaks into the registry, and it's a direct policyengine dependency living in utilities.

The 401/403/404 case is notable: all three error types render the exact same errors/base.html file, differentiated purely by context variables (error_title, error_message, error_icon, error_image, error_actions, custom_err_page, app_display_name) passed in by the calling view (e.g. users.views.ui.errors.custom_403, used by BaseAdminPage.add_view() to reject unauthorized add attempts — see the audit app doc §7 for another caller of the same helper).


3. Templatetags (the only executable logic in this app)

3.1 templatetags/actions.py

Tag/filter Purpose
get_action_name(cl, action) Wraps Django admin's ChangeList.get_action_name, with a fallback to short_description/str() on failure — used in admin/actions.html (not shown above but referenced from admin/layouts/changelist.html).
dict_get(d, key) Null-safe dict lookup, empty string default.
region_country_id(region_id) Cross-app query from a templatetag: imports locations.models.CustomRegion directly and does a select_related('country') DB lookup to resolve a region's parent country ID. This is a live database call triggered from template rendering — a locations dependency baked directly into frontend.
field_groups() (simple_tag) Returns a hardcoded FIELD_GROUPS dict — basic, organization, employment, personal, metadata — whose field names (sso_provider, sso_id, employment_status, employee_type, hire_date, approval_limit, bank_details, is_verified, …) are unmistakably the users app's User model fields. This is used by admin/components/form_fields.html to group fields into tabs for what is presumably the Users admin form — a hardcoded, users-specific constant living inside a generic app's templatetag module (see §8 gaps).
get_item(dictionary, key) Duplicate-purpose of filter_config_tags.get_item but returns [] (list default) instead of {} — used for a different context shape than the filter config one.
get_field(form, name) form[name] accessor, used heavily in admin/layouts/form.html to dynamically pull a form field by name inside nested {% for %} loops over form_layout.
get_attr(obj, attr) getattr(obj, attr, ""), used to render readonly-field values in the admin form layout.

3.2 templatetags/filter_config_tags.py

Single filter, get_item, purpose-built (per its own header comment) for admin/common/filters.html's filter_config|get_item:param lookup (see §2.4.3). Deliberately returns {} (not None) on a miss so chained attribute access (cfg.type) degrades gracefully with Django's default filter rather than throwing.

3.3 templatetags/privacy_filters.py

Filter Behavior
mask_email(email) j***@*** style masking — keeps first local-part character, masks the rest, and always replaces the domain with *** (i.e. the domain is never shown, not even partially).
display_name(email) Returns the local-part of an email, or "Anonymous" if none.
avatar_initial(email) First character, uppercased, or "?".

Used in exactly one place found in this pass: templates/integrations/issuetracker/issues/partials/timeline_item.html — i.e. this is specifically for masking user identity in the issue-tracker UI, which (per the helpdesk/genericissuetracker integration) can plausibly be exposed to a wider or less-trusted audience than the internal admin.


4. Static assets & the manual build pipeline

4.1 No JS framework, no bundler-as-a-tool — a hand-written bash pipeline

frontend/packaging/minify.sh is a plain shell script (not a Django management command, not npm run build) that:

  1. Validates vendor assets exist (jquery, select2, bootstrap, chartjs, redoc, swagger — see §5) — warns (doesn't fail) if missing.
  2. CSS: concatenates static/assets/css/admin/admin.css (mandatory — exit 1 if missing) and static/assets/css/public/public.css (optional) via cleancss --inline=all into static/dist/css/bundle.min.css.
  3. Frontend JS bundle (bundle.min.js): a fixed, explicit ordered list of ~15 files under core/, shared/, api/, components/, pages/ — run through terser --compress --mangle. Missing files are skipped with a warning rather than failing the build.
  4. Admin JS bundle (bundle.admin.min.js): a strictly ordered list, with the ordering constraint spelled out in comments — admin/engine/context.js and admin/engine/registry.js must load first (they define window.AdminRegistry), feature files load in the middle (window.initAllSelect2, window.initAllFilters, etc.), and admin/engine/admin-engine.js must load last (it fires the registry on DOMContentLoaded). The comment explicitly warns against double-loading these engine files as standalone <script> tags in templates, since that re-triggers Select2 initialization and produces a "destroy is not a function" crash.
  5. API JS bundle (bundle.api.min.js): Swagger/ReDoc helpers, session handling, API-stats charting.
  6. A large commented-out earlier version of the same script is left at the bottom of the file (dead code — mirrors a pattern also seen in audit/apps.py per the audit doc).

4.2 Vendor assets are deliberately excluded from the bundles

Per minify.sh's own header comment, vendor libraries (jQuery, Select2, Bootstrap, Chart.js, ReDoc, Swagger UI) live under static/vendor/ and are loaded as separate <script>/<link> tags directly from templates (base/scripts.html, base/styles.html), each with an onerror fallback to a public CDN (jsDelivr / cdnjs / unpkg). Stated rationale: avoids re-bundling third-party code, simplifies CDN fallback, improves browser caching, eases offline dev, and avoids minifier corruption of vendor code.

4.3 CSS architecture

static/assets/css/admin/ follows a numbered ITCSS-ish convention: 01_tokens.css → 02_base.css → 03_layout.css → 04_components/* (alerts, badges, buttons, cards, header, loader, toolbar, and two changelist/changeform "select" variants) → 05_forms/* → 06_tables/* → 07_pages/* → 08_space_themes/* (a themed variant of the changeform/changelist styles) → 09_utilities.css, rolled up by admin.css. static/assets/css/public/ mirrors a similar but flatter structure for non-admin pages, including a dedicated 07_pages/api_login.css / api_stats.css.

4.4 JS architecture (source files, pre-bundle)

  • core/ — foundational, framework-free JS: app.js, api.js (a window.api fetch wrapper — see §7.1 for a security note), auth.js (login form handler), permissions.js (client-side permission-based DOM hiding — see §7.2), toast.js, timezone.js.
  • admin/ — the custom admin-page JS engine: engine/{context.js, registry.js, admin-engine.js} (the bootstrap trio described in §4.1), core.js, form.js, changelist-ui.js, form-options.js, filter-options.js, table-truncate.js, plus per-app feature files: admin/industry/form-options.js, admin/teamcentral/form-options.js, admin/audit/filter-options.js, and admin/invoices/{invoice-form-options.js, invoice-gst.js, invoice-line-items.js, invoice-totals.js}.
  • api/ — session.js (564 lines — the largest single JS file in the app; builds/injects a session-expired modal dynamically, per comments in apidocs/swagger.html/redoc.html), swagger.js, redoc.js, apistats_chart.js.
  • components/ — space.js (+ a stray duplicate space copy.js, see §8), banners.js, django_messages.js, file_upload.js, support.js, support_drawer.js (455 lines), chat_widget.js (233 lines, AI chat UI), reportbug.js.
  • pages/ — login.js, password_reset.js, dashboard.js, modal_signin.js.
  • shared/ — offline-monitor.js.

5. Third-party vendor libraries (static/vendor/)

Checked directly into the repo (not npm/pip-managed): bootstrap, chartjs, fontawesome, fonts, htmx, jquery, redoc, select2, swagger. Every vendor <script>/<link> tag in base/scripts.html / base/styles.html has an inline onerror CDN fallback (jsDelivr for Bootstrap/Select2/ReDoc, cdnjs for Font Awesome, unpkg for HTMX). This means the app can degrade gracefully to public CDNs if the vendored copy is missing or corrupted, but it also means a production deployment with network egress restrictions to those CDNs would only notice vendor-asset breakage as a silent fallback failure, not a hard error.


6. Integration with mailer (email templates)

account/email/*.html (HTML bodies) and account/fallback/*.txt (plaintext bodies + *_subject.txt subject fallbacks) are the templates mailer resolves through TemplateRegistry's prefix constants (EMAIL_SIGNUP_SUCCESS, EMAIL_VERIFICATION_MANUAL, EMAIL_SSO_ONBOARDING, REQUEST_TO_SUPPORT_EMAIL, CONFIRMATION_TO_USER_EMAIL, PASSWORD_RESET_EMAIL). Confirmed via direct references in mailer/engine/engine.py, mailer/engine/templates.py, mailer/flows/bug.py, mailer/flows/password_reset.py, and mailer/flows/support.py, all of which import TemplateRegistry as T from utilities.constants.template_registry. mailer/engine/base.py calls adapter.send_mail(template_prefix, to_email, context) — the "adapter" pattern reads as django-allauth-compatible (the code comment in paystream/app_settings/templates.py notes PASSWORD_RESET_EMAIL's subject-template path is dictated by allauth's own resolution rules, not TemplateRegistry's general convention).

Shared HTML-email partials (account/email/partials/: attachments.html, button.html, info_box.html, info_box_block.html, link_box.html, message_box.html, unsubscribe_footer.html) are composed into the top-level email templates and driven by base/base_email.html — a separate, self-contained shell from base/base.html (email HTML can't rely on the app's CSS bundle or JS at all, so it's inline-styled independently).


7. Security-relevant observations

7.1 Client-side auth token stored in localStorage

core/auth.js's login handler and core/api.js's window.api.headers() store and read a JWT access token via localStorage.getItem('token') / setItem('token', ...), sent as Authorization: Bearer <token>, in addition to cookie-based session auth (credentials: 'include' on every fetch, plus X-CSRFToken). core/permissions.js's loadUser() explicitly tries the JWT path first (/api/v1/users/auth/me/jwt/) and falls back to the session endpoint (/api/v1/users/auth/me/) if no token is present or the JWT call fails. Storing bearer tokens in localStorage (vs. an httpOnly cookie) is a well-known XSS-exposure pattern — any script-injection vulnerability anywhere on the same origin can read localStorage and exfiltrate the token. This is a frontend/users-boundary concern worth flagging to whoever owns the users JWT issuance flow.

7.2 Client-side permission gating is cosmetic, not enforcement

core/permissions.js hides any element carrying a data-perm="<code>" attribute if the loaded user's permissions array (from /api/v1/users/auth/me/) doesn't include that code. This is the concrete implementation of the "for UI, utilities handles it" access model referenced by the project's access-control split (policyengine for webapp/API authorization; this data-perm DOM-hiding mechanism for UI). It is presentation-only — removing a DOM node client-side has no bearing on whether the underlying API call the element would have triggered is actually authorized; that enforcement has to happen server-side (in policyengine or the view itself) regardless of whether the UI shows the control. This isn't a bug — it's a standard "hide, don't rely on hiding" UI pattern — but it's worth stating explicitly in any AI-training-grade doc so the two layers aren't conflated.

7.3 mask_email fully hides the domain, not just part of it

Unlike typical email-masking (j***@e***.com), privacy_filters.mask_email replaces the entire domain with a fixed *** — a stronger masking choice appropriate for a public-facing surface (the issue tracker), consistent with audit's SENSITIVE_KEYS redaction philosophy.

7.4 Admin-namespace detection for JS bundle loading is a string match on request.path

As noted in §2.4.1, '/admin/' in request.path or '/console/' in request.path is a substring check, not a namespace check — low risk in practice (it only controls whether extra admin JS loads, not an authorization decision), but notably looser than the rest of the codebase's pattern of resolving via request.resolver_match.


8. Known Gaps / Things Worth Confirming With the Team

  1. static/dist/js/bundle.support.min.js exists on disk but is produced by nothing. minify.sh only ever writes bundle.min.css, bundle.min.js, bundle.admin.min.js, bundle.api.min.js — there is no DIST_SUPPORT_JS variable or build step for it anywhere in the current or the commented-out legacy version of the script. A repo-wide search found zero template references to bundle.support.min.js either. It's orphaned build output — either a leftover from a build run against an older version of minify.sh, or a manually-created file that was never wired in.
  2. templates/support/ is an empty directory. No files, tracked only as an empty path. Either a placeholder for planned content or a leftover from a refactor (the support bundle above and this empty dir are plausibly related — e.g. a "support ticket UI" that was pulled out).
  3. components/space copy.js — an evident accidental duplicate of components/space.js, committed with a space in the filename. Not referenced by minify.sh's explicit file lists (which name space.js, not the copy) — dead weight, not a functional bug, but worth deleting.
  4. invoices admin JS files are never bundled. static/assets/js/admin/invoices/{invoice-form-options.js, invoice-gst.js, invoice-line-items.js, invoice-totals.js} exist on disk but minify.sh's ADMIN_JS array does not include them (it lists industry, teamcentral, and audit feature files, but not invoices) — meaning, unless something else loads these files independently (no such reference was found in the templates read for this pass), the invoices admin UI's JS enhancements are not shipped in bundle.admin.min.js at all. Worth confirming with whoever owns invoices' admin whether these files are legacy/superseded or a build-script gap that's silently breaking invoice admin form behavior.
  5. FIELD_GROUPS in templatetags/actions.py is a hardcoded, app-specific (users) constant living in a generic, shared templatetag module, with no apparent per-app override mechanism — if any other app needed the same tabbed-field-grouping pattern for its own admin form, there's no indication from this pass of how it would supply its own groups (field_groups() takes no arguments and returns the one fixed dict). Confirm with whoever owns users' admin form (and check the users app doc) whether this is meant to move into a per-model config eventually.
  6. templatetags/actions.py:region_country_id performs a live synchronous DB query (CustomRegion.objects.select_related('country').get(...)) from within template rendering, and imports locations.models directly at call time (not module level, presumably to dodge an app-loading-order circular import). This is a working pattern, not a bug, but it does mean a locations app change to CustomRegion's schema could silently break frontend template rendering wherever this filter is used, and it's a real per-row query cost if used inside a loop over many rows.
  7. Two DB round-trip risks in filter tags are not caching-aware — region_country_id has no caching layer; if used per-row in a large changelist it would generate N queries. Not confirmed either way whether it's actually used in a loop anywhere in the currently-read template set (no direct usage site was found in this pass — it may be dead code; flagged as a gap in coverage rather than a confirmed bug).
  8. minify.sh has a large fully-commented-out legacy copy of itself appended at the bottom of the file (mirrors the same dead-code pattern noted in audit/apps.py in the audit doc) — harmless, but worth a cleanup pass.
  9. Admin-bundle-loading detection via raw path substring matching (§7.4/§2.4.1) rather than namespace/URL-name resolution — low risk, but inconsistent with the rest of the app's pattern (e.g. Swagger/ReDoc detection correctly uses request.resolver_match.url_name).
  10. No frontend/tests.py or test directory was found in this app during this pass — for an app carrying non-trivial cross-app template-tag logic (a live DB query, a hardcoded field-grouping contract other apps' forms depend on), this is a coverage gap worth flagging, similar to the one noted in the audit app doc.

9. Integration Points (cross-app)

App How it touches frontend
paystream Owns the settings wiring that makes frontend/templates and frontend/static the project's only template/static roots (app_settings/templates.py, app_settings/static_media.py), and owns three of the five global context processors (site_context, global_urls, admin_urls, app_version) that every frontend template can use.
utilities Owns TemplateRegistry (the indirection layer nearly every other app uses to reference a frontend template by name), BaseAdminPage (drives admin/layouts/* and the filter-config context), the report_bug_context processor, and get_all_subdomains() (feeds site_context's per-subdomain URL variables).
users account/* templates (signin, dashboard, password reset, invite, deletion, license, logout, support form) are the entire user-facing auth/account surface; admin/custom/users/changelist.html customizes the Users admin; FIELD_GROUPS in templatetags/actions.py is written specifically for the Users admin form; users.views.ui.errors.custom_403 is the handler that renders errors/base.html.
mailer Consumes account/email/* and account/fallback/* templates exclusively via TemplateRegistry's email-prefix constants — see §6.
apidocs apidocs/{swagger,redoc,stats_base,stats_public,stats_private,stats_chart_embed}.html are apidocs's entire UI; note apidocs/stats_base.html reverses URLs under a users_api:users_ui: namespace rather than an apidocs-prefixed one — a naming quirk worth confirming with whoever owns URL routing for API stats.
helpdesk (via genericissuetracker) templates/integrations/issuetracker/** is the full issue-tracker UI (list/detail/create/timeline/comments/attachments/status tabs), including the one usage site of the privacy_filters email-masking tags.
locations Queried directly (not via a service layer) from templatetags/actions.py:region_country_id.
policyengine Reached indirectly through utilities.constants.template_registry.get_api_stats_template()'s user.has_perm("policyengine.view_apidocs_stats") check — the one place frontend's template-selection logic depends on a policyengine permission code.
industries, teamcentral, invoices, audit Each owns one or more per-app admin JS feature files under static/assets/js/admin/<app>/ (see §4.4), bundled (except invoices — see §8 gap #4) into bundle.admin.min.js.
devtools Not directly referenced in this app's own code, but the shared admin/CI conventions this app's build script encodes (asset validation warnings, ordered bundling) are the kind of thing a devtools build/lint command would plausibly wrap — flagged for confirmation when that app's doc is cross-checked.
Django admin (framework) Wholesale template override — admin/base_site.html, admin/change_form.html, admin/submit_line.html replace Django's stock admin templates for the entire project, since frontend/templates is checked first in the loader chain.

10. Quick Reference — Common Tasks

Add a new page that uses the standard site chrome:

  1. Create the template under an appropriate subdirectory of frontend/templates/ (e.g. account/site_pages/ for a public page).
  2. {% extends "base/base.html" %} and fill in {% block content %} (plus {% block title %} / {% block extra_head %} / {% block extra_js %} as needed).
  3. Add a constant to utilities.constants.template_registry.TemplateRegistry rather than hardcoding the path string in the view, and have the view reference TemplateRegistry.YOUR_CONSTANT.

Add a new admin page/model using the shared admin skin:

  1. Subclass utilities.admin.base_admin_page.BaseAdminPage (not plain ModelAdmin) — this alone gets you admin/layouts/changelist.html / admin/layouts/form.html, pagination, audit/history tabs, and (if you set filter_config) AJAX cascading filters.
  2. If the model needs a bespoke changelist tweak beyond what BaseAdminPage gives for free, add admin/custom/<your_app>/changelist.html extending admin/layouts/changelist.html.
  3. If your admin form needs per-app JS (Select2 dependent-dropdowns, computed totals, etc.), add a file under static/assets/js/admin/<your_app>/ and remember to add it to the ADMIN_JS array in frontend/packaging/minify.sh — being on disk is not sufficient (see §8 gap #4 for what happens when this step is skipped).
  4. Re-run frontend/packaging/minify.sh to regenerate static/dist/js/bundle.admin.min.js before the change will show up in the browser.

Add a new outbound email:

  1. Add account/email/<prefix>.html, account/fallback/<prefix>.txt, and account/fallback/<prefix>_subject.txt (or account/email/<prefix>_subject.txt if it's in the allauth-controlled password-reset flow — see the note in §6).
  2. Add the prefix constant to TemplateRegistry's email-templates section.
  3. Wire it up on the mailer side (see the mailer app doc for the adapter/flow pattern) — frontend only supplies the templates, not the sending logic.