Ticket History System¶
Version: Arctyk ITSM v0.6.0+
Last Updated: January 2026
Overview¶
The Ticket History System in Arctyk ITSM provides a comprehensive, visual timeline of all changes made to a ticket throughout its lifecycle. Built on top of the powerful ChangeLog app, it automatically tracks field modifications, status changes, assignments, and other critical updates with full audit details.
Key Features:
- 📊 Visual Timeline - Chronological display with vertical connector line
- 🔍 Detailed Change Tracking - Old vs. new values with color coding
- 🎨 Field-Specific Icons - Visual indicators for different change types
- 📱 Responsive Design - Mobile-optimized timeline view
- 🔐 Automatic Tracking - No manual logging required via Django signals
- 👤 User Attribution - Shows who made each change with timestamp
- 🌐 IP Logging - Records IP address for security auditing
Architecture¶
ChangeLog Integration¶
The history system leverages the changelog app, a reusable Django app that tracks changes to any model via signals:
# changelog/signals.py
from django.contrib.contenttypes.models import ContentType
# Track specific models
track_models = [Asset, Ticket]
@receiver(pre_save)
def log_model_changes(sender, instance, **kwargs):
if sender in track_models:
# Capture old vs new values
# Create ChangeLog entry
pass
ChangeLog Model¶
class ChangeLog(models.Model):
content_type = ForeignKey(ContentType, on_delete=CASCADE)
object_id = PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
# Who/When/Where
changed_by = ForeignKey(User, on_delete=SET_NULL, null=True)
timestamp = DateTimeField(auto_now_add=True)
ip_address = GenericIPAddressField(null=True, blank=True)
# What Changed
change_summary = TextField() # Format: "field: 'old' → 'new'"
# Action type
action = CharField(max_length=10, choices=[
('create', 'Created'),
('update', 'Updated'),
('delete', 'Deleted'),
])
Backend Implementation¶
View Function¶
The ticket_history view retrieves and formats all changes for a ticket:
from changelog.models import ChangeLog
from django.contrib.contenttypes.models import ContentType
@login_required
def ticket_history(request, ticket_id):
ticket = get_object_or_404(Ticket, pk=ticket_id)
# Get all changelog entries for this ticket
content_type = ContentType.objects.get_for_model(Ticket)
changelog_entries = ChangeLog.objects.filter(
content_type=content_type,
object_id=ticket_id
).order_by('-timestamp')
# Parse and format changes
formatted_changes = []
for entry in changelog_entries:
parsed_changes = parse_change_summary(entry.change_summary)
formatted_changes.append({
'entry': entry,
'changes': parsed_changes,
'is_creation': entry.action == 'create',
})
return render(request, 'tickets/partials/history_list.html', {
'ticket': ticket,
'history': formatted_changes,
})
Change Parsing¶
The parse_change_summary function converts text into structured data:
def parse_change_summary(summary):
"""
Parses: "title: 'Old Title' → 'New Title'"
Returns: [{
'field': 'title',
'old_value': 'Old Title',
'new_value': 'New Title',
'display_name': 'Title',
'icon': 'bi-card-text'
}]
"""
if not summary or summary == "Initial creation":
return []
changes = []
lines = summary.strip().split('\n')
for line in lines:
match = re.match(r"^(.+?):\s*'(.*)'\s*→\s*'(.*)'$", line.strip())
if match:
field_name, old_value, new_value = match.groups()
changes.append(format_field_change(field_name, old_value, new_value))
return changes
Field Formatting¶
Maps internal field names to display names and icons:
def format_field_change(field_name, old_value, new_value):
"""Format field changes with display names and icons"""
field_map = {
'title': {'name': 'Title', 'icon': 'bi-card-text'},
'status': {'name': 'Status', 'icon': 'bi-circle-fill'},
'priority': {'name': 'Priority', 'icon': 'bi-exclamation-triangle'},
'assigned_to': {'name': 'Assigned To', 'icon': 'bi-person'},
'assigned_group': {'name': 'Assigned Group', 'icon': 'bi-people'},
'description': {'name': 'Description', 'icon': 'bi-file-text'},
'due_date': {'name': 'Due Date', 'icon': 'bi-calendar'},
'category': {'name': 'Category', 'icon': 'bi-tag'},
'project': {'name': 'Project', 'icon': 'bi-folder'},
# Add more mappings as needed
}
field_info = field_map.get(field_name, {
'name': field_name.replace('_', ' ').title(),
'icon': 'bi-arrow-repeat'
})
return {
'field': field_name,
'display_name': field_info['name'],
'icon': field_info['icon'],
'old_value': old_value or '(empty)',
'new_value': new_value or '(empty)',
}
Frontend Implementation¶
Template Structure¶
{# tickets/partials/history_list.html #}
<div class="history-timeline">
{% for item in history %}
<div class="history-entry">
{# Timeline Connector #}
<div class="timeline-connector"></div>
{# Icon Circle #}
<div class="timeline-icon {% if item.is_creation %}timeline-icon-create{% endif %}">
<i class="bi bi-{% if item.is_creation %}plus-circle{% else %}pencil{% endif %}"></i>
</div>
{# Content Card #}
<div class="history-content">
<div class="history-header">
<strong>{{ item.entry.changed_by.get_full_name }}</strong>
{% if item.is_creation %}
<span class="badge bg-success">Created Ticket</span>
{% else %}
<span class="text-muted">made changes</span>
{% endif %}
<span class="history-timestamp">{{ item.entry.timestamp|date:"M d, Y H:i" }}</span>
</div>
{# Field Changes #}
{% if item.changes %}
<div class="history-changes">
{% for change in item.changes %}
<div class="change-item">
<i class="bi {{ change.icon }} text-primary"></i>
<strong>{{ change.display_name }}:</strong>
{# Old → New Display #}
<div class="change-values">
<span class="old-value">{{ change.old_value }}</span>
<i class="bi bi-arrow-right"></i>
<span class="new-value">{{ change.new_value }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% empty %}
<p class="text-muted">No changes recorded yet.</p>
{% endfor %}
</div>
Integration in Ticket Detail¶
The history is displayed in the "History" tab of the ticket detail page:
{# tickets/ticket_detail.html #}
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" href="#comments">Comments</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#history">History</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane" id="history">
{% include "tickets/partials/history_list.html" %}
</div>
</div>
Styling¶
The history system uses a dedicated SCSS file for timeline visualization:
Key CSS Classes¶
// _history.scss
.history-timeline {
position: relative;
padding: 1rem 0;
.history-entry {
position: relative;
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
// Vertical connector line
.timeline-connector {
position: absolute;
left: 20px;
top: 40px;
bottom: -1.5rem;
width: 2px;
background: linear-gradient(to bottom, #007bff, #e9ecef);
}
// Icon circle
.timeline-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #fff;
border: 2px solid #007bff;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
&.timeline-icon-create {
border-color: #28a745;
background: #d4edda;
}
}
// Content card
.history-content {
flex: 1;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
}
}
// Change values styling
.change-values {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
.old-value {
color: #dc3545;
text-decoration: line-through;
}
.new-value {
color: #28a745;
font-weight: 500;
}
}
Design Features¶
- Vertical Timeline: Clean chronological layout
- Gradient Connector: Visual flow from newest to oldest
- Color Coding:
- Green for creation events
- Blue for updates
- Red for old values
- Green for new values
- Icons: Bootstrap Icons for field types
- Responsive: Stacks properly on mobile devices
- Cards: Elevated design with subtle shadows
Permissions¶
View Access¶
- Ticket Access: User must have permission to view the ticket
- History Tab: Visible to all users with ticket access
- Internal Changes: All users see same history (no filtering)
Tracked Changes¶
All ticket field modifications are automatically tracked:
- Title changes
- Status updates
- Priority adjustments
- Assignment changes (user/group)
- Description modifications
- Due date updates
- Category changes
- Project associations
- Custom field changes
Automatic Tracking via Signals¶
The changelog app uses Django signals for automatic tracking:
# changelog/signals.py
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
@receiver(pre_save)
def capture_changes(sender, instance, **kwargs):
if sender not in track_models:
return
# Get old instance from DB
try:
old_instance = sender.objects.get(pk=instance.pk)
except sender.DoesNotExist:
# New object
return
# Compare fields
changes = []
for field in sender._meta.fields:
old_value = getattr(old_instance, field.name)
new_value = getattr(instance, field.name)
if old_value != new_value:
changes.append(f"{field.name}: '{old_value}' → '{new_value}'")
# Store for post_save
instance._pending_changes = '\n'.join(changes)
@receiver(post_save)
def log_changes(sender, instance, created, **kwargs):
if sender not in track_models:
return
if created:
action = 'create'
summary = 'Initial creation'
else:
action = 'update'
summary = getattr(instance, '_pending_changes', 'No changes detected')
ChangeLog.objects.create(
content_object=instance,
changed_by=get_current_user(), # From middleware
ip_address=get_current_ip(),
action=action,
change_summary=summary
)
Middleware for User Context¶
The changelog app uses middleware to capture the current user:
# changelog/middleware.py
from threading import local
_thread_locals = local()
class CurrentUserMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
_thread_locals.user = getattr(request, 'user', None)
_thread_locals.ip_address = self.get_client_ip(request)
response = self.get_response(request)
# Cleanup
_thread_locals.user = None
_thread_locals.ip_address = None
return response
def get_client_ip(self, request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0]
return request.META.get('REMOTE_ADDR')
def get_current_user():
return getattr(_thread_locals, 'user', None)
def get_current_ip():
return getattr(_thread_locals, 'ip_address', None)
URL Configuration¶
# tickets/urls.py
urlpatterns = [
path('<int:ticket_id>/history/', views.ticket_history, name='ticket_history'),
# ... other ticket URLs
]
Security Features¶
- Authentication:
@login_requiredon history view - Authorization: Checks ticket access permissions
- IP Logging: Records source IP for all changes
- User Attribution: Links changes to authenticated user
- Immutable Logs: ChangeLog entries cannot be edited
- Audit Trail: Complete history cannot be deleted
Performance Considerations¶
Optimizations¶
- Select Related: Uses
select_related('changed_by')to reduce queries - Indexed Fields:
timestamp,content_type,object_idare indexed - Pagination: Large histories paginated (future enhancement)
- Caching: Consider caching parsed changes for high-traffic tickets
Database Impact¶
- One ChangeLog entry per save operation
- Minimal overhead (INSERT only, no UPDATE/DELETE)
- Regular cleanup tasks can archive old entries (optional)
Testing¶
Test coverage in src/tickets/tests/test_history.py:
class TicketHistoryTests(TestCase):
def test_ticket_creation_logged(self):
# Verify creation event logged
pass
def test_ticket_update_logged(self):
# Verify field changes tracked
pass
def test_history_view_permissions(self):
# Verify access control
pass
def test_change_parsing(self):
# Test parse_change_summary function
pass
def test_field_formatting(self):
# Test format_field_change function
pass
Run tests:
Usage Examples¶
Viewing History¶
- Navigate to ticket detail page
- Click "History" tab
- View chronological timeline of all changes
- Hover over entries for full details
Understanding Changes¶
- Green badge "Created Ticket": Initial ticket creation
- Field name with icon: Specific field changed
- Red strikethrough: Old value
- Green bold: New value
- Arrow icon: Direction of change
Example Timeline Entry¶
John Doe made changes
Nov 15, 2024 14:30
Status: 'Open' → 'In Progress'
Assigned To: '(empty)' → 'Jane Smith'
Priority: 'Medium' → 'High'
Configuration¶
Enable Tracking for Model¶
Add model to track_models list in changelog/signals.py:
Add Field Mappings¶
Update format_field_change function in tickets/views.py:
field_map = {
'custom_field': {'name': 'Custom Field', 'icon': 'bi-star'},
# ... existing mappings
}
Future Enhancements¶
Potential improvements:
- Comment integration in timeline
- Attachment tracking
- Relationship changes (linked tickets)
- Bulk change summaries
- Export history to PDF/CSV
- Advanced filtering (by user, date range, field)
- Diff view for description changes
- Restore previous values feature
Related Documentation¶
- Ticket Comments System - Communication system
- ChangeLog App - Core tracking system
- Django Signals
- Middleware Guide
Summary¶
The Ticket History System provides automatic, comprehensive tracking of all ticket changes with a beautiful visual timeline interface. It leverages the powerful ChangeLog app for signal-based tracking, requires zero manual logging, and presents changes in an intuitive, chronological format with user attribution and IP logging for complete auditability.