Skip to content

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

  1. Authentication: @login_required on history view
  2. Authorization: Checks ticket access permissions
  3. IP Logging: Records source IP for all changes
  4. User Attribution: Links changes to authenticated user
  5. Immutable Logs: ChangeLog entries cannot be edited
  6. 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_id are 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:

pytest src/tickets/tests/test_history.py

Usage Examples

Viewing History

  1. Navigate to ticket detail page
  2. Click "History" tab
  3. View chronological timeline of all changes
  4. 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:

from myapp.models import MyModel

track_models = [Asset, Ticket, MyModel]

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


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.