{% extends "base.html" %} {% load common_extras %} {% comment %} One contact (issue #13). Four panes, each its own partial with its own refresh trigger, because they change independently: editing a field does not alter the channel list, and starting a flow does not alter the tags. A single container refreshed on every mutation would re-run every query on the page to redraw one line. Everything here is attacker-controlled — names, emails, platform usernames and profile picture URLs all arrive from strangers (SECURITY-BASELINE §2). Django autoescapes, nothing is marked safe, and the avatar URL is scheme-checked in apps/contacts/activity.py before it can become a src. {% endcomment %} {% block title %}{{ contact.display_name }} · Contacts · DemandTrail Messaging{% endblock %} {% block page_header %}
{% if avatar_url %} {% else %} {% endif %}

{{ contact.display_name }}

Added {{ contact.created_at }}{% if contact.last_interaction_at %} · last seen {{ contact.last_interaction_at }}{% endif %}

Back to contacts {% if can_manage_crm %} {% comment %} SPEC §19's subject access response. A plain link, not htmx: the answer is a file download, and hx-get would swap JSON into the DOM. {% endcomment %} {% endif %} {% if can_manage_crm %}
{% endif %}
{% endblock %} {% block content %}
{# ── System fields ───────────────────────────────────────────── #}

Details

{% if can_edit_contacts %}

Each field saves on its own when you leave it.

{% else %}

You have read-only access to this workspace.

{% endif %} {% for row in editable_fields %}
{% if can_edit_contacts %}
{% else %} {% endif %}
{% endfor %}
{# ── Custom fields ───────────────────────────────────────────── #}

Custom fields

{% if field_values %} {% for row in field_values %}
{% if can_edit_contacts %}
{% if row.is_boolean %} {% elif row.is_number %} {% elif row.is_date %} {% elif row.is_datetime %} {% else %} {% endif %}
{% else %} {% endif %}
{% endfor %} {% comment %} The clear affordance is "empty the box": clear_field_value deletes the row rather than storing an empty string, which is what keeps the check constraint's "exactly one column populated" true and what the condition engine's no_value operator means. {% endcomment %}

Emptying a field removes its value entirely.

{% else %}

No custom fields yet. {% if can_manage_crm %}Add one.{% endif %}

{% endif %}
{# ── Activity ────────────────────────────────────────────────── #} {% include "contacts/_activity.html" %} {# ── Danger zone (SPEC §19, issue #29) ───────────────────────── #} {% comment %} Admin only, and last on the page on purpose: erasure is the one action in the CRM with no undo. Delete above hides a person; this removes them. The typed confirmation is enforced in the view, not here. hx-confirm is a window.confirm and proves nothing about a request that did not come from this page — it is the speed bump, and the two hidden/typed values the form posts are the control. See apps/contacts/views.py::contact_erase. {% endcomment %} {% if can_erase_contacts %}

Erase permanently

Removes {{ contact.display_name }} outright: their profile, custom field values, tags, channel identities, consent records, conversations and message bodies. Counters stay, anonymised. A suppressed email address is kept, because that is a fact about a mailbox rather than about this contact. This cannot be undone.

{% endif %}
{% endblock %}