Skip to content

Admin Guide

Accessing the Admin Portal

The admin is available at /<ADMIN_URL_PREFIX>/ (default: /admin-denbi/). The URL prefix is configured via the ADMIN_URL_PREFIX environment variable in .env (default: admin-denbi).

Log in with your Django superuser credentials.


User Roles & Permissions

The admin uses Django's group-based access control. Three purpose-built groups map directly onto the real-world roles in the de.NBI service-registry team. The groups are created and kept up-to-date by a management command — no manual permission checkbox work is needed.

The three groups at a glance

Group Intended for Can do
Registry Viewer Auditors, new team members, read-only observers View everything — submissions, change logs, API keys, reference data, EDAM terms, bio.tools records, Celery task results
Registry Editor Day-to-day curators Everything a Viewer can, plus create/edit submissions, approve/reject submissions, mark under-review/deprecated, and issue/reset/revoke API keys
Registry Manager Team leads, data stewards Everything an Editor can, plus delete submissions and full add/change/delete on reference data (Service Categories, Service Centres, Principal Investigators)

Superusers bypass the group system entirely and have unrestricted access.

Admin API Keys are managed separately under API → Admin API Keys and are not tied to the group permission system. Key creation is limited to staff users with access to that admin section.

Permissions in detail

Registry Viewer

Resource Permissions
Service Submissions View
Submission Change Logs View
Submission API Keys View
Service Categories View
Service Centres View
Principal Investigators View
EDAM Terms View
bio.tools Records & Functions View
Celery Task Results View

Registry Editor

Everything a Viewer has, plus:

Additional capability Permission codename
Create submissions add_servicesubmission
Edit submission content change_servicesubmission
Approve / reject submissions approve_servicesubmission (custom)
Mark submissions under review or deprecated change_servicesubmission
Issue, reset, and revoke API keys manage_apikeys (custom)

Registry Manager

Everything an Editor has, plus:

Additional capability Permission codename
Delete submissions delete_servicesubmission
Add / change / delete Service Categories add/change/delete_servicecategory
Add / change / delete Service Centres add/change/delete_servicecenter
Add / change / delete Principal Investigators add/change/delete_principalinvestigator

Custom permission codenames

Two semantic permissions are defined on ServiceSubmission.Meta.permissions and are separate from the standard CRUD permissions:

Codename What it gates
submissions.approve_servicesubmission Approve and reject status transitions; bulk approve/reject actions. Kept separate from change_servicesubmission so editors can fix data without having final-decision authority.
submissions.manage_apikeys Issue, reset, and revoke SubmissionAPIKey objects. Kept separate so auditors can view key metadata without being able to create credentials that grant submitters write access.

API permissions are admin-only

These custom permissions are not exposed via the REST API. They are enforced solely in the Django admin interface. The API uses the built-in CRUD permissions (view, change, add, delete) combined with the custom DRF permission classes (IsAdminOrOwner, IsSubmissionOwner) for access control. See the API Guide → Custom permissions for more detail.

Setting up the groups

Automatic since entrypoint v2

The Docker entrypoint now runs python manage.py setup_groups automatically after every manage.py migrate. No manual step is needed on a standard deployment — the groups are always in sync with the codebase by the time Gunicorn starts.

The command is idempotent — safe to run repeatedly. It brings each group's permission set exactly in line with the specification, adding missing permissions and removing any that are no longer in the spec. Existing user → group memberships are never touched.

You can still run it manually for diagnostics or to preview changes:

# Preview changes without writing to the database:
docker compose exec web python manage.py setup_groups --dry-run

# List each group and its current permissions:
docker compose exec web python manage.py setup_groups --list

# Force a sync without restarting the container (rarely needed):
docker compose exec web python manage.py setup_groups

Creating a staff user and assigning a group

  1. Create the user
  2. Go to Admin → Authentication and Authorization → Users → Add User
  3. Enter a username and a strong initial password, then click Save and continue editing
  4. Tick Staff status (required to log in to the admin)
  5. Leave Superuser status unticked unless full, unrestricted access is needed

  6. Assign a group

  7. In the Groups field, move the appropriate group (Registry Viewer, Registry Editor, or Registry Manager) from "Available groups" to "Chosen groups"
  8. Click Save

  9. Inform the user — they can log in at /<ADMIN_URL_PREFIX>/ and should change their password immediately via the account menu (top right)

A user can belong to multiple groups

Permissions are additive. A user in both Registry Viewer and Registry Editor has the union of both groups' permissions, but in practice it is cleaner to assign exactly one group per user.

Superuser vs. staff user behaviour

Superuser Registry Manager Registry Editor Registry Viewer
Log in to admin
View all sections
Edit submissions
Approve / reject
Manage API keys
Delete submissions
Edit reference data
Manage auth tokens
See submission IP address
Django admin settings & log entries

Defence-in-depth: how permissions are enforced

The permission system has two enforcement layers that work together:

  1. Outer gate — has_*_permission methods on the ModelAdmin Django calls these before rendering any view. A Viewer's has_change_permission returns False, so any HTTP POST to the change URL returns 403 Forbidden before the code even processes the POST body.

  2. Inner gate — _require_perm guards in response_change Each privileged POST action (approve, reject, issue key, revoke keys, …) is guarded individually inside response_change. This is defence-in-depth: even if a future refactor were to inadvertently widen the outer gate, the inner guards would still block the action and show a "Permission denied" message instead of silently performing it.

The conditional fieldset system (get_fieldsets) hides the Status Actions panel and the Key Management panel from users who lack the relevant permissions. This is a UI convenience, not a security boundary — the inner guards are the security boundary.

Specifically: the Status Actions panel is shown to any user who has either change_servicesubmission or approve_servicesubmission. A user with only change_servicesubmission will see the buttons, but the Approve and Reject buttons are still blocked by the inner _require_perm guard if that permission is absent — only Under Review, Deprecate, and Undeprecate will succeed. The Key Management panel is shown only to users with manage_apikeys.


Managing Submissions

Submission List View

Columns: Service name (link), Submitter (LAST, First — Affiliation), Status badge (colour-coded pill), Maturity tag (primary bold + secondary muted), Service Centre, ELIXIR-DE flag, Submission date, API Keys (active/total count), Keys (🔑 Manage link to the key list filtered for that submission).

Filters (right sidebar): Status, Primary maturity tag, ELIXIR-DE flag, Service Centre, Service Categories, Responsible PIs, Submission date (date-drill-down calendar).

Search (top): service name, submitter first/last name, submitter affiliation, host institute, responsible PI name.

Default sort: newest first. Page size: 30.

Submission Detail View

The detail view is organised into collapsible fieldsets. The fieldset layout is:

Fieldset Collapsed by default Contents
Status & Metadata No ID, Status (read-only display), submitted/updated timestamps, Submission IP (superusers only), Status action buttons, Primary maturity tag (radio), Secondary maturity tags (checkboxes)
Last Change Summary Yes Most recent field-level diff
Change History Yes Full diff log (most recent 50 entries, each collapsible); a footer line shows the total count when the log exceeds 50 entries
A — General No Date of entry, submitter name/affiliation, ELIXIR-DE flag
B — Service Master Data No Service name, description, year, categories, toolbox, EDAM, publications, logo + preview
C — Responsibilities No Responsible PIs, host institute, service centre, contact details
D — Websites & Links No All URL fields
E — KPIs No KPI monitoring, start year
F — Discoverability & Outreach No Keywords, survey, comments
G — Consent No Data protection consent (read-only)
🔑 API Key Management No Inline key list + Revoke / Reset / Issue controls (hidden if user lacks manage_apikeys)

Submission Edit Form (Submitter View)

When a submitter edits their submission via /update/, they see the same form structure as the initial registration. The edit form includes:

Feature Description
Sidebar navigation Links to jump between sections (A-G) and a progress bar for required fields
Completion progress Real-time percentage showing required field completion
Danger Zone card Only shown for non-deprecated services; allows marking service as deprecated
Deprecated badge Shows if service is deprecated; only admins can reverse

The sidebar is shared between registration (/register/) and edit (/update/) forms.

Status is read-only

The Status field is displayed for information only — it cannot be edited directly. All status transitions are made via the Status action buttons panel in the same fieldset (see below).

Changing Submission Status

On new submission: When a submitter registers a new service, two emails are sent automatically:

  • Admin notification — full internal report with a direct link to the admin change view sent to the registry coordination address.
  • Submitter confirmation — a brief receipt confirmation ("We have received your service registration") sent to internal_contact_email. Contains no admin URL or internal details.

Individual status change (change view): Open a submission. The Status & Metadata fieldset contains a row of action buttons — the current status is highlighted and its button is disabled. Click any other button to trigger the transition:

Button Required permission Result
Approve approve_servicesubmission Approved
Reject approve_servicesubmission Rejected
Mark Under Review change_servicesubmission Under Review
Deprecate change_servicesubmission Deprecated
Undeprecate → Submitted change_servicesubmission Submitted (re-enters review queue)

Status actions bypass the form diff

Status button clicks are processed separately from the regular form save — they do not produce a field-level diff entry in the Last Change Summary or Change History. They are recorded in the Django LogEntry (History tab) with a plain text message.

Two emails are sent automatically on every status transition:

  • Admin notification — sent to the registry coordination address ([contact] email in site.toml), CC'd to SUBMISSION_NOTIFY_CC if configured. Contains a direct link to the admin change view. The submitter is never CC'd on this email.
  • Submitter notification — sent directly to the internal_contact_email of the submission with a plain-language status update ("Your service has been approved / was not approved at this time"). This is a completely separate email so the submitter receives a clear, action-oriented message and never sees the admin portal URL.

Bulk status changes: Select submissions in the list view, then choose an action from the dropdown:

Action Required permission Result
Approve selected approve_servicesubmission Approved
Reject selected approve_servicesubmission Rejected
Mark selected as Under Review change_servicesubmission Under Review
Deprecate selected change_servicesubmission Deprecated
Undeprecate selected change_servicesubmission Submitted (returns to review queue)

All bulk transitions fire the admin + submitter email notifications via Celery and are logged to Django's LogEntry.

Maturity tags are auto-cleared on any non-approved transition

Both individual buttons and bulk actions call the same underlying _change_status function, which clears maturity tags whenever moving away from Approved. See Assigning Maturity Tags for details.

Deprecation is owner-reversible only by admins

Service owners can mark their own service as deprecated via the edit form. Only admins can reverse a deprecation (via bulk action or the change view button), which resets status to Submitted for re-review.

Draft status

draft is defined in the data model but is not set by the submission form or any automated workflow. It can only be assigned via direct admin edit and is reserved for a future "save as draft" feature.

Submitter edits on approved services

When a submitter edits an approved service (via the update form or REST API), the platform determines whether to reset the status to submitted:

  • Exempt fields (configured in site.toml [submission] no_reset_fields) do not trigger a reset. By default these are: logo, github_url, biotools_url, fairsharing_url, edam_topics, edam_operations, etc.
  • Any non-exempt field change resets status to submitted and clears maturity tags. The submitter's update email includes a lifecycle notice: "Because one or more core service fields were modified, your service registration has been reset to Submitted…"
  • Exempt-only edits (e.g. uploading a logo, updating EDAM annotations) preserve the approved status and maturity tags. The submitter receives the normal diff email with no lifecycle notice.

Sanitization artifacts (trailing whitespace stripped by form cleaning, Unicode normalization) are excluded from the reset decision — only genuine value changes count.

Audit Logging

The system maintains three complementary audit trails for tracking changes to submissions.

Submission Change Log

Location 1: Admin → Service Submissions → Open submission → Change History fieldset (collapsed by default)

Location 2: Admin → Service Submissions → Change Log (sidebar menu)

A dedicated append-only table (SubmissionChangeLog) that captures all field-level changes to a submission, regardless of source. The Change Log admin list is read-only — add, edit, and delete are all blocked at the admin level. Entries can never be modified or removed through the admin UI.

Source changed_by value
Submitter web form edit "submitter"
Admin backend save "admin:<username>"
API PATCH "api:<key_label>"

Structure:

  • submission → ServiceSubmission (FK)
  • changed_by → Actor identifier
  • changed_at → UTC timestamp
  • changes → JSON array of {"field", "label", "old", "new"}

Features:

  • One row per edit event (never updated or deleted)
  • Change History fieldset (on each submission page) shows entries collapsibly per submission
  • Dedicated Change Log view (sidebar menu) shows a list of all entries system-wide with:
  • Submission link — click the service name to filter the Change Log to all entries for that submission
  • Who changed it (submitter/admin/API)
  • Changed at — click the timestamp to open that specific entry's read-only detail view showing the full before/after diff
  • Number of fields changed

Django LogEntry (History tab)

Location: Admin → Service Submissions → Open submission → History tab (top right)

Django's built-in LogEntry table that records admin-initiated actions only:

  • Status changes (approve/reject/under_review/deprecate)
  • API key operations (issue/reset/revoke)
  • Direct admin saves via the backend

Not covered: Submitter edits or API PATCH requests.

Deletion Audit Log

Location: Admin → Service Submissions → Deletion Audits (sidebar menu)

When a ServiceSubmission is hard-deleted, a SubmissionDeletionAudit record is written before the cascade. This record is never linked by FK to the submission so it is never cascade-deleted itself — it persists indefinitely.

Each record captures:

  • Key submission fields at time of deletion: service name, status, submitter info, contact email
  • Who deleted it (admin:<username>)
  • When it was deleted
  • The number of change log entries that were cascade-deleted
  • A full snapshot of every SubmissionChangeLog entry (changed_by, changed_at, fields changed)

Delete confirmation warning: When deleting a submission that has change log entries, the admin confirmation page displays a warning showing how many entries will be lost and suggests marking the submission as Deprecated instead (which preserves all history while hiding it from active listings).

Which to use?

Use Case Recommended
Track ALL changes to a submission (submitter + admin + API) Submission Change Log
Quick audit of admin actions only Django LogEntry (History tab)
Query changes programmatically (e.g., "who changed X on date Y?") Submission Change Log
Simple overview of admin activity Django LogEntry
Audit trail for hard-deleted submissions Deletion Audit Log

Last Change Summary

Every submission change view includes a collapsible Last Change Summary section (collapsed by default — click to expand). It shows the most recent edit only.

Column Description
Changed by submitter (edit form), admin:<username> (admin backend), or api:<key_label> (REST API PATCH)
Changed at UTC timestamp of the edit
Field Human-readable field name
Before Previous value (shown in red)
After New value (shown in green)

Status button clicks are not captured here

The Last Change Summary (and Change History) only reflect field-level edits saved through the regular form. Status transitions triggered by the action buttons (Approve, Reject, etc.) do not produce a SubmissionChangeLog entry and will not appear here. Those actions are recorded in the Django LogEntry (History tab) instead.

Email notifications for edits (updated event)

When a submitter edits their service — via the edit form or via PATCH to the REST API — two separate emails are sent:

Recipient Template Contents
Admin ([contact] email + SUBMISSION_NOTIFY_CC) Admin notification Full submission report + what changed table + direct link to admin change view
Submitter (internal_contact_email) Submitter updated Confirmation + what changed table + security notice ("if you did not make this change…")

The admin URL is never included in the submitter email.

Filtering by Status

The list view sidebar filters by status, making it easy to find submissions in a specific state: Draft · Submitted · Under Review · Approved · Rejected · Deprecated

Combine status with the category, service centre, or date filters to narrow down exports.

Exporting Submissions

Select submissions → choose Export selected as CSV or Export selected as JSON.

Both formats include all submission fields:

Category Fields included
Identity id, status, submitted_at, updated_at, date_of_entry, primary_maturity_tag, secondary_maturity_tags
Submitter submitter_first_name/last_name/affiliation, host_institute, public_contact_email, internal_contact_name/email, associated_partner_note
Service service_name, service_description, year_established, is_toolbox, toolbox_name, user_knowledge_required, publications_pmids
Relations service_categories, responsible_pis (semicolons in CSV, arrays in JSON)
EDAM edam_topics, edam_operations — label + URI (semicolons in CSV, objects in JSON)
Links website_url, terms_of_use_url, license, github_url, biotools_url, fairsharing_url, other_registry_url
KPIs kpi_monitoring, kpi_start_year
Discovery keywords_uncited, keywords_seo, register_as_elixir, survey_participation, comments
Logo logo_url — absolute URL, or empty if no logo uploaded
bio.tools (scalar) biotools_id, biotools_name, biotools_description, biotools_homepage, biotools_version, biotools_license, biotools_maturity, biotools_cost — empty strings if no bio.tools record
bio.tools (lists) biotools_tool_type, biotools_operating_system — semicolons in CSV, arrays in JSON
bio.tools (EDAM) biotools_edam_topic_uris, biotools_edam_operation_uris — semicolons in CSV, arrays in JSON
bio.tools (structured) biotools_functions, biotools_publications, biotools_documentation, biotools_download, biotools_links — JSON strings in CSV, arrays of objects in JSON
bio.tools (sync) biotools_last_synced_at — ISO datetime of last successful sync, or empty

JSON export uses a nested submitter object

In the JSON export, submitter fields are grouped under a "submitter" key: {"submitter": {"first_name": "…", "last_name": "…", "affiliation": "…"}, …}. The CSV export keeps these fields flat (submitter_first_name, submitter_last_name, submitter_affiliation).


Assigning Maturity Tags

Maturity tags categorize services by lifecycle state. Tags can only be assigned to approved services.

Tag types

Tag Type Values Notes
Primary (optional) Mature, Emerging, Legacy Mutually exclusive; one per service
Secondary (optional) Unstable, etc. Zero or more per service

Tag lifecycle and auto-clear on unapproval

Tags are bound to approved status. Any status transition away from Approved automatically clears all maturity tags — this covers single-service status buttons and bulk actions (Reject, Mark Under Review, Deprecate, Undeprecate → Submitted).

When a tagged service is about to be unapproved, the change form shows a yellow warning banner listing which tags will be cleared. An info message is also shown after the action confirms how many services had tags cleared.

To re-tag a service after re-approval, use the change form or bulk action again.

Single service: Edit in change form

  1. Open an approved submission's detail page
  2. Scroll to the Status & Metadata section
  3. Select a primary tag (radio buttons) or leave blank
  4. Check secondary tags (checkboxes) as needed
  5. Save

Note

Using a status button (Reject, Deprecate, etc.) in the same section will automatically clear any selected tags — the warning banner indicates this before you act.

Bulk assignment: List action

  1. Filter the submission list by status = Approved (optional)
  2. Select multiple approved submissions
  3. From the Action dropdown, select Assign maturity tags to selected submissions
  4. In the popup form, select tags
  5. Click Assign Tags

Non-approved submissions are filtered out automatically with a warning. Mixed selections (approved + non-approved) result in tags applied only to the approved subset.


API Key Management

Each submission detail page shows the Submission API Keys section. This shows all keys ever issued, their label, creation date, last-used timestamp, and whether they are active.

Available actions

Action What it does
Revoke all keys Deactivates all active keys. The submitter can no longer edit their submission until a new key is issued.
Reset key Revokes all keys and issues one new one. The new plaintext key is shown once in a banner. Communicate it to the submitter securely (e.g. encrypted email, phone).
Issue additional key Creates a new active key alongside existing ones. Useful for CI/CD pipelines or team members. Enter a descriptive label.

Auto-generated labels: If the label field is left blank, the system fills it automatically — "Admin key YYYY-MM-DD by <username>" for Issue, "Admin reset YYYY-MM-DD by <username>" for Reset.

Key scope: All keys issued from the submission change page have write scope. To issue a read-only key, use the standalone Submission API Keys list view (see below) where a scope selector is available.

Key operations do not email the submitter

Revoke, Reset, and Issue actions are silent — no notification is sent to the submitter. Communicate the new key manually (e.g. encrypted email, phone).

Key shown once only

Key plaintexts are shown once in the admin interface and are never stored anywhere. If you accidentally close the browser before copying the key, you must reset it again.

All key operations are logged in Django's admin audit log (History tab, top right of the submission change view).

Standalone Submission API Keys list

Location: Admin → Submissions → Submission API Keys

This view lists all keys across all submissions in one place. Use it to:

  • Filter by active/inactive status or submission status
  • Search by service name, key label, or the username of the staff member who created the key
  • Set or change a key's scope (read or write) — the scope selector is only available here, not on the submission change page

See Audit Logging above for a comparison of the two audit trails.


Managing Reference Data

Reference data (PIs, service centres, categories) can be managed in two ways:

  • Admin UI — the Django admin portal (see below)
  • REST APIPOST/PATCH/DELETE /api/v1/pis/, /api/v1/service-centers/, /api/v1/categories/ — useful for bulk onboarding or automation (see API Guide)

Both interfaces support soft-delete: DELETE via the API (or setting is_active = False in the admin) hides the record from the registration form but keeps it linked to existing submissions.

Deletion guard

Hard deletion of any PI, service centre, or service category is blocked whenever the record is referenced by at least one submission (in any status — draft, submitted, approved, etc.).

Scenario Behaviour
Single delete — record in use Blocked. The confirmation screen is never shown. The admin is redirected straight back to the list page with an error message stating how many submissions are affected and instructing them to use is_active = False instead.
Single delete — record not in use Allowed. The normal Django confirmation page is shown and deletion proceeds only after the admin confirms.
Bulk "Delete selected" — any selected record in use Blocked entirely. No records in the selection are deleted. A detailed error lists each blocked record and its submission count.
Bulk "Delete selected" — all selected records have no submissions Allowed. All selected records are deleted.

Use is_active = False to retire records, not Delete

Setting is_active = False hides the record from the submission form dropdown while preserving all existing submission links. This is always the correct operation for records that are no longer in use. Hard deletion is only appropriate for records that were added by mistake and have never been referenced by any submission.

The Submissions column in each list view shows how many submissions currently reference the record. For Service Categories and Service Centres the count is a hyperlink that opens the submission changelist pre-filtered to that record, making it easy to see exactly which submissions are affected before deciding whether to deactivate or delete.

Principal Investigators (PIs)

Location: Admin → Reference Data → Principal Investigators

  • Add new PIs who are not yet listed.
  • Set is_active = False to hide a PI from the form dropdown without removing them from existing submissions.
  • The is_associated_partner flag should be True for exactly one entry (the generic "Associated partner" option).
  • ORCID iDs are validated on save.
  • The Submissions column shows the total number of submissions that list this PI as responsible (plain count — no hyperlink, as the submission list does not have a per-PI filter).

PI institutes populate the affiliation combobox

The Institute field on each PI record feeds directly into the affiliation autocomplete shown to submitters in Section A of the registration form. Keeping PI institute names consistent and up-to-date here helps submitters find and reuse the correct spelling, reducing data inconsistencies.

Service Centres

Location: Admin → Reference Data → Service Centers

  • Each centre has a short name (e.g. "HD-HuB"), full name, and optional website.
  • is_active = False hides from the form but keeps existing submission links intact.
  • The Submissions column links to the filtered submission changelist for that centre.

Service Categories

Location: Admin → Reference Data → Service Categories

  • Add new category types as needed.
  • is_active = False hides from the form.
  • The Submissions column links to the filtered submission changelist for that category.

Service Logos

Submitters can optionally upload a logo for their service during registration or when editing their submission. Logos are also uploadable directly from the admin detail view.

Accepted formats and limits

Property Value
Formats PNG, JPEG, SVG
Maximum size 10 MB (configurable — see [uploads] in configuration)
Storage /app/mediafiles/logos/<uuid>.<ext> inside the container (mounted from media_data volume)
Served at /media/logos/<uuid>.<ext> — via Gunicorn (nginx proxy_passes everything)

Security processing

Every upload goes through automatic validation before being stored:

  • Magic bytes — the file type is detected from its binary header, not its extension or MIME type
  • JPEG / PNG — re-encoded via Pillow to strip EXIF metadata and verify file integrity
  • SVG — parsed with Python's stdlib XML parser (safe on Python 3.12+/Expat 2.7.1, which blocks XXE and entity-expansion attacks), then scrubbed of <script> elements, on* event-handler attributes, and non-fragment external href values
  • Filename — original filename is discarded; a UUID is assigned before storage

Admin usage

Open a submission's detail view. The B — Service Master Data section shows:

  • Logo — file upload widget to add or replace the current logo
  • Logo preview — inline image display of the currently stored logo (or "—" if none)

Old logos are retained

When a submitter or admin uploads a replacement logo, the previous file remains on disk. No automatic cleanup is performed. If disk space becomes a concern, orphaned logo files can be removed manually or via a future management command.

Production persistence

Logo files are stored in the media_data Docker volume (mounted at /app/mediafiles). Without a persistent volume or bind mount, logos are lost when the container is replaced. See Deployment → Uploaded Media for volume configuration, bind-mount instructions, and backup procedures.


Customising Form Text & Section Descriptions

The registration form text is controlled entirely from a single YAML file: apps/submissions/form_texts.yaml

The file has two parts:

Section descriptions

Each of the seven form sections (A–G) can show an introductory paragraph at the top of its card. Edit the sections block:

sections:
  a:
    description: '' # leave empty to show no description
  b:
    description: 'Provide accurate information about your service.'
  # ... c through g
  • Leave description: "" to hide the paragraph for that section.
  • No raw HTML — use the supported syntax below instead.

Supported text formatting

Named hyperlinks — use [link text](https://...) to display a clickable word or phrase instead of the full URL:

e:
  description: >-
    KPI requirements depend on your service category. See the
    [de.NBI KPI Compass](https://www.denbi.de/images/Service/20210624_KPI_Cheat_Sheet_doi.pdf)
    for guidance.

Bare URLs — plain https:// links are also automatically converted to clickable links:

b:
  description: 'For examples see https://www.denbi.de/services'

Multiple paragraphs — use a YAML literal block scalar (|) and leave a blank line between paragraphs:

f:
  description: |
    This section collects keywords and consent information.

    The information helps us improve visibility in search engines and outreach activities.

Line breaks within a paragraph — also use the | block style; each newline becomes a <br>:

c:
  description: |
    Please name the PI responsible for this service.
    Use the associated partner option if your PI is not listed.

Folded (>-) vs literal (|) block scalars

The >- style collapses line breaks into spaces — useful for long single-paragraph descriptions that you want to wrap neatly in the file. Use | when you need actual line breaks or blank-line paragraph splits to appear in the rendered output.

For the full technical details of the rendering filter, see Custom template tags — linkify_description in the Developer Guide.

Field help text and tooltips

Each field shows two types of guidance:

  • Help text — a short hint shown below the input field.
  • Tooltip — a detailed explanation shown when hovering or clicking the info icon.
service_name:
  help: 'Official name of the service.'
  tooltip: 'Use the canonical name as it appears on your website.'
  • Set help: "" to hide the help text for a field.
  • Set tooltip: "" to hide the info icon for a field.

Deploying form text changes

After editing form_texts.yaml, rebuild and redeploy:

docker compose build web
docker compose up -d web

No code changes, no migrations, no template edits required.


Customising Email Notification Text

Email subject lines and status messages sent to submitters are controlled from a single YAML file:

apps/submissions/email_texts.yaml

Subjects

The subjects section defines the subject line for each email type. Placeholders {service_name} and {status} are replaced automatically:

subjects:
  created: '[de.NBI Registry] New service submission: {service_name}'
  status_changed: "[de.NBI Registry] Status updated to '{status}': {service_name}"
  updated: '[de.NBI Registry] Update: {service_name}'
  submitter_status: 'Your service registration status: {status}  {service_name}'

Status messages

The status_messages section provides the body text included in the submitter notification when an admin changes the submission status. A default fallback is used for any status not explicitly listed:

status_messages:
  approved: 'Your service has been approved and is now registered …'
  rejected: 'Your service registration was not approved at this time …'
  under_review: 'Your submission is currently under review …'
  default: 'If you have questions about your submission, please contact us.'

Deploying email text changes

After editing email_texts.yaml, rebuild and redeploy — same as form text changes:

docker compose build web
docker compose up -d web

No code changes, no migrations, no template edits required.


EDAM Ontology Management

Location: Admin → EDAM Ontology → EDAM Terms

EDAM terms are imported from the official EDAM ontology release and are read-only in the admin. Terms cannot be added or deleted manually — all changes come through a sync.

How EDAM seeding works

Trigger When Notes
Auto-seed on first migrate Once, on a fresh database Fires automatically as a post_migrate signal when the EdamTerm table is empty. Downloads ~3 MB, takes ~30 s.
Monthly beat schedule Every 30 days Celery beat task edam.sync keeps terms current as EDAM publishes new releases.
Admin "Sync EDAM" button On demand Queues a background Celery task. Useful after a known EDAM release or if the automatic sync failed.
CLI On demand python manage.py sync_edam — synchronous, progress shown in terminal.

Viewing Terms

The list shows: accession (e.g. topic_0121), label, branch, obsolete flag, EDAM version.

Filter by branch to see only topics, operations, data types, formats, or identifiers. Search by label or definition text.

Checking the Loaded Version

The EDAM version column shows which release each term was last loaded from (e.g. 1.25). All terms should show the same version after a successful sync.

Triggering a manual EDAM sync

From the admin UI (recommended — no shell access needed):

  1. Go to EDAM Ontology → EDAM Terms
  2. Click ↻ Sync EDAM from upstream in the top-right toolbar
  3. A green message confirms the task was queued
  4. Refresh the page after ~30 seconds to see the updated term count and version

From the CLI:

# Download and import the latest stable release
docker compose exec web python manage.py sync_edam

# Dry-run — parse and count terms without writing to the database
docker compose exec web python manage.py sync_edam --dry-run

# Sync a single branch only
docker compose exec web python manage.py sync_edam --branch topic

# Load from a local file (air-gapped servers)
docker compose exec web python manage.py sync_edam --url /app/EDAM.owl

New terms appear immediately in the form. Obsolete terms are hidden from the form but retained in the database so existing submissions referencing them are not broken.

If the Form Shows No EDAM Terms

On a standard deployment this should not happen — the auto-seed fires on first migrate. If the dropdowns are empty, check the term count:

docker compose exec web python manage.py shell -c \
  "from apps.edam.models import EdamTerm; print(EdamTerm.objects.count())"
# Expected: ~3400. If 0, the auto-seed failed (check migrate output for [edam] lines).
# Fix: docker compose exec web python manage.py sync_edam

SPDX License Management

Location: Admin → Licenses → SPDX Licenses

SPDX licenses are imported from the canonical SPDX License List and are read-only in the admin. Entries cannot be added or deleted manually — all changes come through a sync.

How SPDX seeding works

Trigger When Notes
Auto-seed on first migrate Once, on a fresh database Fires as a post_migrate signal when the SpdxLicense table is empty. Downloads ~500 KB from the SPDX GitHub raw URL, fast.
Fortnightly beat schedule Every 15 days Celery beat task licenses.sync refreshes the table as SPDX publishes new releases.
Admin "Sync Licenses" button On demand Queues a background Celery task. Useful after a known SPDX release or if the automatic sync failed.
CLI On demand python manage.py sync_spdx_licenses — synchronous, progress shown in terminal.

Viewing Licenses

The list shows: SPDX id (e.g. MIT, Apache-2.0), name, OSI approval, FSF libre, deprecated flag, SPDX version. Filter by OSI-approved, deprecated, or search by id/name.

Deprecated entries

Licenses that are either deprecated upstream in SPDX, or that disappear from upstream in a subsequent sync, are marked is_deprecated=True. Deprecated entries are hidden from new submission pickers but retained in the database so existing submissions referencing them are not broken.

Triggering a manual SPDX sync

From the admin UI:

  1. Go to Licenses → SPDX Licenses
  2. Click ↻ Sync Licenses from SPDX in the top-right toolbar
  3. A green message confirms the task was queued
  4. Refresh the page after a few seconds to see the updated license count and version

From the CLI:

# Download and import the latest SPDX License List
docker compose exec web python manage.py sync_spdx_licenses

# Dry-run — parse and count licenses without writing to the database
docker compose exec web python manage.py sync_spdx_licenses --dry-run

# Load from a local file (air-gapped servers)
docker compose exec web python manage.py sync_spdx_licenses --url /app/licenses.json

If the Form Shows No SPDX Licenses

docker compose exec web python manage.py shell -c \
  "from apps.licenses.models import SpdxLicense; print(SpdxLicense.objects.count())"
# Expected: 600+. If 0, the auto-seed failed.
# Fix: docker compose exec web python manage.py sync_spdx_licenses

bio.tools Record Management

When a submitter enters a bio.tools URL, the system automatically fetches and stores a local copy of the tool's bio.tools entry. This is displayed in the submission detail view and exposed in the API.

Viewing bio.tools Records

Location: Admin → bio.tools Integration → bio.tools Records

Each record shows:

  • The linked submission
  • The bio.tools ID and link to bio.tools
  • Extracted metadata: name, description, license, tool types, maturity
  • EDAM topic URIs sourced from bio.tools
  • Last sync timestamp and any sync error

The Functions inline shows all EDAM Operation/Input/Output annotations from bio.tools, structured as one row per function block.

Sync Status

The list view shows a green ✓ or red ✗ for each record's last sync status. A red ✗ means the last sync failed — check the sync_error field on the record.

Common sync errors:

  • bio.tools tool not found (HTTP 404) — the bio.tools ID in the submission URL is wrong
  • bio.tools network error — the server could not reach bio.tools (check firewall/proxy)
  • bio.tools API error (HTTP 5xx) — bio.tools is temporarily unavailable; will retry automatically

Manually Triggering a Sync

From the admin list, select records and choose Sync selected records from bio.tools now. This queues a background Celery task; the record refreshes within a few seconds.

From the command line:

# Sync all records
docker compose exec web python manage.py sync_biotools

# Sync one specific submission
docker compose exec web python manage.py sync_biotools --submission <uuid>

# Dry-run
docker compose exec web python manage.py sync_biotools --dry-run

Creating a bio.tools Record Manually

If a submission has a bio.tools URL but no record was created (e.g. bio.tools was unreachable when the submission was saved), create it manually:

docker compose exec web python manage.py sync_biotools \
  --submission <submission-uuid> \
  --create

Stale Draft Cleanup

There are two independent draft systems — one server-side, one client-side.

Server-side draft sessions

The cleanup_stale_drafts Celery beat task runs daily and removes Django session rows that are older than 24 hours. These sessions are written by the browser draft auto-save feature. The task does not touch any ServiceSubmission rows.

To run manually:

from apps.submissions.tasks import cleanup_stale_drafts
result = cleanup_stale_drafts()
print(f"Removed {result} stale session(s)")

The beat schedule is defined in settings.py under CELERY_BEAT_SCHEDULE. See Celery beat tasks for monitoring.

Client-side localStorage drafts

The registration and edit forms auto-save field values to the submitter's browser localStorage (not to the server). These are managed entirely client-side:

  • Submit clears the draft immediately — no admin action needed.
  • updated_at key invalidation — the edit form draft key embeds the submission's updated_at Unix timestamp. Any server-side save (admin edit, status change, API update) advances updated_at, making the old draft key unreachable. The submitter always sees current values on their next visit.
  • TTL — drafts older than draft_ttl_days (default 7, configurable in site.toml [submission]) are removed on the submitter's next form load.
  • Global purge — on every form load the browser also sweeps all denbi_draft_* localStorage entries and removes expired ones, covering drafts from multiple service registrations.

No admin action is required for client-side drafts. They are entirely transparent to the server.


Periodic Sync Schedule

All bio.tools records are refreshed automatically every 24 hours by a Celery beat task. To verify the scheduler is running:

docker compose exec worker celery -A config inspect scheduled
# Should show the biotools.sync_all task

To change the schedule, edit CELERY_BEAT_SCHEDULE in config/settings.py:

"sync-biotools-daily": {
    "task": "biotools.sync_all",
    "schedule": 86400,  # seconds — change to 43200 for twice-daily
},

Admin API Keys

Scoped machine-to-machine keys that are independent of any staff user account. Use these when you need to give API access to an external consumer (a website, dashboard, or third-party script) without creating a user account for it.

Scopes

Scope Allowed HTTP methods When to use
read GET / HEAD / OPTIONS only Public-facing website, read-only dashboard, external analytics
full All methods (read + write) Trusted internal integration that needs to mutate data

Rule of thumb: always start with read. Only issue a full key if the consumer explicitly needs to create, update, or delete records.

Security properties of read keys

  • A leaked read key cannot modify any submission or reference data.
  • Serialisers already exclude all sensitive internal fields (internal_contact_email, submission_ip, user_agent_hash), so a read key cannot expose PII that isn't already exposed by the same endpoint to admin users.
  • Revoke instantly by setting Is active to False — no user account to disable.

Creating a key

  1. Go to API → Admin API Keys → Add Admin API Key.
  2. Enter a descriptive Label (e.g. Public website – read-only, CI pipeline).
  3. Select the Scope (read for external consumers, full for internal integrations).
  4. Click Save.
  5. The full plaintext key appears once in a warning banner — copy it immediately.

Using the key

Authorization: AdminKey <plaintext-key>
# Example — list all submissions with a read-scope key:
curl https://service-registry.bi.denbi.de/api/v1/submissions/ \
  -H "Authorization: AdminKey sk_abc123..."

# Attempting a write with a read-scope key returns 403:
# {"detail": "This key is read-only. Use a full-access Admin API Key to modify data."}

Revoking a key

Uncheck Is active in the admin change view and save. The key stops working immediately. The record is kept for audit purposes — hard deletion is intentionally disabled.


Email Notification Settings

Emails are sent asynchronously via Celery. Configure via environment variables in .env:

EMAIL_HOST=smtp.example.org
EMAIL_PORT=587
EMAIL_USE_TLS=true
EMAIL_FROM=no-reply@denbi.de
SUBMISSION_NOTIFY_CC=admin@denbi.de          # Optional CC on every notification
SUBMISSION_NOTIFY_OVERRIDE=                  # Override recipient for testing

Events that trigger emails:

Event Recipient(s) Template
New submission created Admin + SUBMISSION_NOTIFY_CC (CC: internal_contact_email) notification.html
Submitter edits via update form Admin + SUBMISSION_NOTIFY_CC notification.html
Status changed by admin Admin + SUBMISSION_NOTIFY_CC and internal_contact_email (separate submitter email) notification.html + status_update_submitter.html

The submitter email on status change is suppressed when SUBMISSION_NOTIFY_OVERRIDE is set (e.g. in staging/testing), so test environments do not accidentally send submitter-facing emails.


Monitoring

Health Checks

  • GET /health/live/ — 200 if the process is running (no DB check)
  • GET /health/ready/ — 200 only if DB and Redis are reachable; 503 otherwise

Logs

Logs are structured JSON on stdout (captured by Docker). Key fields: timestamp, levelname, name, message, request_id.

View live logs:

make logs
# or
docker compose logs -f web
docker compose logs -f worker

Celery / Task Queue

Check task queue health:

docker compose exec worker celery -A config inspect active
docker compose exec worker celery -A config inspect stats

# Check scheduled tasks (should include cleanup-stale-drafts, sync-biotools-daily, sync-edam-monthly)
docker compose exec worker celery -A config inspect scheduled

# Ping the worker directly (same check used by the Docker healthcheck)
docker compose exec worker celery -A config inspect ping

The worker container reports a Docker health status based on celery inspect ping. The beat container has no inspection API so its healthcheck is disabled — liveness is inferred from the process staying up.


ALTCHA CAPTCHA

The registration and edit forms are protected by ALTCHA — a self-hosted, privacy-respecting proof-of-work CAPTCHA. The browser widget is served from static/js/altcha.min.js (no CDN, no third-party requests) and the challenge endpoint is GET /captcha/.

How it works

  1. When the form loads, the widget automatically fetches a signed challenge from /captcha/.
  2. The browser solves a small SHA-256 proof-of-work puzzle (invisible to the user).
  3. On submit, the solved payload is included in the form POST as the altcha field.
  4. The server verifies the signature and expiry before processing the form.

No user interaction is required — ALTCHA runs silently in the background.

Setting the HMAC key

ALTCHA_HMAC_KEY in .env is the secret used to sign and verify challenges.

Generate a strong key:

python -c "import secrets; print(secrets.token_hex(32))"

Then set it in .env:

ALTCHA_HMAC_KEY=<your-generated-key>

Restart the web service to apply: docker compose restart web

Required in production

When ALTCHA_HMAC_KEY is empty, ALTCHA verification is bypassed entirely — safe for local development but must be configured before deploying publicly.

Rotating the ALTCHA HMAC key

  1. Generate a new key: python -c "import secrets; print(secrets.token_hex(32))"
  2. Update ALTCHA_HMAC_KEY in .env.
  3. Restart the web service: docker compose restart web

Any challenges signed with the old key will immediately become invalid. Users who opened the form before the rotation will see a CAPTCHA failure on submit — they need to reload the page to fetch a new challenge signed with the new key.


Rotating the SECRET_KEY

  1. Generate a new key: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
  2. Update SECRET_KEY in .env (or your deployment environment / CI secret store).
  3. Restart the web and worker services: docker compose restart web worker
  4. All existing sessions will be invalidated (users will need to log in again).

Rotating Database Password

  1. Update the PostgreSQL password: docker compose exec db psql -U denbi -c "ALTER USER denbi PASSWORD 'new-password';"
  2. Update DB_PASSWORD in .env to the new password.
  3. Restart services: docker compose restart web worker beat