Gyrinx uses django-simple-history to track changes to all models. This provides a comprehensive audit trail of who made what changes and when.
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
For changes made through web requests (forms, admin), the user is automatically tracked via the HistoryRequestMiddleware.
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)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 automaticallyEvery 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}")# All campaign history across all campaigns
from gyrinx.core.models.campaign import Campaign
all_history = Campaign.history.all()# Recent changes across all models
recent_campaigns = Campaign.history.filter(
history_date__gte=timezone.now() - timedelta(days=7)
)# All changes made by a specific user
user_changes = Campaign.history.filter(history_user=user)# Get differences between versions
campaign = Campaign.objects.get(id=some_id)
diff = campaign.get_history_diff() # Compare latest with previousStandard 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)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
)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)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 == userHistory records accumulate over time. Consider:
- Periodic cleanup of old history records
- Indexing on
history_dateandhistory_user - Monitoring database size growth
- Use
select_related()when accessing history users - Filter history queries by date ranges when possible
- Consider pagination for large history sets
If history_user is None:
- Ensure
HistoryRequestMiddlewareis inMIDDLEWAREsettings - Use
save_with_user()orcreate_with_user()for programmatic changes - Check that the user is authenticated in the request
Standard bulk operations don't trigger signals that create history:
- Use
bulk_create_with_history()instead ofbulk_create() - Use
update_with_user()instead ofupdate()
If no history records are created:
- Ensure the model inherits from
AppBase - Check that
simple_historyis inINSTALLED_APPS - Verify the model has
history = HistoricalRecords()