Repository: nautobot/nautobot-app-golden-config (language: python)

Issue:
Duplicate GC entries for devices when platform changes
### Environment
* Python version:  <!-- Example: 3.7.7 --> 3.9.6
* Nautobot version:  <!-- Example: 1.0.1 --> 1.3.7 and 1.4.2
* nautobot-golden-config version:  <!-- Example: 1.0.0 --> 1.1.3 and 1.2.0

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current release.
-->
### Steps to Reproduce
1. Set up Nautobot with 2 platforms: IOS and IOS-XE
2. Set up at least 1 Golden Config features and rules for both platforms.
3. Create a device using platform IOS
4. Run all 3 Golden Config jobs for the device (backup, intended, compliance)
5. In Nautobot, go to Golden Config --> Configuration Compliance.  Select the device.
6. At the top of the page, note the red/green boxes showing "compliant/non-compliant" for each feature. There should be one box for each IOS feature configured.
7. Change the assigned platform to the device from IOS to IOS-XE
8. Rerun all 3 Golden Config jobs (from step 4)
9. Go back to the Configuration Compliance page, and select the same device again
10. At the top of the page, you will now see twice as many red/green boxes.  One box for each IOS feature, and another box for each IOS-XE feature.

<!-- What did you expect to happen? -->
### Expected Behavior
After changing the platform on a device (like from IOS to IOS-XE), all of the golden config objects should be dropped from the old platform.

<!-- What happened instead? -->
### Observed Behavior
The data from the original platform remains, and is only removed when I delete the config compliance data from a device and rerun the compliance job.

This causes duplicate features to be reported for a device that has changed platforms (until it can be manually deleted and the compliance job reran).

This also skews the Compliance Reporting numbers, where each device with double-platform data shows double numbers in the report.


Discussion / hints:
Can you confirm:
* This is not a dynamic group issue
* This is not a caching issue

e.g. if you go to dynamic group after you make such a change, do you see the same issues? If so, can you open an issue with Core. 
Notice DNS is twice (as well as other features)
<img width="1481" alt="image" src="https://user-images.githubusercontent.com/9260483/194072380-f2bc9e60-f477-4613-9654-639355c6fa49.png">

Will have to fix it here: https://github.com/nautobot/nautobot-plugin-golden-config/blob/fa4c7c2407187a6eac79855e90ef736fc4e4968c/nautobot_golden_config/models.py#L321
> Can you confirm:
> 
> * This is not a dynamic group issue
> * This is not a caching issue
> 
> e.g. if you go to dynamic group after you make such a change, do you see the same issues? If so, can you open an issue with Core.

I don't believe this has anything to do with Dynamic Groups.  I am seeing this on devices setup when no dynamic groups are configured in Nautobot.

I also don't believe it's a caching issue, as it occurs even in a new/incognito browser.  As for it being a CacheOps issue, usually those go away when a modification is made to the relevant database table (see Nautobot issue[ #1627](https://github.com/nautobot/nautobot/issues/1627) for details).  So I don't _believe_ it's cacheops related, but am not certain.

Fix patch:
diff --git a/docs/dev/dev_adr.md b/docs/dev/dev_adr.md
index fa7ae6a1..a343c8e1 100644
--- a/docs/dev/dev_adr.md
+++ b/docs/dev/dev_adr.md
@@ -84,7 +84,7 @@ The filtering logic happens in the `get_job_filter` function. Any consumer (job/
 
 ## Diff logic
 
-There is a function mapper for the diff logic. This allows for the diff logic to take on different proccesses for cli, json, and custom. This is enforced on the save method of `ConfigCompliance`.
+There is a function mapper for the diff logic. This allows for the diff logic to take on different processes for cli, json, and custom. This is enforced on the save method of `ConfigCompliance`.
 
 ## Dynamic Group
 
@@ -121,7 +121,7 @@ The PROCESSING feature, that is enabled as a Dynamic Application Feature, expose
 
 Both API views, as commented, are only targeting ONE single device because being a synchronous operation (versus the rest of the features that are run asynchronously as Jobs), it could take too much time, and have an undesired impact in Nautobot performance.
 
-All the functions used in the post-processing chain require a consitent signature:
+All the functions used in the post-processing chain require a consistent signature:
 `func(config_postprocessing: str, configs: models.GoldenConfig, request: HttpRequest) -> str`.
 
 - `config_postprocessing: str`: it's the reference configuration to use as template to render.
@@ -137,3 +137,9 @@ The API view, under the path `config-postprocessing`, uses custom permissions, n
 It was decided to restrict the usage of Jinja filters to only the ones related to getting Nautobot secrets values (defined here), plus the `encrypt_type5` and `encrypt_type7` filters from Netutils. Remember that this function is not defined to replace the regular Jinja rendering done for creating the Intended configuration, only to add secrets information on the fly. This avoids undesired behavior on this synchronous operation.
 
 This function performs an additional permission validation, to check if the requesting user has permissions to view the `SecretsGroup` requested.
+
+### Configuration Compliance 
+
+Over time device(s) platform may change; whether this is a device refresh or full replacement. A Django `post_save` signal is used on the `ConfigCompliance` model and provides a reliable and efficient way to manage configuration compliance objects. This signal deletes any `ConfigCompliance` objects that don't match the current platform. This decision was made to avoid compliance reporting inconsistencies that can arise when outdated or irrelevant objects remain in the database which were generated with the previous platform.
+
+This has a computational impact when updating a Device object's platform. This is similar to the computational impact of an SQL `cascade` option on a delete. This is largely unavoidable and should be limited in impact, such that it will only be the removal of the number of `ConfigCompliance` objects, which is no bigger than the number of  `Config Features`, which is generally intended to be a small amount.
diff --git a/nautobot_golden_config/__init__.py b/nautobot_golden_config/__init__.py
index 342b9ca8..e24efeb3 100644
--- a/nautobot_golden_config/__init__.py
+++ b/nautobot_golden_config/__init__.py
@@ -8,6 +8,7 @@
 
 __version__ = metadata.version(__name__)
 
+from django.db.models.signals import post_migrate
 from nautobot.extras.plugins import PluginConfig
 
 
@@ -37,5 +38,14 @@ class GoldenConfig(PluginConfig):
         "get_custom_compliance": None,
     }
 
+    def ready(self):
+        """Register custom signals."""
+        from nautobot_golden_config.models import ConfigCompliance  # pylint: disable=import-outside-toplevel
+
+        from .signals import config_compliance_platform_cleanup  # pylint: disable=import-outside-toplevel
+
+        super().ready()
+        post_migrate.connect(config_compliance_platform_cleanup, sender=ConfigCompliance)
+
 
 config = GoldenConfig  # pylint:disable=invalid-name
diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py
index d6a1908f..d44fba14 100644
--- a/nautobot_golden_config/models.py
+++ b/nautobot_golden_config/models.py
@@ -1,24 +1,23 @@
 """Django Models for tracking the configuration compliance per feature and device."""
 
-import logging
 import json
+import logging
+
 from deepdiff import DeepDiff
-from django.db import models
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
+from django.db import models
 from django.shortcuts import reverse
 from django.utils.module_loading import import_string
 from django.utils.text import slugify
-
-from nautobot.extras.models import ObjectChange, DynamicGroup
+from nautobot.core.models.generics import PrimaryModel
+from nautobot.extras.models import DynamicGroup, ObjectChange
 from nautobot.extras.utils import extras_features
 from nautobot.utilities.utils import serialize_object, serialize_object_v2
-from nautobot.core.models.generics import PrimaryModel
 from netutils.config.compliance import feature_compliance
-
 from nautobot_golden_config.choices import ComplianceRuleTypeChoice
+from nautobot_golden_config.utilities.constant import ENABLE_SOTAGG, PLUGIN_CFG
 from nautobot_golden_config.utilities.utils import get_platform
-from nautobot_golden_config.utilities.constant import PLUGIN_CFG, ENABLE_SOTAGG
 
 
 LOGGER = logging.getLogger(__name__)
diff --git a/nautobot_golden_config/signals.py b/nautobot_golden_config/signals.py
new file mode 100755
index 00000000..d099c75f
--- /dev/null
+++ b/nautobot_golden_config/signals.py
@@ -0,0 +1,16 @@
+"""Signal helpers."""
+
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from nautobot.dcim.models import Platform
+from nautobot_golden_config import models
+
+
+@receiver(post_save, sender=models.ConfigCompliance)
+def config_compliance_platform_cleanup(sender, instance, **kwargs):  # pylint: disable=unused-argument
+    """Signal helper to delete any orphaned ConfigCompliance objects. Caused by device platform changes."""
+    cc_wrong_platform = models.ConfigCompliance.objects.filter(device=instance.device).filter(
+        rule__platform__in=Platform.objects.exclude(slug=instance.device.platform.slug)
+    )
+    if cc_wrong_platform.count() > 0:
+        cc_wrong_platform.delete()


Test patch:
diff --git a/nautobot_golden_config/tests/test_models.py b/nautobot_golden_config/tests/test_models.py
index 18097803..1af5ee15 100644
--- a/nautobot_golden_config/tests/test_models.py
+++ b/nautobot_golden_config/tests/test_models.py
@@ -1,21 +1,15 @@
 """Unit tests for nautobot_golden_config models."""
 
-from django.test import TestCase
-from django.db.utils import IntegrityError
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
+from django.db.utils import IntegrityError
+from django.test import TestCase
 from nautobot.dcim.models import Platform
-from nautobot.extras.models import GitRepository, GraphQLQuery, DynamicGroup
+from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery
+from nautobot_golden_config.models import ConfigCompliance, ConfigRemove, ConfigReplace, GoldenConfigSetting
 from nautobot_golden_config.tests.conftest import create_git_repos
 
-from nautobot_golden_config.models import (
-    ConfigCompliance,
-    GoldenConfigSetting,
-    ConfigRemove,
-    ConfigReplace,
-)
-
-from .conftest import create_device, create_feature_rule_json, create_config_compliance, create_saved_queries
+from .conftest import create_config_compliance, create_device, create_feature_rule_json, create_saved_queries
 
 
 class ConfigComplianceModelTestCase(TestCase):
@@ -74,6 +68,26 @@ def test_create_config_compliance_success_compliant(self):
         self.assertEqual(cc_obj.missing, "")
         self.assertEqual(cc_obj.extra, "")
 
+    def test_config_compliance_signal_change_platform(self):
+        """Make sure signal is working."""
+        ConfigCompliance.objects.create(
+            device=self.device,
+            rule=self.compliance_rule_json,
+            actual={"foo": {"bar-1": "baz"}},
+            intended={"foo": {"bar-1": "baz"}},
+        )
+        self.assertEqual(ConfigCompliance.objects.filter(device=self.device).count(), 1)
+        self.device.platform = Platform.objects.create(name="Platform Change", slug="platform-change")
+        new_rule_json = create_feature_rule_json(self.device)
+
+        ConfigCompliance.objects.create(
+            device=self.device,
+            rule=new_rule_json,
+            actual={"foo": {"bar-1": "baz"}},
+            intended={"foo": {"bar-1": "baz"}},
+        )
+        self.assertEqual(ConfigCompliance.objects.filter(device=self.device).count(), 1)
+
 
 class GoldenConfigTestCase(TestCase):
     """Test GoldenConfig Model."""