History Tracking
Gyrinx uses django-simple-history to track changes to all models. This provides a comprehensive audit trail of who made what changes and when.
Overview
All models that inherit from AppBase
automatically have history tracking enabled. This means:
Every create, update, and delete operation is recorded
The user who made the change is tracked (when possible)
Full historical state is preserved
Changes can be queried and compared
Automatic User Tracking
Web Requests
For changes made through web requests (forms, admin), the user is automatically tracked via the HistoryRequestMiddleware
.
Programmatic Changes
For changes made in code (management commands, scripts), you need to explicitly provide the user:
# Using save_with_user (defaults to owner if no user provided)
campaign = Campaign(name="My Campaign", owner=user)
campaign.save_with_user(user=admin_user)
# Using create_with_user (defaults to owner if no user provided)
campaign = Campaign.objects.create_with_user(
user=admin_user,
name="My Campaign",
owner=user
)
# Using bulk operations with history
campaigns = [Campaign(name=f"Campaign {i}", owner=user) for i in range(3)]
Campaign.bulk_create_with_history(campaigns, user=admin_user)
Default User Behavior
When no explicit user is provided, the system uses the object's owner
as the history user:
# These will use the owner as the history user
campaign = Campaign.objects.create_with_user(
name="My Campaign",
owner=user # This will be used as history user
)
campaign.save_with_user() # Uses campaign.owner automatically
History Models
Every model with history tracking gets a corresponding historical model:
# Original model
campaign = Campaign.objects.get(id=some_id)
# Access history
history = campaign.history.all() # All historical records
latest = campaign.history.first() # Most recent change
oldest = campaign.history.last() # First record
# History record fields
for record in history:
print(f"Change type: {record.history_type}") # +, ~, or -
print(f"Changed by: {record.history_user}")
print(f"Changed at: {record.history_date}")
print(f"Change reason: {record.history_change_reason}")
Querying History
All History for a Model
# All campaign history across all campaigns
from gyrinx.core.models.campaign import Campaign
all_history = Campaign.history.all()
Recent Changes
# Recent changes across all models
recent_campaigns = Campaign.history.filter(
history_date__gte=timezone.now() - timedelta(days=7)
)
Changes by User
# All changes made by a specific user
user_changes = Campaign.history.filter(history_user=user)
Comparing Versions
# Get differences between versions
campaign = Campaign.objects.get(id=some_id)
diff = campaign.get_history_diff() # Compare latest with previous
Bulk Operations
Standard Django bulk operations don't create history records:
# No history created
Campaign.objects.bulk_create([...])
Campaign.objects.filter(...).update(...)
Use the history-aware methods instead:
# Creates history records
Campaign.bulk_create_with_history(campaigns, user=user)
Campaign.objects.filter(...).update_with_user(user=user, field=value)
Best Practices
Management Commands
Always provide a user for history tracking in management commands:
class Command(BaseCommand):
def handle(self, *args, **options):
admin_user = User.objects.get(username='admin')
# Use history-aware methods
campaign = Campaign.objects.create_with_user(
user=admin_user,
name="Generated Campaign",
owner=some_user
)
Data Migrations
For data migrations that create or modify records, ensure history is tracked:
def migrate_data(apps, schema_editor):
Campaign = apps.get_model('core', 'Campaign')
User = apps.get_model('auth', 'User')
admin_user = User.objects.get(username='admin')
for campaign in Campaign.objects.all():
campaign.save_with_user(user=admin_user)
Testing
History records are created during tests, so account for them:
@pytest.mark.django_db
def test_campaign_history():
user = User.objects.create_user(username="test", password="test")
campaign = Campaign.objects.create_with_user(
user=user,
name="Test",
owner=user
)
# Check history was created
history = campaign.history.all()
assert history.count() == 1
assert history.first().history_user == user
Performance Considerations
History Volume
History records accumulate over time. Consider:
Periodic cleanup of old history records
Indexing on
history_date
andhistory_user
Monitoring database size growth
Query Optimization
Use
select_related()
when accessing history usersFilter history queries by date ranges when possible
Consider pagination for large history sets
Troubleshooting
Missing User Information
If history_user
is None
:
Ensure
HistoryRequestMiddleware
is inMIDDLEWARE
settingsUse
save_with_user()
orcreate_with_user()
for programmatic changesCheck that the user is authenticated in the request
Bulk Operations Not Tracked
Standard bulk operations don't trigger signals that create history:
Use
bulk_create_with_history()
instead ofbulk_create()
Use
update_with_user()
instead ofupdate()
History Not Created
If no history records are created:
Ensure the model inherits from
AppBase
Check that
simple_history
is inINSTALLED_APPS
Verify the model has
history = HistoricalRecords()
Last updated