Skip to content

[WEB-5285] feat: add ChangeTrackerMixin to track model field changes and original values#8145

Merged
sriramveeraghanta merged 1 commit intopreviewfrom
feat-model-change-tracking
Nov 20, 2025
Merged

[WEB-5285] feat: add ChangeTrackerMixin to track model field changes and original values#8145
sriramveeraghanta merged 1 commit intopreviewfrom
feat-model-change-tracking

Conversation

@dheeru0198
Copy link
Copy Markdown
Member

@dheeru0198 dheeru0198 commented Nov 20, 2025

Description

This PR introduces ChangeTrackerMixin, a Django model mixin that enables tracking of field changes between model initialization and save operations. This provides a clean way to detect which fields have been modified on model instances.

Features

  • Selective Tracking: Define TRACKED_FIELDS to track specific fields, or track all non-deferred fields by default
  • Deferred Field Handling: Automatically excludes deferred fields to avoid unnecessary database queries
  • API:
    • has_changed(field_name: str) -> bool: Check if a specific field changed
    • changed_fields: list[str]: Get all changed field names
    • old_values: dict[str, Any]: Get original field values

Usage

class Issue(ChangeTrackerMixin, models.Model):
    TRACKED_FIELDS = ['status', 'assignee']  # Optional: track specific fields
    title = models.CharField(max_length=255)
    status = models.CharField(max_length=50)

# Later in code
issue = Issue.objects.get(pk=1)
issue.status = "In Progress"

if issue.has_changed("status"):
    print(f"Status changed from '{issue.old_values['status']}'")
print(f"Changed fields: {issue.changed_fields}")  # ['status']

Use Cases

  • Audit logging for changed fields only
  • Conditional notifications based on field changes
  • Webhook triggers for specific field modifications
  • Business logic dependent on change detection

Notes

  • Non-breaking addition - existing models unaffected
  • Compatible with other mixins (AuditModel, SoftDeleteModel, etc.)
  • Minimal performance impact (stores values in memory)

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Feature (non-breaking change which adds functionality)
  • Improvement (change that would cause existing functionality to not work as expected)
  • Code refactoring
  • Performance improvements
  • Documentation update

Screenshots and Media (if applicable)

Test Scenarios

References

Summary by CodeRabbit

  • Chores
    • Enhanced internal database infrastructure to improve system reliability and data consistency tracking capabilities.

✏️ Tip: You can customize this high-level summary in your review settings.

Copilot AI review requested due to automatic review settings November 20, 2025 09:05
@makeplane
Copy link
Copy Markdown

makeplane bot commented Nov 20, 2025

Linked to Plane Work Item(s)

This comment was auto-generated by Plane

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Nov 20, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

A new ChangeTrackerMixin class is added to track field changes from model initialization. The mixin captures original field values, provides methods to query changes, and excludes deferred fields from tracking.

Changes

Cohort / File(s) Summary
Field Change Tracking Mixin
apps/api/plane/db/mixins.py
Adds ChangeTrackerMixin class with field change detection, storing original values at initialization and exposing has_changed() method, changed_fields property, and old_values property for querying modifications.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

  • Field tracking logic: Verify correct identification of tracked fields (TRACKED_FIELDS vs. non-deferred fields)
  • Initialization state capture: Ensure _track_fields() properly stores original values without side effects
  • Change detection accuracy: Confirm has_changed() and property implementations correctly compare current and original values
  • Mixin integration: Check compatibility with existing audit-related mixins and model inheritance patterns

Poem

🐰 Hop, hop! A tracker's born today,
Remembering what fields used to say,
Changed or not? Just ask the mixin wise,
Original values are its prize! ✨

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat-model-change-tracking

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1eaa48c and c111a05.

📒 Files selected for processing (1)
  • apps/api/plane/db/mixins.py (2 hunks)

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sriramveeraghanta sriramveeraghanta merged commit f510020 into preview Nov 20, 2025
9 of 13 checks passed
@sriramveeraghanta sriramveeraghanta deleted the feat-model-change-tracking branch November 20, 2025 09:06
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces a ChangeTrackerMixin for Django models to track field changes between model initialization and save operations, providing a clean API for detecting which fields have been modified.

Key Changes:

  • Added ChangeTrackerMixin class with change detection capabilities
  • Provides has_changed(), changed_fields, and old_values APIs
  • Supports selective field tracking via TRACKED_FIELDS attribute
  • Automatically excludes deferred fields to prevent unnecessary database queries

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

fields that are being tracked (either via TRACKED_FIELDS or
all non-deferred fields).
"""
return self._original_values
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning the internal _original_values dictionary directly exposes it to external modification. Consider returning a copy to prevent unintended mutations:

@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.copy()

This prevents callers from accidentally modifying the tracked values, which could lead to incorrect change detection.

Suggested change
return self._original_values
return self._original_values.copy()

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +177
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
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The equality comparison old_val != new_val may not work correctly for mutable field types like JSONField, ArrayField, or related objects. Django field values for these types might be different object instances with the same content, leading to false positives for changes.

Consider using Django's field comparison or deep equality checks for complex field types. For example:

@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_name, old_val in self._original_values.items():
        new_val = getattr(self, field_name)
        # Use field-specific comparison for complex types
        field = self._meta.get_field(field_name)
        if hasattr(field, 'to_python'):
            # Compare using field's internal comparison
            if old_val != new_val:
                changed.append(field_name)
        else:
            if old_val != new_val:
                changed.append(field_name)
    return changed

This ensures accurate change detection for all Django field types, including JSON fields which are commonly used in this codebase (as seen in models like Integration and WorkspaceIntegration).

Copilot uses AI. Check for mistakes.
"""
if field_name not in self._original_values:
return False
return self._original_values[field_name] != getattr(self, field_name)
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The equality comparison in has_changed may not work correctly for mutable field types like JSONField, ArrayField, or related objects, which are commonly used in this codebase. This is the same issue as in the changed_fields property.

The comparison should be consistent with the changed_fields property to ensure has_changed(field) and field in changed_fields always return the same result.

Copilot uses AI. Check for mistakes.
Comment on lines +127 to +145
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)
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing field values directly with getattr(self, field) creates a shallow reference to mutable objects (like lists from ArrayField or dicts from JSONField). If the original object is mutated in place (e.g., issue.metadata['key'] = 'value'), the tracked value will also change, preventing accurate change detection.

Consider deep copying mutable field values during tracking:

import copy

def _track_fields(self) -> None:
    """
    Capture the initial values of fields to track.
    ...
    """
    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:
                value = getattr(self, field)
                # Deep copy mutable types to prevent reference issues
                self._original_values[field] = copy.deepcopy(value) if isinstance(value, (dict, list)) else value
    else:
        for field in self._meta.fields:
            if field.attname not in deferred_fields:
                value = getattr(self, field.attname)
                self._original_values[field.attname] = copy.deepcopy(value) if isinstance(value, (dict, list)) else value

This ensures that in-place mutations don't affect the original tracked values.

Copilot uses AI. Check for mistakes.
abstract = True


class ChangeTrackerMixin:
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ChangeTrackerMixin should inherit from models.Model to ensure proper Method Resolution Order (MRO) when used with other Django model mixins. Django model mixins should typically inherit from models.Model with abstract = True in the Meta class.

Suggested fix:

class ChangeTrackerMixin(models.Model):
    """
    A mixin to track changes in model fields between initialization and save.
    ...
    """
    
    _original_values: dict[str, Any]
    
    class Meta:
        abstract = True
    
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        ...

This ensures compatibility with Django's model system and prevents potential MRO issues when combined with other mixins like AuditModel.

Copilot uses AI. Check for mistakes.
ClarenceChen0627 pushed a commit to ClarenceChen0627/plane that referenced this pull request Dec 5, 2025
ClarenceChen0627 pushed a commit to ClarenceChen0627/plane that referenced this pull request Dec 5, 2025
ClarenceChen0627 pushed a commit to ClarenceChen0627/plane that referenced this pull request Dec 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants