Recently, our team implemented a custom solution to track user changes on a Django model. This post documents the reason, thinking, and implementation of our approach.
The Need
The Django project we work on keeps track of data in a number of models, but specifically, it was important to track user actions to and changes on a particular model (BuildingInspection). We needed to track the action the user was taking, the user taking the action, the date/time the change is made, and the field value(s) being made in the change. Subsequently, we needed to display the data on a dashboard for specific users, so that those users can review and take actions based on the changes.
Research
A number of packages already allow tracking model history. Here are some popular Django change tracking libraries we found, with some notes on each:
django-simple-history
Best for: Straightforward auditing with full model snapshots
- Pros: Easy setup with
HistoricalRecords()field, excellent admin integration, comprehensive features - Cons: Saves an entire model's snapshot, which is a lot of data to be adding to the database over time, bulk operations are not tracked,
F()expressions are not supported
django-auditlog
Best for: Lightweight change tracking with JSON diffs
- Pros: Minimal setup, stores only changes as JSON diffs, designed for performance, stores less data than full snapshots
- Cons: Bulk operations are missed, many-to-many relationship issues (especially admin inline M2M changes), no rollback functionality, doesn't capture file contents
django-reversion
Best for: Full version control with rollback capabilities
- Pros: Complete versioning system with rollback support, mature and well-maintained library
- Cons: Bulk operations not tracked, multi-table inheritance complications, more complex setup, uses a lot of resources
django-field-history
Best for: Individual field change tracking (Note: Limited maintenance)
- Pros: Granular field-level tracking, bulk_create optimization for performance
- Cons: Not well maintained, ManyToManyField not supported, MySQL compatibility issues, limited Django version support
Library Limitations
We found that signal-based libraries share common limitations:
- Bulk Operations:
bulk_create(),bulk_update(), andQuerySet.update()operations are not tracked - Performance: adding the libraries' extra code to model save operations could cause performance issues
- Storage: All require strategies for maintaining and tracking logs
- Many-to-many relationships turn out to be problematic
Decision
After looking at the available packages and their pros and cons, we decided that none of the available options offered the customization we needed for tracking users' changes, so we designed our own approach.
Our specific requirements that led to this decision:
- Action-based tracking: We needed to track not just field changes, but specific user actions (status changes, PDF creation, photo additions)
- Custom metadata: Each change needed to store related model references and action-specific data
- Selective tracking: Only certain actions on our
BuildingInspectionmodel needed tracking, not every field change - Limiting data added to database: We wanted to avoid a rapid expansion of our database and avoid tracking changes to fields that were not going to be relevant for our users
Implementation
Our custom solution centers around a BuildingInspectionChange model that captures data for user actions, rather than changes for all fields:
class BuildingInspectionChange(models.Model):
class Actions(models.TextChoices):
CREATE_PDF = "create-pdf", "Create PDF"
DOWNLOAD_PDF = "download-pdf", "View / Download PDF"
STATUS_REVIEW = "status-review", "Ready for review"
STATUS_COMPLETE = "status-complete", "Completed"
STATUS_DRAFT = "status-draft", "Revert to Draft"
EDIT = "edit", "Edit"
PHOTO = "add-photo", "Attached Photo"
REMOVE = "remove", "Remove"
ADD_SECTION = "add-section", "Add Section"
REMOVE_SECTION = "remove-section", "Remove Section"
building_inspection = models.ForeignKey(BuildingInspection, on_delete=models.CASCADE, related_name="changes")
created_at = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
action = models.CharField(max_length=20, choices=Actions.choices)
related_model = models.CharField(
max_length=255,
blank=True,
help_text="`app.Model:id` representation of related data being edited.",
)
related_str = models.CharField(
max_length=255,
blank=True,
help_text="Used to reference related data being edited, like equipment.",
)
fields = models.JSONField(
default=list, blank=True, help_text="A list of fields that were edited."
)
related_data = models.JSONField(default=dict, blank=True, help_text="The data of this change.")
class Meta:
ordering = ["-created_at"]
Key Design Decisions
- Action-Centered: Instead of tracking every field change, we track user actions to know what the user was actually doing.
- Flexible Metadata: The
related_model,related_str, fields, andrelated_dataJSON fields allow us to store context-specific information for each action type. - Manual Tracking: We explicitly create
BuildingInspectionChangerecords in our business logic, giving us control over what gets tracked and when. - Easy to Display: Rather than trying to figure out how to get another library's data into a dashboard of changes, we built the data model to easily be able to display this information on a dashboard.
- Security Benefit: using our own library also gives us an extra benefit of not being vulnerable to supply chain attacks by avoiding an additional third party dependency.
Usage Examples
After creating a Django model for the changes, we needed to decide on how and when to create the BuildingInspectionChanges.
A few options:
- in the Django model, either in the
BuildingInspection.save()method, or with pre-save or post-save signals - in the view that calls the
BuildingInspection.save()method
Since our user actions included not only changes to the BuildingInspection model fields, but also creating or deleting other related objects (images, sections), we decided to add the relevant code to the views that handled updating building inspections, creating or deleting images, creating or deleting sections.
Updating a BuildingInspection's status in the view for editing a building inspection:
from django.forms.models import model_to_dict
...
new_sections = ... # From the request data
# Add changes to the building_inspection.
for section in new_sections:
building_inspection.changes.create(
user=self.request.user,
action=BuildingInspectionChange.Actions.ADD_SECTION,
related_model=f"{BuildingInspectionSection._meta.label}:{section.pk}",
related_str=str(section),
related_data=str(model_to_dict(section)),
)
Creating a PDF for a BuildingInspection:
building_inspection.changes.create(
action=BuildingInspectionChange.Actions.CREATE_PDF,
user=request.user
)
Limitations
While our custom solution meets our specific needs, it comes with trade-offs that should be carefully considered:
- Manual Maintenance: We must remember to add tracking calls to new features and view logic
- No Automatic Rollback: Unlike libraries like
django-reversion, we don't get built-in rollback capabilities
Considerations When Choosing An Approach:
When to Consider This Approach:
- You need to track specific user actions rather than all model changes
- Database storage efficiency is a priority
- You need custom metadata or context with each change
- Your team can commit to maintaining manual tracking calls
- Existing libraries don't fit your specific auditing requirements
When to Use Existing Libraries:
- You need comprehensive, automatic change tracking
- Rollback functionality is important
- You prefer minimal maintenance
- Typical field-level tracking meets your needs
Conclusion
While existing Django change tracking libraries offer helpful features, our specific requirements for action-based data made it easier both to build a custom solution, and to give users the ability to take actions based on the data.