-
Notifications
You must be signed in to change notification settings - Fork 5.3k
[WEB-5285] feat: add ChangeTrackerMixin to track model field changes and original values #8145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,3 +1,6 @@ | ||||||
| # Type imports | ||||||
| from typing import Any | ||||||
|
|
||||||
| # Django imports | ||||||
| from django.db import models | ||||||
| from django.utils import timezone | ||||||
|
|
@@ -80,3 +83,108 @@ class AuditModel(TimeAuditModel, UserAuditModel, SoftDeleteModel): | |||||
|
|
||||||
| class Meta: | ||||||
| abstract = True | ||||||
|
|
||||||
|
|
||||||
| class ChangeTrackerMixin: | ||||||
| """ | ||||||
| A mixin to track changes in model fields between initialization and save. | ||||||
|
|
||||||
| This mixin captures the initial state of model fields when the instance is | ||||||
| created and provides utilities to detect which fields have changed. | ||||||
|
|
||||||
| Usage: | ||||||
| To track specific fields, define a TRACKED_FIELDS list on your model: | ||||||
|
|
||||||
| class MyModel(ChangeTrackerMixin, models.Model): | ||||||
| TRACKED_FIELDS = ['field1', 'field2', 'field3'] | ||||||
| field1 = models.CharField(max_length=100) | ||||||
| field2 = models.IntegerField() | ||||||
| field3 = models.BooleanField() | ||||||
|
|
||||||
| If TRACKED_FIELDS is not defined, all non-deferred fields will be tracked. | ||||||
|
|
||||||
| Properties: | ||||||
| changed_fields: A list of field names that have changed since initialization. | ||||||
| old_values: A dictionary mapping field names to their original values. | ||||||
|
|
||||||
| Methods: | ||||||
| has_changed(field_name): Check if a specific field has changed. | ||||||
|
|
||||||
| Notes: | ||||||
| - Deferred fields (from .defer() or .only()) are automatically excluded | ||||||
| from tracking to avoid triggering database queries. | ||||||
| - Field values are captured in __init__, so changes are tracked relative | ||||||
| to the initial state when the instance was loaded from the database. | ||||||
| """ | ||||||
|
|
||||||
| _original_values: dict[str, Any] | ||||||
|
|
||||||
| def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||||
| super().__init__(*args, **kwargs) | ||||||
| self._original_values = {} | ||||||
| self._track_fields() | ||||||
|
|
||||||
| def _track_fields(self) -> None: | ||||||
| """ | ||||||
| Capture the initial values of fields to track. | ||||||
|
|
||||||
| This method stores the current values of fields that should be tracked. | ||||||
| If TRACKED_FIELDS is defined on the model, only those fields are tracked. | ||||||
| Otherwise, all non-deferred fields are tracked. Deferred fields are | ||||||
| automatically excluded to prevent unnecessary database queries. | ||||||
| """ | ||||||
| deferred_fields = self.get_deferred_fields() | ||||||
| tracked_fields = getattr(self, "TRACKED_FIELDS", None) | ||||||
| if tracked_fields: | ||||||
| for field in tracked_fields: | ||||||
| if field not in deferred_fields: | ||||||
| self._original_values[field] = getattr(self, field) | ||||||
| else: | ||||||
| for field in self._meta.fields: | ||||||
| if field.attname not in deferred_fields: | ||||||
| self._original_values[field.attname] = getattr(self, field.attname) | ||||||
|
Comment on lines
+127
to
+145
|
||||||
|
|
||||||
| def has_changed(self, field_name: str) -> bool: | ||||||
| """ | ||||||
| Check if a specific field has changed since initialization. | ||||||
|
|
||||||
| Args: | ||||||
| field_name (str): The name of the field to check. | ||||||
|
|
||||||
| Returns: | ||||||
| bool: True if the field has changed, False otherwise. Returns False | ||||||
| if the field was not being tracked or is deferred. | ||||||
| """ | ||||||
| if field_name not in self._original_values: | ||||||
| return False | ||||||
| return self._original_values[field_name] != getattr(self, field_name) | ||||||
|
||||||
|
|
||||||
| @property | ||||||
| def changed_fields(self) -> list[str]: | ||||||
| """ | ||||||
| Get a list of all fields that have changed since initialization. | ||||||
|
|
||||||
| Returns: | ||||||
| list[str]: A list of field names that have different values than | ||||||
| when the instance was initialized. Returns an empty list | ||||||
| if no fields have changed. | ||||||
| """ | ||||||
| changed = [] | ||||||
| for field, old_val in self._original_values.items(): | ||||||
| new_val = getattr(self, field) | ||||||
| if old_val != new_val: | ||||||
| changed.append(field) | ||||||
| return changed | ||||||
|
Comment on lines
+172
to
+177
|
||||||
|
|
||||||
| @property | ||||||
| def old_values(self) -> dict[str, Any]: | ||||||
| """ | ||||||
| Get a dictionary of the original field values from initialization. | ||||||
|
|
||||||
| Returns: | ||||||
| dict: A dictionary mapping field names to their original values | ||||||
| as they were when the instance was initialized. Only includes | ||||||
| fields that are being tracked (either via TRACKED_FIELDS or | ||||||
| all non-deferred fields). | ||||||
| """ | ||||||
| return self._original_values | ||||||
|
||||||
| return self._original_values | |
| return self._original_values.copy() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
ChangeTrackerMixinshould inherit frommodels.Modelto ensure proper Method Resolution Order (MRO) when used with other Django model mixins. Django model mixins should typically inherit frommodels.Modelwithabstract = Truein the Meta class.Suggested fix:
This ensures compatibility with Django's model system and prevents potential MRO issues when combined with other mixins like
AuditModel.