{% extends "base.html" %} {% block title %}History: {{ verbose_name }} #{{ pk }}{% endblock %} {% block breadcrumb %} Home › {{ verbose_name }}s › #{{ pk }} › History {% endblock %} {% block extra_head %}
{% endblock %} {% block content %}

#
Change History {{ entries|length }}

{% if entries|length > 0 %}
{% endif %}
Back to {{ verbose_name }}
{% if entries|length > 0 %}
{{ stats.total_entries }}
Total Events
{{ stats.unique_users }}
Contributors
{{ stats.total_field_changes }}
Field Changes
—
Last Activity
{% for act_name, act_count in stats.action_counts.items() %}
{% endfor %}
{% for entry in entries %}
{{ entry.action|replace('_', ' ') }}
{{ entry.username[:2]|upper }} {{ entry.username }}
{% if entry.fields_changed > 0 %}
{{ entry.fields_changed }} field{{ 's' if entry.fields_changed != 1 else '' }} {% endif %} {% if not entry.success %}
Failed {% endif %}
{% if entry.role %}
{{ entry.role }} {% endif %} {% if entry.ip_address %}
{{ entry.ip_address }} {% endif %}
{{ entry.timestamp }}
{% if entry.changes and entry.changes|length > 0 %}

####
Field Changes ({{ entry.changes|length }})

Field | Previous Value |  | New Value
--- | --- | --- | ---
{% for field_name, change in entry.changes.items() %}
{% if change is mapping %}
{% else %}
{% endif %}
{{ field_name }} | {{ change.old|default('(empty)') if change.old is not none else '(empty)' }} | → | {{ change.new|default('(empty)') if change.new is not none else '(empty)' }} | {{ change }}
{% endfor %}

{% elif entry.action in ['create', 'CREATE'] %}

####
Record Created

A new record was created. No field-level diff is available for creation events.

{% elif entry.action in ['delete', 'DELETE'] %}

####
Record Deleted

This record was permanently deleted from the database.

{% endif %}

####
Audit Details

{% if entry.id %}
Entry ID
{{ entry.id }}
{% endif %}
Timestamp (UTC)
{{ entry.timestamp }}
User
{{ entry.username }}{% if entry.user_id %} ({{ entry.user_id }}){% endif %}
{% if entry.role %}
Role
{{ entry.role }}
{% endif %} {% if entry.ip_address %}
IP Address
{{ entry.ip_address }}
{% endif %} {% if entry.user_agent %}
User Agent
{{ entry.user_agent }}
{% endif %}
Status
{% if entry.success %}
Success {% else %}
Failed {% if entry.error_message %}
{{ entry.error_message }}
{% endif %} {% endif %}
{% for key, value in entry.metadata.items() %} {% if key not in ['pk', 'record_id'] %}
{{ key|replace('_', ' ')|title }}
{{ value }}
{% endif %} {% endfor %}
{% endfor %}
Select 2 entries to compare — 0/2 selected
{% else %}

### No History Found

No changes have been recorded for this record yet. Actions like creating, editing, or deleting will appear here automatically.

Back to {{ verbose_name }}
{% endif %}

###
Export History

Download the full audit trail for {{ verbose_name }} #{{ pk }}.

###
Entry Comparison

{% endblock %} {% block extra_js %} (function() { 'use strict'; /* ── Data ──────────────────────────────────────────── */ var entries = {{ entries|tojson|default('[]') }}; var modelName = {{ model_name|tojson|default('""') }}; var pk = {{ pk|tojson|default('""') }}; /* ── Relative time helper ──────────────────────────── */ function relativeTime(dateStr) { if (!dateStr) return ''; var d; try { d = new Date(dateStr); } catch(e) { return dateStr; } if (isNaN(d.getTime())) return dateStr; var diff = Date.now() - d.getTime(); var sec = Math.floor(diff / 1000); if (sec < 5) return 'just now'; if (sec < 60) return sec + 's ago'; var min = Math.floor(sec / 60); if (min < 60) return min + 'm ago'; var hr = Math.floor(min / 60); if (hr < 24) return hr + 'h ago'; var days = Math.floor(hr / 24); if (days < 7) return days + 'd ago'; if (days < 30) return Math.floor(days / 7) + 'w ago'; return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } function fullDateTime(dateStr) { if (!dateStr) return ''; try { var d = new Date(dateStr); if (isNaN(d.getTime())) return dateStr; return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch(e) { return dateStr; } } /* ── Apply relative times ──────────────────────────── */ document.querySelectorAll('.entry-time').forEach(function(el) { var ts = el.getAttribute('data-ts'); var rel = relativeTime(ts); var textEl = el.querySelector('.time-text'); if (textEl && rel) { textEl.textContent = rel; el.setAttribute('title', fullDateTime(ts)); } }); /* Format full timestamps in detail panels */ document.querySelectorAll('.full-ts').forEach(function(el) { var ts = el.getAttribute('data-ts'); var formatted = fullDateTime(ts); if (formatted) el.textContent = formatted; }); /* Last activity in stats card */ var lastEl = document.getElementById('lastActivityAge'); if (lastEl && entries.length > 0) { var lastTs = entries[0].timestamp || ''; lastEl.textContent = relativeTime(lastTs); lastEl.title = fullDateTime(lastTs); } /* ── Expand / Collapse ─────────────────────────────── */ var allExpanded = false; window.toggleEntry = function(idx) { var card = document.getElementById('entry-' + idx); if (card) card.classList.toggle('expanded'); }; var expandBtn = document.getElementById('expandAllBtn'); if (expandBtn) { expandBtn.addEventListener('click', function() { allExpanded = !allExpanded; document.querySelectorAll('.entry-card').forEach(function(c) { if (allExpanded) c.classList.add('expanded'); else c.classList.remove('expanded'); }); this.innerHTML = allExpanded ? '
Collapse All' : '
Expand All'; }); } /* ── Filter chips ──────────────────────────────────── */ document.querySelectorAll('.filter-chip').forEach(function(chip) { chip.addEventListener('click', function() { document.querySelectorAll('.filter-chip').forEach(function(c) { c.classList.remove('active'); }); chip.classList.add('active'); var filter = chip.getAttribute('data-filter'); document.querySelectorAll('.timeline-entry').forEach(function(entry) { if (filter === 'all' || entry.getAttribute('data-action') === filter) { entry.classList.remove('hidden'); } else { entry.classList.add('hidden'); } }); }); }); /* ── Search ────────────────────────────────────────── */ var searchInput = document.getElementById('historySearch'); if (searchInput) { searchInput.addEventListener('input', function() { var q = this.value.toLowerCase().trim(); /* Reset filter chips to "all" when searching */ document.querySelectorAll('.filter-chip').forEach(function(c) { c.classList.remove('active'); }); var allChip = document.querySelector('.filter-chip[data-filter="all"]'); if (allChip) allChip.classList.add('active'); document.querySelectorAll('.timeline-entry').forEach(function(entry) { if (!q) { entry.classList.remove('hidden'); return; } var text = ( (entry.getAttribute('data-action') || '') + ' ' + (entry.getAttribute('data-username') || '') + ' ' + (entry.getAttribute('data-timestamp') || '') + ' ' + entry.textContent ).toLowerCase(); entry.classList.toggle('hidden', text.indexOf(q) === -1); }); }); } /* ── Compare mode ──────────────────────────────────── */ var compareMode = false; var compareSelection = []; var compareBar = document.getElementById('compareBar'); var compareBtn = document.getElementById('compareBtn'); var compareCancelBtn = document.getElementById('compareCancelBtn'); var compareViewBtn = document.getElementById('compareViewBtn'); var compareCountEl = document.getElementById('compareCount'); var compareModal = document.getElementById('compareModal'); var compareContent = document.getElementById('compareContent'); function enterCompareMode() { compareMode = true; compareSelection = []; if (compareBar) compareBar.classList.add('show'); updateCompareUI(); document.querySelectorAll('.entry-card-header').forEach(function(h) { h.style.outline = '2px dashed transparent'; h.style.outlineOffset = '-2px'; h.style.transition = 'outline-color .2s'; }); } function exitCompareMode() { compareMode = false; compareSelection = []; if (compareBar) compareBar.classList.remove('show'); document.querySelectorAll('.entry-card-header').forEach(function(h) { h.style.outline = ''; h.style.outlineOffset = ''; }); document.querySelectorAll('.entry-card').forEach(function(c) { c.style.outline = ''; }); } function updateCompareUI() { if (compareCountEl) compareCountEl.textContent = compareSelection.length; if (compareViewBtn) compareViewBtn.disabled = compareSelection.length !== 2; /* Highlight selected cards */ document.querySelectorAll('.entry-card').forEach(function(c) { var idx = parseInt(c.id.replace('entry-', '')); if (compareSelection.indexOf(idx) !== -1) { c.style.outline = '2px solid var(--accent)'; c.style.outlineOffset = '-2px'; } else { c.style.outline = ''; } }); } if (compareBtn) { compareBtn.addEventListener('click', function() { if (compareMode) exitCompareMode(); else enterCompareMode(); }); } if (compareCancelBtn) { compareCancelBtn.addEventListener('click', exitCompareMode); } /* Intercept click on entry header in compare mode */ var origToggle = window.toggleEntry; window.toggleEntry = function(idx) { if (compareMode) { var pos = compareSelection.indexOf(idx); if (pos !== -1) { compareSelection.splice(pos, 1); } else if (compareSelection.length < 2) { compareSelection.push(idx); } updateCompareUI(); return; } origToggle(idx); }; if (compareViewBtn) { compareViewBtn.addEventListener('click', function() { if (compareSelection.length !== 2) return; var a = entries[compareSelection[0]]; var b = entries[compareSelection[1]]; if (!a || !b) return; var html = '
'; /* Headers */ html += '
'; html += '
' + (a.action || '') + ' by ' + (a.username || '') + '
'; html += '
' + fullDateTime(a.timestamp) + '
'; html += '
'; html += '
'; html += '
' + (b.action || '') + ' by ' + (b.username || '') + '
'; html += '
' + fullDateTime(b.timestamp) + '
'; html += '
'; html += '
'; /* Diff comparison */ var allFields = {}; var ac = a.changes || {}; var bc = b.changes || {}; Object.keys(ac).forEach(function(k) { allFields[k] = true; }); Object.keys(bc).forEach(function(k) { allFields[k] = true; }); var fieldNames = Object.keys(allFields); if (fieldNames.length > 0) { html += '

'; html += '
Field | Entry A | Entry B
--- | --- | ---
'; fieldNames.forEach(function(f) { var aVal = ac[f] ? (typeof ac[f] === 'object' ? (ac[f].new || ac[f].old || JSON.stringify(ac[f])) : String(ac[f])) : '—'; var bVal = bc[f] ? (typeof bc[f] === 'object' ? (bc[f].new || bc[f].old || JSON.stringify(bc[f])) : String(bc[f])) : '—'; var diff = aVal !== bVal; html += '
'; html += '
'; html += '
'; html += '
'; html += '
' + f + ' | ' + (diff ? '' : '') + aVal + (diff ? '' : '') + ' | ' + (diff ? '' : '') + bVal + (diff ? '' : '') + '
'; }); html += '

'; } else { html += '

No field changes to compare between these entries.

'; } if (compareContent) compareContent.innerHTML = html; if (compareModal) compareModal.classList.add('show'); }); } window.closeCompareModal = function() { if (compareModal) compareModal.classList.remove('show'); }; /* ── Export ─────────────────────────────────────────── */ var exportModal = document.getElementById('exportModal'); var exportBtn = document.getElementById('exportBtn'); if (exportBtn) { exportBtn.addEventListener('click', function() { if (exportModal) exportModal.classList.add('show'); }); } window.closeExportModal = function() { if (exportModal) exportModal.classList.remove('show'); }; if (exportModal) { exportModal.addEventListener('click', function(e) { if (e.target === exportModal) closeExportModal(); }); } if (compareModal) { compareModal.addEventListener('click', function(e) { if (e.target === compareModal) closeCompareModal(); }); } window.exportHistory = function(fmt) { var data = entries; var blob, filename; if (fmt === 'json') { blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); filename = modelName + '_' + pk + '_history.json'; } else { /* CSV export with all fields */ var cols = ['timestamp', 'username', 'role', 'action', 'ip_address', 'success', 'fields_changed', 'changes']; var lines = [cols.join(',')]; data.forEach(function(e) { lines.push(cols.map(function(c) { var v = c === 'changes' ? JSON.stringify(e[c] || {}) : String(e[c] != null ? e[c] : ''); return '"' + v.replace(/"/g, '""') + '"'; }).join(',')); }); blob = new Blob([lines.join('\n')], { type: 'text/csv' }); filename = modelName + '_' + pk + '_history.csv'; } var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); closeExportModal(); if (typeof showToast === 'function') { showToast('Exported ' + data.length + ' entries as ' + fmt.toUpperCase(), 'success'); } }; /* ── Keyboard shortcuts ────────────────────────────── */ document.addEventListener('keydown', function(e) { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === 'Escape') { closeExportModal(); closeCompareModal(); if (compareMode) exitCompareMode(); return; } if (e.key === 'e' || e.key === 'E') { if (expandBtn) expandBtn.click(); return; } if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey) { e.preventDefault(); if (searchInput) searchInput.focus(); return; } if (e.key === 'c' || e.key === 'C') { if (compareBtn && !compareMode) compareBtn.click(); return; } }); /* ── Auto-expand first entry with changes ──────────── */ var autoExpanded = false; for (var i = 0; i < entries.length; i++) { if (entries[i].changes && Object.keys(entries[i].changes).length > 0) { var firstCard = document.getElementById('entry-' + i); if (firstCard) { firstCard.classList.add('expanded'); autoExpanded = true; } break; } } /* If no entry has changes, expand the first one anyway */ if (!autoExpanded && entries.length > 0) { var firstCard = document.getElementById('entry-0'); if (firstCard) firstCard.classList.add('expanded'); } /* ── Live time updates (every 30s) ─────────────────── */ setInterval(function() { document.querySelectorAll('.entry-time').forEach(function(el) { var ts = el.getAttribute('data-ts'); var textEl = el.querySelector('.time-text'); if (textEl) textEl.textContent = relativeTime(ts); }); if (lastEl && entries.length > 0) { lastEl.textContent = relativeTime(entries[0].timestamp || ''); } }, 30000); })(); {% endblock %}