Development Setup¶
Quick start¶
Everything you need to go from a fresh clone to a running local stack.
1. Prerequisites¶
| Tool | Minimum version | Install |
|---|---|---|
| Docker Engine + Compose | 24 / v2 | docs.docker.com |
| Git | any | system package |
| Conda / Miniforge | any | github.com/conda-forge/miniforge — only needed for local Python work (tests, linting) without Docker |
2. Clone and configure¶
Open .env and set at minimum:
SECRET_KEY=any-long-random-string # generate with: python -c "import secrets; print(secrets.token_hex(50))"
DB_PASSWORD=devpassword
REDIS_PASSWORD=devpassword
All other values in .env.example have safe defaults for local development.
3. Build and start¶
make build # builds Docker images from scratch (no cache)
make dev # starts web + worker + beat + db + redis
Migrations run automatically
The container entrypoint runs manage.py migrate before starting. On a fresh database this also auto-seeds the EDAM ontology (~3 400 terms, ~30 s). No manual migrate step needed.
4. Create a superuser¶
5. Access the app¶
| URL | What |
|---|---|
| http://localhost:8000 | Public registration form |
| http://localhost:8000/admin-denbi/ | Admin portal (superuser login) |
| http://localhost:8000/api/docs/ | Interactive API docs (Swagger UI) |
| http://localhost:8000/api/redoc/ | ReDoc API reference |
Day-to-day workflow¶
Starting and stopping¶
make dev # start stack (migrations run automatically on first start)
make dev-down # stop stack (volumes preserved — data survives)
make logs # tail all service logs
After changing a model¶
Migrations must be generated locally (not inside the container) because the container's non-root user cannot write migration files back to the bind-mounted source tree:
# 1. Generate the migration file (runs in your local conda env)
make makemigrations
# 2. Apply it to the running dev database
make migrate
Commit the generated migration file alongside your model changes.
Full clean reset¶
Wipes all containers, volumes, and data then rebuilds from scratch:
Use this when you want a guaranteed clean state — e.g. after pulling migrations that conflict with your local DB, or when debugging a migration issue.
Running the test suite¶
Tests use SQLite in-memory and a local-memory cache — no Docker or external services required:
make test # pytest — must stay ≥ 80% coverage
make test-cov # pytest + HTML report → open htmlcov/index.html
Or activate the conda environment first and run pytest directly:
Linting and formatting¶
make lint # ruff check + format check (read-only)
make lint-fix # auto-fix all fixable issues
make audit # pip-audit against production requirements
make typecheck # mypy
Make targets reference¶
Development
| Target | What it does |
|---|---|
make build |
Rebuild all Docker images with --no-cache |
make dev |
Start full dev stack (web + worker + beat + db + redis) |
make dev-down |
Stop the dev stack (data preserved) |
make logs |
Tail all dev stack logs |
make migrate |
Run pending migrations in the running web container |
make makemigrations |
Generate new migration files locally (needed after model changes) |
make superuser |
Create a Django superuser |
make shell |
Open Django shell_plus in the web container |
make collectstatic |
Collect static files into the container |
Testing and quality (requires pip install -r requirements/development.txt)
| Target | What it does |
|---|---|
make test |
pytest with SQLite in-memory — no Docker needed |
make test-cov |
pytest + HTML coverage report (htmlcov/) |
make lint |
ruff check + format check |
make lint-fix |
Auto-fix ruff lint and formatting issues |
make audit |
pip-audit against production requirements |
make typecheck |
Run mypy type checker |
make dead-code |
vulture dead-code detection (unused functions, variables) |
make security |
bandit SAST security scan (medium + high severity) |
Documentation
| Target | What it does |
|---|---|
make docs |
Serve MkDocs locally at http://127.0.0.1:8001 |
make docs-build |
Build static MkDocs site into site/ (--strict) |
Production
| Target | What it does |
|---|---|
make prod-up |
Start production stack (compose + prod overlay) |
make prod-down |
Stop production stack |
make prod-migrate |
Run migrations in the production web container |
make prod-logs |
Tail production logs |
Cleanup
| Target | What it does |
|---|---|
make clean |
Stop containers + remove all volumes — permanently deletes DB data, prompts for confirmation |
make nuke |
Full reset: clean → build → dev → wait for migrations — one command to a fresh working stack |
Conda environment (for local Python work)¶
The conda environment is used for tests, linting, and generating migrations — tasks where you want a fast feedback loop without Docker.
conda create -n denbi-registry python=3.12
conda activate denbi-registry
pip install -r requirements/development.txt
Point Django at the test settings for anything that needs Django but not a real database:
export DJANGO_SETTINGS_MODULE=config.settings_test
export SECRET_KEY=any-value
export DB_PASSWORD=any-value
export REDIS_PASSWORD=any-value
Project layout¶
denbi_service_registry/
├── apps/
│ ├── api/ — DRF viewsets, serializers, authentication
│ ├── biotools/ — bio.tools HTTP client, sync, signal, Celery tasks
│ ├── edam/ — EDAM ontology model, sync management command
│ ├── registry/ — Reference data (PIs, categories, service centres)
│ └── submissions/ — Core model, registration form, views, admin, diff_utils
├── config/
│ ├── settings.py — Main Django settings
│ ├── settings_test.py — Test overrides (SQLite, no Redis)
│ ├── celery.py — Celery app definition
│ └── site.toml — Non-secret site configuration
├── docs/ — MkDocs documentation source
├── nginx/host/ — Host nginx vhost configuration
├── requirements/ — base.txt, production.txt, development.txt
├── scripts/
│ └── entrypoint.sh — Docker entrypoint: runs migrate before CMD
├── static/ — Vendored static assets (Bootstrap, HTMX, Tom-Select, swagger-ui, redoc)
├── templates/ — Django HTML templates
└── tests/ — pytest test suite
Vendored static assets¶
All third-party CSS and JavaScript is downloaded once and committed to static/. No CDN is contacted at runtime. This is a hard requirement for GDPR compliance — browser requests to jsDelivr, Google Fonts, unpkg, or any other CDN would constitute data transfers to third parties without user consent.
Current inventory¶
| Asset | Version | Location | Used by |
|---|---|---|---|
| Bootstrap | 5.3.3 | static/css/bootstrap.min.css, static/js/bootstrap.bundle.min.js |
All pages |
| HTMX | 1.9.12 | static/js/htmx.min.js |
bio.tools prefill |
| Tom-Select | 2.3.1 | static/css/tom-select.bootstrap5.min.css, static/js/tom-select.complete.min.js |
EDAM multi-select widget, affiliation combobox |
| ALTCHA | 2.3.0 | static/js/altcha.min.js |
Registration and edit forms (CAPTCHA widget) |
| swagger-ui-dist | 5.18.2 | static/swagger-ui/ (4 files) |
/api/docs/ |
| ReDoc | 2.2.0 | static/redoc/bundles/redoc.standalone.js |
/api/redoc/ |
| de.NBI favicon | — | static/img/favicon.ico |
All pages, admin |
Updating a library¶
Can I use an external URL instead of vendoring?¶
| Asset type | External URL OK? | Notes |
|---|---|---|
Logo (logo_url in site.toml) |
Yes | CSP img-src is built dynamically from this URL |
Favicon (favicon_url in site.toml) |
Yes | Same dynamic CSP behaviour |
| JS/CSS frameworks | No | Would make browser requests to third-party CDNs — GDPR violation |
| Swagger UI / ReDoc | No | drf-spectacular defaults to jsDelivr; we override to /static/. Do not revert |
Checking for CDN leakage¶
Open browser DevTools → Network tab. All requests should resolve to localhost in dev or your own domain in prod. Any CDN request will also violate default-src 'self' and appear as a blocked request in the browser console.
Custom template tags¶
Custom template tags and filters live in
apps/submissions/templatetags/registry_tags.py and are loaded in templates with
{% load registry_tags %}.
Available filters¶
linkify_description¶
Renders a section description string from form_texts.yaml as safe HTML.
Use this filter (not Django's built-in urlize) for all section description output.
| Input syntax | Output |
|---|---|
[link text](https://example.com) |
<a href="https://example.com">link text</a> |
https://example.com |
auto-linked anchor |
Blank line (\n\n) |
paragraph break — wraps each block in <p> |
Single newline (\n, using YAML \| block) |
<br> |
Raw <html> |
escaped — never rendered as markup |
Only http:// and https:// schemes are accepted for links. javascript: and other
schemes in [text](...) syntax are not matched and pass through as escaped plain text.
Template usage:
Extending or testing the filter:
The filter is unit-tested in tests/test_template_tags.py (TestLinkifyDescriptionFilter).
Add a new test there whenever you extend the filter's behaviour.
Integration tests that render form_body.html or register.html with patched YAML
data live in tests/test_forms.py (TestSectionDescriptionsYAML).
Styling:
.section-description is styled in static/css/registry.css as a light tinted callout
box — subtle rgba(0,0,0,0.03) background with rounded corners and muted text — to
visually distinguish it from form input labels and fields. If you change the style, keep
the distinction clear: the description is contextual guidance, not an actionable form
element.
Markdown service descriptions (markdown_render)¶
service_description can be rendered as Markdown when [features] markdown_descriptions is on (see Configuration). linkify_description above is unrelated: it only formats section descriptions from form_texts.yaml.
All rendering goes through one module, apps/submissions/markdown_render.py, used by the catalogue, the API, the editor preview and the admin, so output is identical everywhere:
| Function | Purpose |
|---|---|
render_markdown(text) |
Markdown to sanitized SafeString (flag-independent, uncached) |
render_with_notice(text) |
(html, removed) for the preview; removed is true when sanitization dropped content |
markdown_to_text(text, limit=300) |
Plain-text snippet derived from render_markdown() output, so cards show the same text as the list view |
render_submission_description(sub) |
Flag-aware, cached HTML. Flag off returns the entity-decoded plain string (not safe) so templates autoescape it once |
submission_description_snippet(sub) |
Flag-aware, cached plain-text snippet (flag off: entity-decoded plain string) |
decode_legacy_entities(text) |
html.unescape applied once: the one rule every plain-text display surface uses for legacy rows |
markdown_enabled() |
Reads the feature flag from SITE_CONFIG |
Pipeline: markdown-it-py (CommonMark) with raw HTML off, breaks on (a single newline is a line break), <url> autolinks disabled and a shift_headings core rule, then bleach.clean with an allowlist, then anchor hardening, then removal of paragraphs left empty, then mark_safe. The parser instance is built once at import (_PARSER); rendering keeps all state per call. Descriptions are stored raw and sanitized on output only.
- Why markdown-it-py: it caps nesting (
MAX_NESTING = 20) and its render time grew linearly with input size for the slow-to-parse inputs intests/test_markdown_robustness.py; atDESCRIPTION_MAX_LENGTHthe slowest of them rendered in under 0.1 s on a development machine. Python-Markdown, tried earlier during development, took minutes on some long inputs and raisedRecursionErroron deeply nested lists. That test checks such inputs scale linearly (a ratio, not a wall-clock budget, so it holds under coverage and load). Content nested deeper than the cap is not rendered and is reported as removed. - Link destinations:
validateLinkaccepts every URL so that the bleach href filter below is the only place that decides; markdown-it's own check would leave a blocked link as literal[x](javascript:...)text. - Headings are shifted down three levels so they never compete with the page's own headings:
#becomesh4,##h5,###and deeperh6. The allowlist isp,br,strong,em,ul,ol,li,a,blockquote,h4,h5,h6. - Ordered lists keep a numeric
startattribute (_allowed_ol_attr, up to 9 digits), so2024. Launchedis numbered from 2024. - Strict href filter:
akeeps onlytitleand anhrefthat is an absolutehttp://orhttps://URL with a host, or a non-emptymailto:(_allowed_a_attr). bleach's protocol check alone would let scheme-less values such as/x,//hostor#fragthrough, and host-less ones such ashttps:example.orgorhttp:/x, which browsers resolve as relative links; all of these are dropped. - Anchor hardening (
_harden_anchors): an anchor whosehrefwas dropped is unwrapped to its text, and so is an anchor with no visible text (tags stripped, entities decoded, whitespace including U+00A0 trimmed, zero-width characters such as U+200B removed; for example a README badge whose image was stripped). Kept anchors getrel="nofollow noopener noreferrer"; onlyhttp/httpsanchors also gettarget="_blank",mailtoanchors do not. render_with_notice()reportsremovedwhen a Markdown-generated anchor or tag did not survive sanitization (images, code, horizontal rules, dropped links) or when the nesting cap skipped content. Headings are allowed, so they never trigger it.- Line endings: browsers submit textareas with CRLF, JSON clients send LF.
normalize_newlines()(apps/submissions/validation.py) is applied when saving (_sanitise_text), when measuring the description length (form, model, API and preview, so a line break is one character on every channel and in the editor counter) and when diffing (snapshot()), so an untouched description is never reported as changed and never resets an approved service on a web edit. - Legacy entities: rows saved by the old web form hold HTML entities (
x > 5). Markdown decodes them itself; every other display surface (flag-off catalogue and API, emails) goes throughdecode_legacy_entities(), so all surfaces agree in both flag states. Source views (admin textarea, raw API field, exports) show the stored value.
Caching: the flag-aware helpers cache per (pk, updated_at) for 24 hours (MD_CACHE_TTL). The key also contains RENDER_FINGERPRINT, a hash of markdown_render.py itself plus the installed markdown-it-py, mdurl and bleach versions, computed at import. Any change to the rules (they all live in that module) or a library upgrade therefore invalidates cached HTML automatically, and during a rolling deploy old and new workers never share entries. Every write of a description goes through save(), which bumps updated_at, so an edit is never served stale. The list view HTML and the card snippet are cached separately; the snippet is the visible text of that HTML. The catalogue views call prime_descriptions() for the services they show, which reads all their entries with one get_many and writes the misses with one set_many. Caching only saves work: if the cache fails (for example Redis is down), descriptions are rendered without it and a warning is logged. Cached values are the sanitized HTML and are trusted on read (mark_safe), which relies on Redis being private: it is password-protected and not published on a host port; keep it that way.
API: SubmissionDetailSerializer.get_service_description_html returns conditional_escape(render_submission_description(obj)): the sanitized SafeString as is when the flag is on, and the entity-decoded plain text escaped exactly once when it is off.
Template filters (apps/catalogue/templatetags/markdown_tags.py, {% load markdown_tags %}):
{% with description=service|md_description %}{% if description %} {# list view #}
<div class="catalogue-list-desc-text md-rendered">{{ description }}</div>
{% endif %}{% endwith %}
<p class="catalogue-description">{{ service|md_snippet }}</p> {# card #}
The list view hides the description toggle when nothing renders (for example an image-only description). Styling: static/css/registry.css has one .md-rendered rule set, used by both the catalogue list view and the editor preview, with heading sizes in em so the preview matches the catalogue at either base font size; static/admin/css/markdown_preview.css mirrors the same scale with admin theme colours.
Preview endpoint: markdown_preview_view (POST /markdown-preview/, URL name submissions:markdown-preview) renders templates/submissions/partials/markdown_preview.html from the posted service_description, NFC-normalised and stripped exactly as clean_service_description does, so the length check matches the form. It returns 404 when the flag is off. Blank input gives "Nothing to preview."; input over DESCRIPTION_MAX_LENGTH gives "Description is too long to preview."; otherwise the rendered HTML, preceded by a "Some formatting was removed" notice when render_with_notice() reports removed. It is limited by RATE_LIMIT_PREVIEW, counted per signed-in user (admins) or else per client IP, and does not block: a throttled request gets a 200 fragment with "Too many previews. Please wait a moment and try again." Error fragments use role="alert", which the editor script uses to avoid caching them.
Editor: the Write | Preview editor is one partial, templates/submissions/partials/markdown_editor_box.html, shared by the public forms and the admin:
- Public forms:
form_body.htmlincludesmarkdown_editor.htmlforservice_descriptionwhen the flag is on (otherwise the plainfield.html).markdown_editor.htmlwraps the box with the samefield_label.htmlandfield_feedback.htmlpartials thatfield.htmluses; the box includesfield_widget.html, so the textarea keeps its inline-validation wrapper.field.htmlis composed of those partials (the fieldset branch keeps its own<legend>and reusesfield_widget.htmlandfield_feedback.html), with unchanged output.form_scripts.htmlloadsjs/markdown-editor.jswhen the flag is on. - Admin:
MarkdownTextareaWidget(apps/submissions/widgets.py) renders the admin textarea and passes it to the box aswidget_html(it must be non-empty; an empty value falls back to thefieldinclude). ItsMediashipsadmin/css/markdown_preview.cssandjs/markdown-editor.js.ServiceSubmissionAdmin.get_formswaps the widget in per request when the flag is on, keeping the existing textarea attrs. - Box contract: the root carries
data-md-editoranddata-md-preview-url; tabs are[role=tab][data-md-tab=write|preview], panels[data-md-panel=write|preview](the preview panel is focusable withtabindex="0"and deliberately has noaria-live); the counter is[data-md-counter]withdata-min/data-maxfrom the{% description_length_limits %}tag inregistry_tags(the single source forDESCRIPTION_MIN_LENGTH/DESCRIPTION_MAX_LENGTH); the help toggle is[data-md-help-toggle]and opensmarkdown_help.html. The tab strip and footer ship withhiddenanddata-md-enhance, so without JavaScript only the textarea shows. static/js/markdown-editor.jsreveals those controls and initialises every[data-md-editor]on load and afterhtmx:afterSettle(window.MarkdownEditor.init(root)is also exposed). Tabs follow the WAI-ARIA tabs pattern with automatic activation (Left/Right/Home/End). Selecting Preview POSTs the text withfetch(CSRF token from thecsrftokencookie, falling back to the form field), showing "Loading preview…" meanwhile; if the text is unchanged since the last successful render, the cached HTML is reused without a request. Stale responses are discarded by a sequence counter. The counter counts like the server (LF line endings, so a line break is one character; NFC, Pythonstr.strip()whitespace, code points): neutral at 0,is-errorwith "at least N characters" below the minimum,is-warnfrom 90% of the maximum,is-errorabove it.
Plain-text emails: render every .txt email body with render_plaintext(template_name, context) from apps/submissions/tasks.py, not render_to_string(). It disables autoescaping, so the raw Markdown (and characters such as &, <, >, ') arrive unescaped. HTML email bodies keep using render_to_string() and stay escaped. Both are rendered from _decoded_description_context(context): it adds service_description_text (used by notification.txt) and runs the description's before/after values in changes through decode_legacy_entities(), so legacy rows show x > 5, not x > 5, in both parts, matching the catalogue and the API. Other fields are left as stored.
Tests: tests/test_markdown_render.py (pipeline), tests/test_markdown_robustness.py (linear scaling of the slow-to-parse max-length inputs tested, nesting cap, ordered-list start), tests/test_markdown_output_matrix.py (exact output of every surface x flag state x input class), tests/test_markdown_consistency.py (removal of disallowed markup and cross-surface parity), tests/test_markdown_behaviour.py (storage, edit status reset, template guards), tests/test_markdown_tags.py, tests/test_catalogue_markdown_render.py, tests/test_markdown_preview_view.py, tests/test_markdown_editor_render.py, tests/test_markdown_input_unification.py, tests/test_field_partials.py, tests/test_api_markdown.py, tests/test_admin_markdown.py, tests/test_audit_markdown_command.py, tests/test_export_formula_guard.py and tests/test_email_plaintext.py.
Field-level diff (diff_utils)¶
apps/submissions/diff_utils.py computes a human-readable before/after diff for any submission save. It is used by all three edit paths — the submitter web form (views.py), the admin backend (admin.py), and the REST API (api/views.py).
Key functions¶
| Function | Purpose |
|---|---|
snapshot(instance) |
Returns a dict of current scalar field values; must be called before form.is_valid() or admin save_model() |
snapshot_m2m(instance) |
Returns a dict of current M2M field values as sorted lists of strings |
build_diff(before, after) |
Compares two snapshots; returns [{field, label, old, new}] for changed fields only |
filter_sanitization_artifacts(changes, form_changed_data, form_field_names) |
Removes false-positive diff entries where form sanitization (bleach, NFC normalisation, whitespace stripping) produced a raw difference that resolved to the same stored value. Always applied after build_diff() in the web-form edit path. |
Snapshot timing¶
Django's ModelForm._post_clean() calls construct_instance() during is_valid(), which writes POST data onto form.instance before form.save() is called. Always take the snapshot() and snapshot_m2m() calls before constructing the form:
before_scalar = snapshot(submission) # ← before form construction
before_m2m = snapshot_m2m(submission)
form = SubmissionForm(request.POST, instance=submission)
form.is_valid() # ← would corrupt before-state if snapshot taken here
form.save()
after_scalar = snapshot(submission)
after_m2m = snapshot_m2m(submission)
changes = build_diff({**before_scalar, **before_m2m}, {**after_scalar, **after_m2m})
In the admin, save_model() receives obj already populated with new form values — re-fetch the original from the database for the before-snapshot:
DRF serializers do not modify self.instance during is_valid(), so the snapshot can safely be taken before serializer construction.
Adding a diffable field¶
- Add an entry to
DIFFABLE_FIELDS(scalar) orDIFFABLE_M2Mindiff_utils.py - If the field uses
choices, add it to_CHOICE_FIELDSsoget_FOO_display()is used - Add a test to
tests/test_diff_utils.py
Sanitization artifacts¶
Form clean_* methods (bleach, unicodedata.normalize, .strip()) may produce a cleaned value identical to the stored value even when the raw POST string differed slightly (trailing spaces, NFC variants). build_diff() compares snapshots of the stored model value before and after save, so such non-changes do not appear in the diff.
filter_sanitization_artifacts() provides a second layer: it removes any diff entry where the field is managed by the form and is absent from form.changed_data. It is called after build_diff() in the web-form path before persisting last_change_summary.
Status reset and sanitization: EditView determines whether to reset status using a prospective diff — it compares before_scalar against snapshot(updated) taken from the in-memory instance after form.save(commit=False). Because _post_clean() has already applied clean_* normalization at that point, sanitization artifacts never appear in the prospective diff and cannot trigger a false status reset.
Logo upload security pipeline¶
Uploaded service logos pass through apps/submissions/logo_utils.py before being
stored. Understanding this module is useful when modifying file upload handling.
Entry point¶
from apps.submissions.logo_utils import validate_and_process_logo
result = validate_and_process_logo(file_obj) # → InMemoryUploadedFile
Processing steps (in order)¶
| Step | What happens |
|---|---|
| Size check | Raises ValidationError if file exceeds settings.LOGO_MAX_BYTES (configurable in site.toml) |
| Magic-byte detection | Reads first bytes to determine type — never trusts file extension or MIME header |
| Pixel check | PNG/JPEG only: raises ValidationError if width × height exceeds settings.LOGO_MAX_PIXELS (configurable in site.toml); read from the image header before decoding |
| SVG size check | SVG only: raises ValidationError if the file exceeds settings.LOGO_MAX_SVG_BYTES (configurable in site.toml); checked before parsing |
| JPEG / PNG | Re-encoded via Pillow: strips EXIF metadata, verifies image integrity. JPEG output is always RGB — RGBA/LA images are composited on a white background; CMYK/YCbCr/L are converted to RGB; palette images with transparency are treated as RGBA. This ensures all major browsers can render the stored file. |
| SVG | Parsed by stdlib xml.etree.ElementTree (Python 3.12+/Expat 2.7.1: external entities are not loaded, and text from internal entities goes through the same rules as the rest of the file). The root must be an SVG <svg>; nesting deeper than _SVG_MAX_DEPTH (100) is rejected; only elements in _SVG_ALLOWED_ELEMENTS are kept (shapes, text, gradients, patterns, clip paths, masks, filters, structure), and the text following a removed element is preserved (each child list is rebuilt once, linear time); namespaced attributes other than _KEPT_NAMESPACED_ATTRS (xlink:href, xml:space, xml:lang), on* attributes, src/action/formaction/ping are dropped; a non-fragment href is dropped on <a> and rejects the upload on any other element (_reject_outside_reference), so an <image> wrapping a bitmap is never stored as a blank logo. CSS in <style>, style and other un-namespaced attributes that contains url(, image-set(, image(, src(, @import or a backslash is read with tinycss2 (CSS Syntax spec, which decodes escapes such as u\72 l(; other text cannot reference a resource and is left as is): url() keeps only #fragment and data: references (others become none), top-level @import is removed, and parse errors, nested @import and image-set()/image()/src() are rejected. CSS that needs no change is kept byte for byte |
| UUID filename | Original filename is discarded; _logo_upload_to() in models.py assigns logos/<uuid4>.<ext> |
When processing runs¶
Only a newly uploaded file is processed. Every upload route calls the same code:
SubmissionForm.clean_logo() (registration and edit forms) and
ServiceSubmissionAdminForm.clean_logo() go through process_new_logo_upload();
the API serializer's validate_logo() calls validate_and_process_logo().
The inline field-validation endpoint (/register/validate/) does not receive
uploaded files, so it never processes a logo.
Saving a record without choosing a new file keeps the stored logo as it is, so
changes to these rules never rewrite logos that are already stored.
Limits shown to users¶
logo_limits_text() / logo_help_text() build the limit text from the settings.
It is used by the public form (via the {logo_limits} placeholder in
form_texts.yaml), the admin form and the API serializer (OpenAPI schema).
Serving uploaded media¶
config/urls.py serves /media/ through _serve_media(), which sets a
restrictive Content-Security-Policy (_MEDIA_CSP: sandboxed, no scripts, only
inline styles and data: images/fonts). The site-wide CSPMiddleware leaves an
existing header in place. The header has no effect on pages that show logos
through <img>; it applies when a logo URL is opened directly.
Tests¶
tests/test_logo_utils.py— the processing rules, unit level (including linear-scaling checks for the CSS handling and for element removal)tests/test_logo_upload_routes.py— the same scenarios through every upload route (registration, edit, API create, API PATCH, admin), plus the help texttests/test_media_serving.py— the/media/response header
Adding a new allowed format¶
- Add magic-byte detection to
_sniff_type()— return a new type string - Add a processing function (strip metadata, verify integrity)
- Add the new branch to
validate_and_process_logo() - Add tests to
tests/test_logo_utils.pyand a scenario totests/test_logo_upload_routes.py - Update
logo_limits_text()and the docs if the format has its own limit
Media files in development¶
In development (docker-compose.yml), the project root is bind-mounted as
.:/app. Uploaded files land in mediafiles/logos/ inside the container,
which maps to <project-root>/mediafiles/ on your host. The directory is
listed in .gitignore — do not commit uploaded logos.
The container runs as a non-root user, so the bind-mounted directory must exist and be writable from inside the container before uploads can be stored locally:
Media files in tests¶
config/settings_test.py overrides MEDIA_ROOT to a temporary directory
(tempfile.mkdtemp()), so test file uploads never touch the project's
mediafiles/ directory. The temp directory is cleaned up by the OS after
the test process exits. Tests that assert on specific file URLs should use
the settings + tmp_path fixtures to set a deterministic MEDIA_ROOT
(see tests/test_api.py → TestLogoUpload for the pattern).
Available simple tags¶
| Tag | Purpose |
|---|---|
{% site_logo_url %} |
Returns logo URL from site.toml, or empty string |
{% site_favicon_url %} |
Returns favicon URL (auto-detects static/img/favicon.* as fallback) |
{% site_setting section key %} |
Generic accessor for any site.toml value |
Form draft (localStorage)¶
The registration form (/register/) and the edit form (/update/edit/<submission_id>/) auto-save field values to the browser's localStorage so that a partially completed form survives a tab close or accidental navigation.
The API-key entry page (/update/) is deliberately not draft-enabled — it carries no draft key and does not load the draft script — so a typed API key is never written to localStorage. The key is also never a field on the register/edit forms, so it cannot enter a draft by that route either.
Draft key scheme¶
| Form | Key pattern | Invalidated when |
|---|---|---|
| Register | denbi_draft_register |
Form is submitted (cleared in submit event handler) |
| Edit | denbi_draft_edit_{submission_uuid}_{updated_at_unix} |
Submission updated_at changes (any server-side save) or form is submitted |
The edit form embeds updated_at as a Unix timestamp in the key. Any server-side modification (admin edit, status change, API PATCH) advances updated_at, making the old draft key unreachable on the next page load — the user always sees the current DB values.
Draft payload structure¶
_savedAt is a Date.now() millisecond timestamp written by saveDraft(). It is used exclusively for TTL enforcement and is not a form field name.
Fields excluded from drafts (SKIP_NAMES)¶
| Field | Reason |
|---|---|
csrfmiddlewaretoken |
Security — must come from the server |
data_protection_consent |
Must be re-checked deliberately on every submission |
altcha |
Proof-of-work challenges expire; stale values are rejected server-side |
logo |
File inputs cannot be restored from localStorage |
TTL and global purge¶
Every form load calls purgeStaleDrafts() before restoreDraft(). It:
- Iterates all
localStoragekeys starting withdenbi_draft_. - Removes any entry whose
_savedAtis older than the configured TTL, or whose_savedAtis absent. - For the edit form specifically: also removes any
denbi_draft_edit_{uuid}_*key that does not match the current draft key (old-version cleanup).
The TTL is configured in site.toml under [submission] draft_ttl_days (default 7). It is exposed to JavaScript as data-draft-ttl-days on the <form> element.
Restoring a draft¶
restoreDraft() is called after purgeStaleDrafts(). It reads DRAFT_KEY from localStorage, verifies the TTL, then restores each field. A "Draft restored" banner with a Clear draft button is shown if any fields were actually restored. Server-side validation errors suppress draft restoration so the user sees the real error rather than a stale draft overwriting the submitted values.
Testing¶
JavaScript logic is not covered by the Python test suite. Python tests cover:
FORM_DRAFT_TTL_DAYSis present in the template context (tests/test_context_processors.py)data-draft-ttl-daysattribute is rendered in both form pages (tests/test_views.py)
Manual browser verification: use DevTools → Application → Local Storage to inspect and manipulate _savedAt values.
Submission lifecycle (status reset)¶
When a submitter edits an approved service via the web form or REST API, the platform determines whether to reset the status to submitted (triggering a re-review) based on which fields actually changed.
How "changed" is determined¶
Scalar fields — a prospective diff compares the before_scalar snapshot (taken before form processing) with a snapshot of the in-memory instance after form.save(commit=False). This uses the already-cleaned values, so sanitization artifacts (whitespace, NFC normalization) do not produce false positives.
M2M fields — form.changed_data filtered to the set of tracked M2M field names (DIFFABLE_M2M). M2M comparison is PK-based with no sanitization, so form.changed_data is reliable here.
no_reset_fields exemption¶
Fields listed in settings.SUBMISSION_NO_RESET_FIELDS (configured via site.toml [submission] no_reset_fields) are exempt. If all actually-changed fields are exempt, the approved status is preserved. If any non-exempt field changed, status is reset and maturity tags are cleared (they are only valid on approved services).
The exemption is enforced identically in:
apps/submissions/views.pyEditView.post()— web form pathapps/api/views.pypartial_update()— REST API path
lifecycle.py¶
apps/submissions/lifecycle.py provides get_no_reset_fields() — an @lru_cache-decorated function that reads settings.SUBMISSION_NO_RESET_FIELDS, validates each entry against DIFFABLE_FIELDS/DIFFABLE_M2M, rejects system-controlled fields, and returns a frozenset of valid exempt names. The cache is populated at startup via SubmissionsConfig.ready() so misconfigured entries are logged immediately.
Call get_no_reset_fields.cache_clear() in tests that override settings.SUBMISSION_NO_RESET_FIELDS.
When status resets¶
On reset, both the web form and API:
- Set
status = "submitted" - Clear
primary_maturity_tag = Noneandsecondary_maturity_tags = [] - Pass
status_reset=Truetosend_update_notificationso the submitter email includes a lifecycle notice
Frontend JavaScript widgets¶
All form widget JS lives in static/js/edam-autocomplete.js. It is loaded globally
via base.html and provides three independent functions:
buildEdamPicker(selectEl)¶
Enhances any <select class="edam-autocomplete"> into a pill-zone + search + dropdown
picker for EDAM ontology terms. Applied automatically to all matching elements on
DOMContentLoaded. Configure via data attributes on the <select>:
| Attribute | Default | Purpose |
|---|---|---|
data-max-items |
6 |
Maximum selectable terms |
data-placeholder |
"Search EDAM terms…" |
Search input placeholder |
data-branch |
"" |
EDAM branch filter (topic, operation, etc.) |
buildCompactSelect(selectEl, label)¶
Enhances any <select multiple data-compact-select="label"> into a searchable
checkbox list with selected pills shown at the top (matching the EDAM picker layout).
To apply to a new field, add the data attribute to the widget in forms.py:
No JS changes needed — the boot code auto-discovers all [data-compact-select] elements.
Currently used by: responsible_pis ("PIs"), service_categories ("categories").
Tom Select combobox (data-affiliation-combobox)¶
The submitter_affiliation field uses Tom Select (vendored at
static/js/tom-select.complete.min.js) initialised in register.html and
edit.html. It provides a single-value searchable combobox with create: true
(free-text entry). To add Tom Select to another field, load the JS in the relevant
template's {% block extra_js %} and initialise with new TomSelect(el, {...}).
Reusable form widgets¶
Four reusable widget classes in apps/submissions/widgets.py provide consistent UX across form fields:
| Widget | Use case | Output |
|---|---|---|
EdamAutocompleteWidget |
EDAM term fields (edam_topics, edam_operations) |
Searchable multi-select with pills, max 6 items |
AffiliationComboboxWidget |
Institute/affiliation autocomplete (submitter_affiliation, host_institute) |
Tom Select single-select combobox with free-text entry |
CompactSelectWidget |
Multi-select fields (service_categories, responsible_pis) |
Searchable list with checkboxes, selected items shown as pills |
CompactSelectSingleWidget |
Single-select fields (service_center) |
Searchable list with radio buttons (not checkboxes), clarifies "pick one" to users |
To add a new instance of these, just use them in forms.py:
"my_instit_field": AffiliationComboboxWidget(placeholder="e.g. Max-Planck-Institut"),
"my_multi_field": CompactSelectWidget(label="Items"),
"my_single_field": CompactSelectSingleWidget(label="Choice"),
No JS changes needed — widgets declare their Media (CSS/JS dependencies) and register data attributes that the boot code auto-discovers.
Implementation details — see apps/submissions/widgets.py docstrings for architectural rationale (progressive enhancement, accessibility, GDPR compliance for vendored assets).
Adding a feature¶
- Create a branch:
git checkout -b feature/my-feature - Make changes; add or update tests
make lint-fix && make test— lint and coverage must pass- Open a pull request against
main