diff --git a/apps/api/plane/db/mixins.py b/apps/api/plane/db/mixins.py index ca3c9a2d3..be5613b61 100644 --- a/apps/api/plane/db/mixins.py +++ b/apps/api/plane/db/mixins.py @@ -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) + + 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 + + @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