{% for item in compliance_details %}
- {% if item.compliance %}
+ {% if item.compliance == None %}
+ |
+ {% elif item.compliance %}
|
{% else %}
|
@@ -120,7 +123,9 @@
| Status |
- {% if item.rule.config_ordered %}
+ {% if item.compliance == None %}
+ N/A |
+ {% elif item.rule.config_ordered %}
{% if item.compliance %}
Compliant |
{% else %}
diff --git a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_update.html b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_update.html
index d2252aa76..d586545b3 100644
--- a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_update.html
+++ b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_update.html
@@ -10,6 +10,7 @@
{% render_field form.weight %}
{% render_field form.description %}
{% render_field form.dynamic_group %}
+ {% render_field form.empty_compliance_behavior %}
diff --git a/nautobot_golden_config/tests/forms/test_golden_config_settings.py b/nautobot_golden_config/tests/forms/test_golden_config_settings.py
index cd47ba9f5..c2de8623b 100644
--- a/nautobot_golden_config/tests/forms/test_golden_config_settings.py
+++ b/nautobot_golden_config/tests/forms/test_golden_config_settings.py
@@ -43,6 +43,7 @@ def test_no_query_no_scope_success(self):
"intended_path_template": "{{ obj.location.name }}/{{ obj.name }}.cfg",
"backup_test_connectivity": True,
"dynamic_group": dynamic_group.pk,
+ "empty_compliance_behavior": "validated",
}
)
self.assertTrue(form.is_valid(), form.errors)
@@ -63,11 +64,40 @@ def test_no_query_fail(self):
"intended_path_template": "{{ obj.location.name }}/{{ obj.name }}.cfg",
"backup_test_connectivity": True,
"dynamic_group": DynamicGroup.objects.first(),
+ "empty_compliance_behavior": "validated",
}
)
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["__all__"][0], "A GraphQL query must be defined when `ENABLE_SOTAGG` is True")
+ def test_empty_compliance_behavior_valid_choices(self):
+ """All three empty_compliance_behavior choices should be accepted by the form."""
+ for choice in ("validated", "empty_both", "empty_intended"):
+ GoldenConfigSetting.objects.all().delete()
+ dynamic_group = DynamicGroup.objects.create(
+ name=f"DG {choice}",
+ filter={},
+ content_type=ContentType.objects.get_for_model(Device),
+ )
+ with mock.patch("nautobot_golden_config.models.ENABLE_SOTAGG", False):
+ form = GoldenConfigSettingForm(
+ data={
+ "name": f"test_{choice}",
+ "slug": f"test_{choice}",
+ "weight": 1000,
+ "description": "Test description.",
+ "backup_repository": GitRepository.objects.get(name="test-backup-repo-1"),
+ "backup_path_template": "{{ obj.location.name }}/{{obj.name}}.cfg",
+ "intended_repository": GitRepository.objects.get(name="test-intended-repo-1"),
+ "intended_path_template": "{{ obj.location.name }}/{{ obj.name }}.cfg",
+ "backup_test_connectivity": True,
+ "dynamic_group": dynamic_group.pk,
+ "empty_compliance_behavior": choice,
+ }
+ )
+ self.assertTrue(form.is_valid(), f"Form invalid for choice '{choice}': {form.errors}")
+ self.assertTrue(form.save())
+
def test_clean_up(self):
"""Transactional custom model, unable to use `get_or_create`.
diff --git a/nautobot_golden_config/tests/test_models.py b/nautobot_golden_config/tests/test_models.py
index e94bb3c8d..f996b3e56 100644
--- a/nautobot_golden_config/tests/test_models.py
+++ b/nautobot_golden_config/tests/test_models.py
@@ -9,7 +9,7 @@
from nautobot.dcim.models import Platform
from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status
-from nautobot_golden_config.choices import RemediationTypeChoice
+from nautobot_golden_config.choices import EmptyComplianceBehaviorChoice, RemediationTypeChoice
from nautobot_golden_config.models import (
ConfigCompliance,
ConfigPlan,
@@ -24,6 +24,7 @@
from .conftest import (
create_config_compliance,
create_device,
+ create_feature_rule_cli,
create_feature_rule_cli_with_remediation,
create_feature_rule_json,
create_feature_rule_xml,
@@ -734,3 +735,104 @@ def test_hierconfig_instantiation_error(self, mock_workflow_remediation, mock_ge
mock_get_hconfig.assert_called_once()
# WorkflowRemediation should never be called since get_hconfig raises exception
mock_workflow_remediation.assert_not_called()
+
+
+class EmptyComplianceBehaviorTestCase(TestCase):
+ """Test configurable empty compliance behavior on GoldenConfigSetting."""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Set up base objects."""
+ create_git_repos()
+ create_saved_queries()
+ cls.device = create_device()
+ cls.compliance_rule_cli = create_feature_rule_cli(cls.device)
+ cls.dynamic_group = DynamicGroup.objects.create(
+ name="test_empty_compliance",
+ content_type=ContentType.objects.get_for_model(cls.device),
+ filter={},
+ )
+
+ def _create_setting(self, behavior):
+ """Helper to create GoldenConfigSetting with given behavior."""
+ GoldenConfigSetting.objects.all().delete()
+ return GoldenConfigSetting.objects.create(
+ name="test_empty",
+ slug="test_empty",
+ weight=1000,
+ dynamic_group=self.dynamic_group,
+ empty_compliance_behavior=behavior,
+ sot_agg_query=GraphQLQuery.objects.get(name="GC-SoTAgg-Query-1"),
+ backup_repository=GitRepository.objects.get(name="test-backup-repo-1"),
+ intended_repository=GitRepository.objects.get(name="test-intended-repo-1"),
+ jinja_repository=GitRepository.objects.get(name="test-jinja-repo-1"),
+ )
+
+ def _create_compliance(self, actual, intended):
+ """Helper to create or update a ConfigCompliance record."""
+ cc, _ = ConfigCompliance.objects.update_or_create(
+ device=self.device,
+ rule=self.compliance_rule_cli,
+ defaults={"actual": actual, "intended": intended},
+ )
+ return cc
+
+ def test_validated_both_empty(self):
+ """With TYPE_VALIDATED, empty+empty should be compliant."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_VALIDATED)
+ cc = self._create_compliance(actual="", intended="")
+ self.assertTrue(cc.compliance)
+ self.assertEqual(cc.compliance_int, 1)
+
+ def test_validated_intended_empty_actual_populated(self):
+ """With TYPE_VALIDATED, empty intended + populated actual runs normal check."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_VALIDATED)
+ cc = self._create_compliance(actual="ntp 1.1.1.1", intended="")
+ self.assertIsNotNone(cc.compliance)
+
+ def test_empty_both_both_empty(self):
+ """With TYPE_EMPTY_BOTH, both empty should be N/A."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_EMPTY_BOTH)
+ cc = self._create_compliance(actual="", intended="")
+ self.assertIsNone(cc.compliance)
+ self.assertIsNone(cc.compliance_int)
+
+ def test_empty_both_intended_empty_actual_populated(self):
+ """With TYPE_EMPTY_BOTH, only intended empty should run normal check."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_EMPTY_BOTH)
+ cc = self._create_compliance(actual="ntp 1.1.1.1", intended="")
+ self.assertIsNotNone(cc.compliance)
+ self.assertIsNotNone(cc.compliance_int)
+
+ def test_empty_both_actual_empty_intended_populated(self):
+ """With TYPE_EMPTY_BOTH, populated intended should run normal check."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_EMPTY_BOTH)
+ cc = self._create_compliance(actual="", intended="ntp 1.1.1.1")
+ self.assertIsNotNone(cc.compliance)
+
+ def test_empty_intended_both_empty(self):
+ """With TYPE_EMPTY_INTENDED, both empty should be N/A."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_EMPTY_INTENDED)
+ cc = self._create_compliance(actual="", intended="")
+ self.assertIsNone(cc.compliance)
+ self.assertIsNone(cc.compliance_int)
+
+ def test_empty_intended_intended_empty_actual_populated(self):
+ """With TYPE_EMPTY_INTENDED, empty intended should be N/A regardless of actual."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_EMPTY_INTENDED)
+ cc = self._create_compliance(actual="ntp 1.1.1.1", intended="")
+ self.assertIsNone(cc.compliance)
+ self.assertIsNone(cc.compliance_int)
+
+ def test_empty_intended_actual_empty_intended_populated(self):
+ """With TYPE_EMPTY_INTENDED, populated intended should run normal check."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_EMPTY_INTENDED)
+ cc = self._create_compliance(actual="", intended="ntp 1.1.1.1")
+ self.assertIsNotNone(cc.compliance)
+
+ def test_na_compliance_skips_remediation(self):
+ """N/A compliance should result in empty remediation."""
+ self._create_setting(EmptyComplianceBehaviorChoice.TYPE_EMPTY_BOTH)
+ cc = self._create_compliance(actual="", intended="")
+ self.assertIsNone(cc.compliance)
+ self.assertEqual(cc.remediation, "")
diff --git a/nautobot_golden_config/tests/test_views.py b/nautobot_golden_config/tests/test_views.py
index 63da3abe7..219496d36 100644
--- a/nautobot_golden_config/tests/test_views.py
+++ b/nautobot_golden_config/tests/test_views.py
@@ -1,5 +1,5 @@
"""Unit tests for nautobot_golden_config views."""
-# pylint: disable=protected-access
+# pylint: disable=protected-access,too-many-lines
import datetime
import re
@@ -22,7 +22,7 @@
from nautobot.users import models as users_models
from packaging import version
-from nautobot_golden_config import models, views
+from nautobot_golden_config import models, tables, views
from nautobot_golden_config.utilities.constant import PLUGIN_CFG
from .conftest import create_device_data, create_feature_rule_json, create_job_result
@@ -382,14 +382,13 @@ def setUpTestData(cls):
{"device": dev04, "feature": feature_dev01},
]
for iterator_j, update in enumerate(updates):
- compliance_int = iterator_j % 2
+ is_compliant = iterator_j % 2
models.ConfigCompliance.objects.create(
device=update["device"],
rule=update["feature"],
actual={"foo": {"bar-1": "baz"}},
- intended={"foo": {f"bar-{compliance_int}": "baz"}},
- compliance=bool(compliance_int),
- compliance_int=compliance_int,
+ intended={"foo": {f"bar-{is_compliant}": "baz"}},
+ compliance=bool(is_compliant),
)
def test_get_object_anonymous(self):
@@ -464,9 +463,9 @@ def test_alter_queryset(self):
self.assertGreater(len(features), 0)
self.assertIsInstance(queryset, RestrictedQuerySet)
for device in queryset:
- self.assertSequenceEqual(list(device.keys()), ["device", "device__name", *features])
+ self.assertCountEqual(list(device.keys()), ["device", "device__name", *features])
for feature in features:
- self.assertIn(device[feature], [0, 1])
+ self.assertIn(device[feature], ["True", "False"])
def test_table_columns(self):
"""Test the columns of the ConfigCompliance table return the expected pivoted data."""
@@ -487,7 +486,6 @@ def test_table_columns(self):
actual={"foo": {"bar-1": "baz"}},
intended={"foo": {"bar-1": "baz"}},
compliance=True,
- compliance_int=1,
)
response = self.client.get(reverse("plugins:nautobot_golden_config:configcompliance_list"))
@@ -599,7 +597,7 @@ def test_overview_status_and_context(self):
def test_overview_per_feature_counts(self):
"""Annotation values on the overview queryset match the fixture data.
- setUpTestData creates 4 devices x 4 features with compliance_int alternating
+ setUpTestData creates 4 devices x 4 features with compliance alternating
0/1/0/1 per device, so each feature should be: count=4, compliant=2,
non_compliant=2, comp_percent=50.0.
"""
@@ -628,11 +626,11 @@ def setUpTestData(cls):
cls.dev4 = Device.objects.get(name="Device 4")
cls.rule = create_feature_rule_json(cls.dev1, feature="qset-feat-a")
- def _create_compliance(self, device, rule, compliance_int):
- # save() recalculates compliance_int via FUNC_MAPPER(actual, intended).
+ def _create_compliance(self, device, rule, compliant):
+ # save() recalculates compliance via FUNC_MAPPER(actual, intended).
# Drive the result through actual/intended: matching = compliant, differing = non-compliant.
actual = {"foo": "bar"}
- intended = {"foo": "bar"} if compliance_int else {"foo": "baz"}
+ intended = {"foo": "bar"} if compliant else {"foo": "baz"}
return models.ConfigCompliance.objects.create(
device=device,
rule=rule,
@@ -706,6 +704,308 @@ def test_ordering_by_comp_percent_descending(self):
)
self.assertEqual(slugs, ["qset-feat-a", "qset-feat-b"])
+ def test_na_records_excluded_from_compliant_counts(self):
+ """N/A + compliant: N/A excluded from count, compliant counted, comp_percent reflects only real records."""
+ self._create_compliance(self.dev1, self.rule, 1)
+ cc_na = self._create_compliance(self.dev4, self.rule, 1)
+ models.ConfigCompliance.objects.filter(pk=cc_na.pk).update(compliance=None)
+
+ result = views._get_feature_compliance_queryset([self.dev1.id, self.dev4.id]).get(slug="qset-feat-a")
+ self.assertEqual(result.count, 1)
+ self.assertEqual(result.compliant, 1)
+ self.assertEqual(result.non_compliant, 0)
+ self.assertAlmostEqual(result.comp_percent, 100.0)
+
+ def test_na_records_excluded_from_non_compliant_counts(self):
+ """N/A + non-compliant: N/A excluded, non-compliant counted."""
+ self._create_compliance(self.dev1, self.rule, 0)
+ cc_na = self._create_compliance(self.dev4, self.rule, 1)
+ models.ConfigCompliance.objects.filter(pk=cc_na.pk).update(compliance=None)
+
+ result = views._get_feature_compliance_queryset([self.dev1.id, self.dev4.id]).get(slug="qset-feat-a")
+ self.assertEqual(result.count, 1)
+ self.assertEqual(result.compliant, 0)
+ self.assertEqual(result.non_compliant, 1)
+ self.assertAlmostEqual(result.comp_percent, 0.0)
+
+ def test_all_na_gives_zero_count_and_null_percent(self):
+ """All records N/A: count=0, comp_percent=None (same as no records)."""
+ cc1 = self._create_compliance(self.dev1, self.rule, 1)
+ cc2 = self._create_compliance(self.dev4, self.rule, 1)
+ models.ConfigCompliance.objects.filter(pk__in=[cc1.pk, cc2.pk]).update(compliance=None)
+
+ result = views._get_feature_compliance_queryset([self.dev1.id, self.dev4.id]).get(slug="qset-feat-a")
+ self.assertEqual(result.count, 0)
+ self.assertEqual(result.compliant, 0)
+ self.assertEqual(result.non_compliant, 0)
+ self.assertIsNone(result.comp_percent)
+
+ def test_mixed_compliance_with_na(self):
+ """Compliant + non-compliant + N/A: N/A excluded, others counted normally."""
+ dev2 = Device.objects.get(name="Device 2")
+ rule_b = create_feature_rule_json(dev2, feature="qset-feat-mixed")
+ self._create_compliance(self.dev1, self.rule, 1)
+ self._create_compliance(self.dev4, self.rule, 0)
+ cc_na = self._create_compliance(dev2, rule_b, 1)
+ models.ConfigCompliance.objects.filter(pk=cc_na.pk).update(compliance=None)
+
+ result = views._get_feature_compliance_queryset([self.dev1.id, self.dev4.id, dev2.id]).get(slug="qset-feat-a")
+ self.assertEqual(result.count, 2)
+ self.assertEqual(result.compliant, 1)
+ self.assertEqual(result.non_compliant, 1)
+ self.assertAlmostEqual(result.comp_percent, 50.0)
+
+
+@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+class PivotQueryMatrixTestCase(TestCase): # pylint: disable=too-many-public-methods
+ """Test the hand-built pivot query (alter_queryset) against the full behavior matrix.
+
+ Per-cell values:
+ True → 1 in pivot (green check)
+ False → 0 in pivot (red X)
+ None → filtered out (N/A)
+ (no record) → None in pivot (default dash)
+
+ Per-device row scenarios:
+ 1. All features compliant
+ 2. All features non-compliant
+ 3. Mixed compliant/non-compliant
+ 4. All features N/A → device excluded from pivot
+ 5. Mix of real compliance + N/A
+ 6. No records at all → device not in pivot
+ 7. Some features have records, some don't
+ 8. N/A + no-record mix → device excluded
+ 9. Real + N/A + no-record
+ """
+
+ @classmethod
+ def setUpTestData(cls):
+ create_device_data()
+ cls.dev1 = Device.objects.get(name="Device 1")
+ cls.dev2 = Device.objects.get(name="Device 2")
+ cls.dev3 = Device.objects.get(name="Device 3")
+ cls.dev4 = Device.objects.get(name="Device 4")
+ cls.dev5 = Device.objects.get(name="Device 5")
+ cls.dev6 = Device.objects.get(name="Device 6")
+
+ # Create 3 features on Platform 1 (shared by dev1, dev4, dev5, dev6)
+ cls.feat_bgp = create_feature_rule_json(cls.dev1, feature="bgp")
+ cls.feat_ntp = create_feature_rule_json(cls.dev1, feature="ntp")
+ cls.feat_snmp = create_feature_rule_json(cls.dev1, feature="snmp")
+
+ def _get_pivot(self):
+ """Execute the pivot query and return {device_name: {feature: value}}."""
+ request = RequestFactory(SERVER_NAME="nautobot.example.com").get(
+ reverse("plugins:nautobot_golden_config:configcompliance_list")
+ )
+ request.user = User.objects.first()
+ qs = views.ConfigComplianceUIViewSet(request=request, action="list").alter_queryset(request)
+ return {row["device__name"]: row for row in qs}
+
+ def _create(self, device, rule, compliant):
+ """Create a ConfigCompliance record with the given compliance result."""
+ actual = {"foo": "bar"}
+ intended = {"foo": "bar"} if compliant else {"foo": "baz"}
+ return models.ConfigCompliance.objects.create(device=device, rule=rule, actual=actual, intended=intended)
+
+ def _mark_na(self, cc):
+ """Mark a ConfigCompliance record as N/A by setting compliance to None."""
+ models.ConfigCompliance.objects.filter(pk=cc.pk).update(compliance=None)
+
+ # --- Per-cell values ---
+
+ def test_render_compliant(self):
+ """ComplianceColumn renders a green check for compliant."""
+ col = tables.ComplianceColumn(verbose_name="test")
+ rendered = col.render("True")
+ self.assertIn("mdi-check-bold", rendered)
+ self.assertIn("text-success", rendered)
+
+ def test_render_non_compliant(self):
+ """ComplianceColumn renders a red X for non-compliant."""
+ col = tables.ComplianceColumn(verbose_name="test")
+ rendered = col.render("False")
+ self.assertIn("mdi-close-thick", rendered)
+ self.assertIn("text-danger", rendered)
+
+ def test_render_na(self):
+ """ComplianceColumn renders double-dash for N/A."""
+ col = tables.ComplianceColumn(verbose_name="test")
+ rendered = col.render("None")
+ self.assertIn("- -", rendered)
+ self.assertIn("N/A", rendered)
+
+ def test_render_no_record(self):
+ """ComplianceColumn renders a dash for no record."""
+ col = tables.ComplianceColumn(verbose_name="test")
+ rendered = col.render(None)
+ self.assertIn("mdi-minus", rendered)
+
+ def test_cell_compliant(self):
+ """True compliance → "True" in pivot."""
+ self._create(self.dev1, self.feat_bgp, True)
+ pivot = self._get_pivot()
+ self.assertEqual(pivot["Device 1"]["bgp"], "True")
+
+ def test_cell_non_compliant(self):
+ """False compliance → "False" in pivot."""
+ self._create(self.dev1, self.feat_bgp, False)
+ pivot = self._get_pivot()
+ self.assertEqual(pivot["Device 1"]["bgp"], "False")
+
+ def test_cell_na(self):
+ """None compliance (N/A) → "None" in pivot cell."""
+ cc = self._create(self.dev1, self.feat_bgp, True)
+ self._mark_na(cc)
+ pivot = self._get_pivot()
+ self.assertEqual(pivot["Device 1"]["bgp"], "None")
+
+ def test_cell_no_record(self):
+ """No ConfigCompliance record → device not in pivot at all."""
+ pivot = self._get_pivot()
+ self.assertNotIn("Device 1", pivot)
+
+ # --- Per-device row ---
+
+ def test_row_all_compliant(self):
+ """Device with all features compliant → all cells are "True"."""
+ self._create(self.dev1, self.feat_bgp, True)
+ self._create(self.dev1, self.feat_ntp, True)
+ self._create(self.dev1, self.feat_snmp, True)
+ pivot = self._get_pivot()
+ row = pivot["Device 1"]
+ self.assertEqual(row["bgp"], "True")
+ self.assertEqual(row["ntp"], "True")
+ self.assertEqual(row["snmp"], "True")
+
+ def test_row_all_non_compliant(self):
+ """Device with all features non-compliant → all cells are "False"."""
+ self._create(self.dev1, self.feat_bgp, False)
+ self._create(self.dev1, self.feat_ntp, False)
+ self._create(self.dev1, self.feat_snmp, False)
+ pivot = self._get_pivot()
+ row = pivot["Device 1"]
+ self.assertEqual(row["bgp"], "False")
+ self.assertEqual(row["ntp"], "False")
+ self.assertEqual(row["snmp"], "False")
+
+ def test_row_mixed_compliance(self):
+ """Device with mixed compliance → cells are "True" and "False" accordingly."""
+ self._create(self.dev1, self.feat_bgp, True)
+ self._create(self.dev1, self.feat_ntp, False)
+ self._create(self.dev1, self.feat_snmp, True)
+ pivot = self._get_pivot()
+ row = pivot["Device 1"]
+ self.assertEqual(row["bgp"], "True")
+ self.assertEqual(row["ntp"], "False")
+ self.assertEqual(row["snmp"], "True")
+
+ def test_row_all_na(self):
+ """Device with all N/A records → device in pivot with all "None" cells."""
+ cc1 = self._create(self.dev1, self.feat_bgp, True)
+ cc2 = self._create(self.dev1, self.feat_ntp, True)
+ cc3 = self._create(self.dev1, self.feat_snmp, True)
+ self._mark_na(cc1)
+ self._mark_na(cc2)
+ self._mark_na(cc3)
+ pivot = self._get_pivot()
+ row = pivot["Device 1"]
+ self.assertEqual(row["bgp"], "None")
+ self.assertEqual(row["ntp"], "None")
+ self.assertEqual(row["snmp"], "None")
+
+ def test_row_mix_real_and_na(self):
+ """Device with some real compliance and some N/A → N/A cells are "None", real cells are "True"/"False"."""
+ self._create(self.dev1, self.feat_bgp, True)
+ cc_na = self._create(self.dev1, self.feat_ntp, True)
+ self._mark_na(cc_na)
+ self._create(self.dev1, self.feat_snmp, False)
+ pivot = self._get_pivot()
+ row = pivot["Device 1"]
+ self.assertEqual(row["bgp"], "True")
+ self.assertEqual(row["ntp"], "None")
+ self.assertEqual(row["snmp"], "False")
+
+ def test_row_no_records_excluded(self):
+ """Device with zero compliance records → not in pivot."""
+ # Create records for another device so the pivot isn't empty
+ self._create(self.dev4, self.feat_bgp, True)
+ pivot = self._get_pivot()
+ self.assertNotIn("Device 1", pivot)
+ self.assertIn("Device 4", pivot)
+
+ def test_row_partial_records(self):
+ """Device with records for some features but not others → missing features are None (no record)."""
+ self._create(self.dev1, self.feat_bgp, True)
+ # No record for ntp or snmp
+ pivot = self._get_pivot()
+ row = pivot["Device 1"]
+ self.assertEqual(row["bgp"], "True")
+ self.assertIsNone(row.get("ntp"))
+ self.assertIsNone(row.get("snmp"))
+
+ def test_row_na_plus_no_record(self):
+ """Device with only N/A records (no other features) → in pivot with "None" cells."""
+ cc_na = self._create(self.dev1, self.feat_bgp, True)
+ self._mark_na(cc_na)
+ # No records for ntp or snmp
+ pivot = self._get_pivot()
+ row = pivot["Device 1"]
+ self.assertEqual(row["bgp"], "None")
+
+ def test_row_real_na_and_no_record(self):
+ """Device with one real, one N/A, one missing → real is "True"/"False", N/A is "None", missing is None."""
+ self._create(self.dev1, self.feat_bgp, False)
+ cc_na = self._create(self.dev1, self.feat_ntp, True)
+ self._mark_na(cc_na)
+ # No record for snmp
+ pivot = self._get_pivot()
+ row = pivot["Device 1"]
+ self.assertEqual(row["bgp"], "False")
+ self.assertEqual(row["ntp"], "None")
+ self.assertIsNone(row.get("snmp"))
+
+ # --- Global scenarios ---
+
+ def test_global_all_na(self):
+ """When all compliance records across all devices are N/A, devices still appear with "None" cells."""
+ cc1 = self._create(self.dev1, self.feat_bgp, True)
+ cc2 = self._create(self.dev4, self.feat_ntp, True)
+ self._mark_na(cc1)
+ self._mark_na(cc2)
+ pivot = self._get_pivot()
+ self.assertIn("Device 1", pivot)
+ self.assertEqual(pivot["Device 1"]["bgp"], "None")
+ self.assertIn("Device 4", pivot)
+ self.assertEqual(pivot["Device 4"]["ntp"], "None")
+
+ def test_global_no_records_empty_pivot(self):
+ """When no compliance records exist, pivot is empty."""
+ pivot = self._get_pivot()
+ self.assertEqual(len(pivot), 0)
+
+ def test_global_mixed_devices(self):
+ """Multiple devices with varying states all render correctly."""
+ # dev1: all compliant
+ self._create(self.dev1, self.feat_bgp, True)
+ self._create(self.dev1, self.feat_ntp, True)
+ # dev4: mixed
+ self._create(self.dev4, self.feat_bgp, False)
+ self._create(self.dev4, self.feat_ntp, True)
+ # dev5: all N/A
+ cc_na = self._create(self.dev5, self.feat_bgp, True)
+ self._mark_na(cc_na)
+
+ pivot = self._get_pivot()
+ self.assertIn("Device 1", pivot)
+ self.assertIn("Device 4", pivot)
+ self.assertIn("Device 5", pivot)
+ self.assertEqual(pivot["Device 5"]["bgp"], "None")
+ self.assertEqual(pivot["Device 1"]["bgp"], "True")
+ self.assertEqual(pivot["Device 1"]["ntp"], "True")
+ self.assertEqual(pivot["Device 4"]["bgp"], "False")
+ self.assertEqual(pivot["Device 4"]["ntp"], "True")
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
class FilteredComplianceDeviceIdsTestCase(TestCase):
@@ -737,9 +1037,9 @@ def setUpTestData(cls):
cls.rule_p1 = create_feature_rule_json(cls.dev1, feature="filt-feat-a")
cls.rule_p2 = create_feature_rule_json(cls.dev2, feature="filt-feat-b")
- def _create_compliance(self, device, rule, compliance_int):
+ def _create_compliance(self, device, rule, compliant):
actual = {"foo": "bar"}
- intended = {"foo": "bar"} if compliance_int else {"foo": "baz"}
+ intended = {"foo": "bar"} if compliant else {"foo": "baz"}
return models.ConfigCompliance.objects.create(device=device, rule=rule, actual=actual, intended=intended)
def test_no_params_returns_all_devices_with_records(self):
diff --git a/nautobot_golden_config/views.py b/nautobot_golden_config/views.py
index c67412aeb..f66d8e31f 100644
--- a/nautobot_golden_config/views.py
+++ b/nautobot_golden_config/views.py
@@ -8,14 +8,12 @@
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ObjectDoesNotExist
-from django.db.models import Count, ExpressionWrapper, FloatField, Max, Q, Sum, Value
-from django.db.models.functions import Coalesce, NullIf
+from django.db.models import Case, Count, ExpressionWrapper, FloatField, Max, Q, Value, When
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.html import format_html
from django.utils.timezone import make_aware
from django.views.generic import TemplateView, View
-from django_pivot.pivot import pivot
from django_tables2 import RequestConfig
from nautobot.apps import views
from nautobot.apps.ui import (
@@ -281,16 +279,21 @@ def _get_feature_compliance_queryset(device_ids):
division-by-zero.
"""
device_filter = Q(feature__rule__device__in=device_ids)
+ # Exclude N/A records (compliance is NULL when configs are empty and behavior is non-default)
+ active_filter = device_filter & Q(feature__rule__compliance__isnull=False)
+ compliant_filter = active_filter & Q(feature__rule__compliance=True)
+ total = Count("feature__rule", filter=active_filter)
+ compliant_count = Count("feature__rule", filter=compliant_filter)
return models.ComplianceFeature.objects.annotate(
- count=Count("feature__rule", filter=device_filter),
- compliant=Coalesce(Sum("feature__rule__compliance_int", filter=device_filter), 0),
- non_compliant=Count("feature__rule", filter=device_filter)
- - Coalesce(Sum("feature__rule__compliance_int", filter=device_filter), 0),
- comp_percent=ExpressionWrapper(
- 100.0
- * Coalesce(Sum("feature__rule__compliance_int", filter=device_filter), 0)
- / NullIf(Count("feature__rule", filter=device_filter), Value(0)),
- output_field=FloatField(),
+ count=total,
+ compliant=compliant_count,
+ non_compliant=Count("feature__rule", filter=active_filter & Q(feature__rule__compliance=False)),
+ comp_percent=Case(
+ When(
+ **{"count__gt": 0},
+ then=ExpressionWrapper(100.0 * compliant_count / total, output_field=FloatField()),
+ ),
+ default=None,
),
).order_by("-comp_percent")
@@ -354,13 +357,23 @@ def alter_queryset(self, request):
"""Build actual runtime queryset as the build time queryset of table `pivoted`."""
# Super because alter_queryset() calls get_queryset(), which is what calls queryset.restrict()
self.queryset = super().alter_queryset(request)
- return pivot(
- self.queryset,
- ["device", "device__name"],
- "rule__feature__slug",
- "compliance_int",
- aggregation=Max,
- )
+ features = self.queryset.values_list("rule__feature__slug", flat=True).distinct()
+ annotations = {}
+ for slug in features:
+ annotations[slug] = Max(
+ Case(
+ When(
+ rule__feature__slug=slug,
+ then=Case(
+ When(compliance=True, then=Value("True")),
+ When(compliance=False, then=Value("False")),
+ default=Value("None"),
+ ),
+ ),
+ default=None,
+ )
+ )
+ return self.queryset.values("device", "device__name").annotate(**annotations)
def perform_bulk_destroy(self, request, **kwargs):
"""Overwrite perform_bulk_destroy to handle special use case in which the UI shows devices but want to delete ConfigCompliance objects."""
@@ -422,6 +435,9 @@ def devicetab(self, request, pk, *args, **kwargs):
elif request.GET.get("compliance") == "non-compliant":
context["compliance_filter"] = "non-compliant"
context["compliance_details"] = compliance_details.filter(compliance=False)
+ elif request.GET.get("compliance") == "n-a":
+ context["compliance_filter"] = "n-a"
+ context["compliance_details"] = compliance_details.filter(compliance__isnull=True)
context["active_tab"] = request.GET.get("tab")
context["device"] = device
@@ -483,8 +499,10 @@ def overview(self, request, *args, **kwargs): # pylint: disable=too-many-locals
# Device pie: total = distinct devices with ≥1 compliance record;
# compliants = devices with zero non-compliant records.
- device_total = cc_qs.values("device_id").distinct().count()
- device_non_compliant = cc_qs.filter(compliance=False).values("device_id").distinct().count()
+ # Exclude N/A records (compliance is NULL when both actual and intended are empty)
+ cc_active_qs = cc_qs.filter(compliance__isnull=False)
+ device_total = cc_active_qs.values("device_id").distinct().count()
+ device_non_compliant = cc_active_qs.filter(compliance=False).values("device_id").distinct().count()
device_aggr = calculate_aggr_percentage(
{"total": device_total, "compliants": device_total - device_non_compliant}
)
@@ -510,7 +528,7 @@ def overview(self, request, *args, **kwargs): # pylint: disable=too-many-locals
# Feature pie: total = all compliance records for filtered devices;
# compliants = those marked compliant.
feature_aggr = calculate_aggr_percentage(
- cc_qs.aggregate(total=Count("id"), compliants=Count("id", filter=Q(compliance=True)))
+ cc_active_qs.aggregate(total=Count("id"), compliants=Count("id", filter=Q(compliance=True)))
)
pie_feature_panel = EChartsPanel(
diff --git a/poetry.lock b/poetry.lock
index 3b085cabc..33d2e407f 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "amqp"
@@ -971,28 +971,6 @@ files = [
graph = ["objgraph (>=1.7.2)"]
profile = ["gprof2dot (>=2022.7.29)"]
-[[package]]
-name = "django"
-version = "4.2.30"
-description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
-optional = false
-python-versions = ">=3.8"
-groups = ["main", "dev"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "django-4.2.30-py3-none-any.whl", hash = "sha256:4d07aaf1c62f9984842b67c2874ebbf7056a17be253860299b93ae1881faad65"},
- {file = "django-4.2.30.tar.gz", hash = "sha256:4ebc7a434e3819db6cf4b399fb5b3f536310a30e8486f08b66886840be84b37c"},
-]
-
-[package.dependencies]
-asgiref = ">=3.6.0,<4"
-sqlparse = ">=0.3.1"
-tzdata = {version = "*", markers = "sys_platform == \"win32\""}
-
-[package.extras]
-argon2 = ["argon2-cffi (>=19.1.0)"]
-bcrypt = ["bcrypt"]
-
[[package]]
name = "django"
version = "5.2.13"
@@ -1000,7 +978,6 @@ description = "A high-level Python web framework that encourages rapid developme
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
-markers = "python_version >= \"3.12\""
files = [
{file = "django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a"},
{file = "django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4"},
@@ -1027,27 +1004,6 @@ files = [
{file = "django_ajax_tables-1.1.1.tar.gz", hash = "sha256:5a7e7bc7940aa6332a564916cde22010a858a3d29fc1090ce8061010ec76337c"},
]
-[[package]]
-name = "django-celery-beat"
-version = "2.7.0"
-description = "Database-backed Periodic Tasks."
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "django_celery_beat-2.7.0-py3-none-any.whl", hash = "sha256:851c680d8fbf608ca5fecd5836622beea89fa017bc2b3f94a5b8c648c32d84b1"},
- {file = "django_celery_beat-2.7.0.tar.gz", hash = "sha256:8482034925e09b698c05ad61c36ed2a8dbc436724a3fe119215193a4ca6dc967"},
-]
-
-[package.dependencies]
-celery = ">=5.2.3,<6.0"
-cron-descriptor = ">=1.2.32"
-Django = ">=2.2,<5.2"
-django-timezone-field = ">=5.0"
-python-crontab = ">=2.3.4"
-tzdata = "*"
-
[[package]]
name = "django-celery-beat"
version = "2.8.1"
@@ -1055,7 +1011,6 @@ description = "Database-backed Periodic Tasks."
optional = false
python-versions = ">=3.8"
groups = ["main"]
-markers = "python_version >= \"3.12\""
files = [
{file = "django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171"},
{file = "django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a"},
@@ -1162,22 +1117,6 @@ files = [
[package.dependencies]
django = ">=4.2"
-[[package]]
-name = "django-filter"
-version = "25.1"
-description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80"},
- {file = "django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153"},
-]
-
-[package.dependencies]
-Django = ">=4.2"
-
[[package]]
name = "django-filter"
version = "25.2"
@@ -1185,7 +1124,6 @@ description = "Django-filter is a reusable Django application for allowing users
optional = false
python-versions = ">=3.10"
groups = ["main"]
-markers = "python_version >= \"3.12\""
files = [
{file = "django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3"},
{file = "django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23"},
@@ -1243,21 +1181,6 @@ files = [
django = ">=3.2"
jinja2 = ">=3"
-[[package]]
-name = "django-pivot"
-version = "1.9.0"
-description = "Create pivot tables and histograms from ORM querysets"
-optional = false
-python-versions = "*"
-groups = ["main"]
-files = [
- {file = "django-pivot-1.9.0.tar.gz", hash = "sha256:5e985d32d9ff2a6b89419dd0292c0fa2822d494ee479b5fd16cdb542abf66a88"},
- {file = "django_pivot-1.9.0-py3-none-any.whl", hash = "sha256:1c60e18e7d5f7e42856faee0961748082ddd05b01ae7c8a4baed64d2bbacd051"},
-]
-
-[package.dependencies]
-django = ">=2.2.0"
-
[[package]]
name = "django-prometheus"
version = "2.4.1"
@@ -1293,27 +1216,6 @@ redis = ">=4.0.2"
[package.extras]
hiredis = ["redis[hiredis] (>=4.0.2)"]
-[[package]]
-name = "django-silk"
-version = "5.4.3"
-description = "Silky smooth profiling for the Django Framework"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "django_silk-5.4.3-py3-none-any.whl", hash = "sha256:f7920ae91a34716654296140b2cbf449e9798237a0c6eb7cf2cd79c2cfb39321"},
- {file = "django_silk-5.4.3.tar.gz", hash = "sha256:bedb17c8fd9c029a7746cb947864f5c9ea943ae33d6a9581e60f67c45e4490ad"},
-]
-
-[package.dependencies]
-Django = ">=4.2"
-gprof2dot = ">=2017.09.19"
-sqlparse = "*"
-
-[package.extras]
-formatting = ["autopep8"]
-
[[package]]
name = "django-silk"
version = "5.5.0"
@@ -1321,7 +1223,6 @@ description = "Silky smooth profiling for the Django Framework"
optional = false
python-versions = ">=3.10"
groups = ["main"]
-markers = "python_version >= \"3.12\""
files = [
{file = "django_silk-5.5.0-py3-none-any.whl", hash = "sha256:82b5a690d623935be916dd145e2f605a4ac9454d54e03df6a7ffcf38333c8444"},
{file = "django_silk-5.5.0.tar.gz", hash = "sha256:41fcabe65d59d31ccdb69daeb3c3e6d30879ab2c00b0875b111fda0f69aec065"},
@@ -1444,22 +1345,6 @@ test = ["mock ; python_version < \"3.3\"", "pytest"]
uvicorn = ["uvicorn (>0.6) ; python_version >= \"3.6\""]
waitress = ["waitress"]
-[[package]]
-name = "djangorestframework"
-version = "3.16.1"
-description = "Web APIs for Django, made easy."
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec"},
- {file = "djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7"},
-]
-
-[package.dependencies]
-django = ">=4.2"
-
[[package]]
name = "djangorestframework"
version = "3.17.1"
@@ -1467,7 +1352,6 @@ description = "Web APIs for Django, made easy."
optional = false
python-versions = ">=3.10"
groups = ["main"]
-markers = "python_version >= \"3.12\""
files = [
{file = "djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457"},
{file = "djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5"},
@@ -1704,35 +1588,6 @@ gitdb = ">=4.0.1,<5"
doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
-[[package]]
-name = "google-auth"
-version = "2.49.1"
-description = "Google Authentication Library"
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7"},
- {file = "google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64"},
-]
-
-[package.dependencies]
-cryptography = ">=38.0.3"
-pyasn1-modules = ">=0.2.1"
-
-[package.extras]
-aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"]
-cryptography = ["cryptography (>=38.0.3)"]
-enterprise-cert = ["pyopenssl"]
-pyjwt = ["pyjwt (>=2.0)"]
-pyopenssl = ["pyopenssl (>=20.0.0)"]
-reauth = ["pyu2f (>=0.1.5)"]
-requests = ["requests (>=2.20.0,<3.0.0)"]
-rsa = ["rsa (>=3.1.4,<5)"]
-testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"]
-urllib3 = ["packaging", "urllib3"]
-
[[package]]
name = "gprof2dot"
version = "2025.4.14"
@@ -2344,35 +2199,6 @@ sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5) ; sys_platform != \"win32\" an
yaml = ["PyYAML (>=3.10)"]
zookeeper = ["kazoo (>=2.8.0)"]
-[[package]]
-name = "kubernetes"
-version = "33.1.0"
-description = "Kubernetes python client"
-optional = false
-python-versions = ">=3.6"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "kubernetes-33.1.0-py2.py3-none-any.whl", hash = "sha256:544de42b24b64287f7e0aa9513c93cb503f7f40eea39b20f66810011a86eabc5"},
- {file = "kubernetes-33.1.0.tar.gz", hash = "sha256:f64d829843a54c251061a8e7a14523b521f2dc5c896cf6d65ccf348648a88993"},
-]
-
-[package.dependencies]
-certifi = ">=14.05.14"
-durationpy = ">=0.7"
-google-auth = ">=1.0.1"
-oauthlib = ">=3.2.2"
-python-dateutil = ">=2.5.3"
-pyyaml = ">=5.4.1"
-requests = "*"
-requests-oauthlib = "*"
-six = ">=1.9.0"
-urllib3 = ">=1.24.2"
-websocket-client = ">=0.32.0,<0.40.0 || >0.40.0,<0.41.dev0 || >=0.43.dev0"
-
-[package.extras]
-adal = ["adal (>=1.0.2)"]
-
[[package]]
name = "kubernetes"
version = "35.0.0"
@@ -2380,7 +2206,6 @@ description = "Kubernetes python client"
optional = false
python-versions = ">=3.6"
groups = ["main"]
-markers = "python_version >= \"3.12\""
files = [
{file = "kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d"},
{file = "kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee"},
@@ -2606,23 +2431,6 @@ html-clean = ["lxml_html_clean"]
html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
-[[package]]
-name = "markdown"
-version = "3.8.2"
-description = "Python implementation of John Gruber's Markdown."
-optional = false
-python-versions = ">=3.9"
-groups = ["main", "dev", "docs"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"},
- {file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"},
-]
-
-[package.extras]
-docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
-testing = ["coverage", "pyyaml"]
-
[[package]]
name = "markdown"
version = "3.10.2"
@@ -2630,7 +2438,6 @@ description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.10"
groups = ["main", "dev", "docs"]
-markers = "python_version >= \"3.12\""
files = [
{file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"},
{file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"},
@@ -3028,73 +2835,6 @@ ttp = "*"
ttp_templates = "*"
typing-extensions = ">=4.3.0"
-[[package]]
-name = "nautobot"
-version = "3.0.11"
-description = "Source of truth and network automation platform."
-optional = false
-python-versions = "<3.14,>=3.10"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "nautobot-3.0.11-py3-none-any.whl", hash = "sha256:3633493d1bdcde95a8b3ab2b98fd17b33480acf10f6208bc052aed85ab4aa9d9"},
- {file = "nautobot-3.0.11.tar.gz", hash = "sha256:5d7a2f86b699b42cc46ba11efb037940bae234be2d3b510d331d6e5240d599f3"},
-]
-
-[package.dependencies]
-celery = ">=5.6.3,<5.7.0"
-cryptography = ">=46.0.7,<46.1.0"
-Django = ">=4.2.30,<4.3.0"
-django-ajax-tables = ">=1.1.1,<1.2.0"
-django-celery-beat = ">=2.7.0,<2.8.0"
-django-celery-results = ">=2.6.0,<2.7.0"
-django-constance = ">=4.3.5,<4.4.0"
-django-cors-headers = ">=4.9.0,<4.10.0"
-django-db-file-storage = ">=0.5.6.1,<0.6.0.0"
-django-extensions = ">=4.1,<4.2"
-django-filter = ">=25.1,<25.2"
-django-health-check = ">=3.20.8,<3.21.0"
-django-jinja = ">=2.11.0,<2.12.0"
-django-prometheus = ">=2.4.1,<2.5.0"
-django-redis = ">=6.0.0,<6.1.0"
-django-silk = ">=5.4.3,<5.5.0"
-django-structlog = {version = ">=10.0.0,<10.1.0", extras = ["celery"]}
-django-tables2 = ">=2.8.0,<2.9.0"
-django-taggit = ">=6.1.0,<6.2.0"
-django-timezone-field = ">=7.2.1,<7.3.0"
-django-tree-queries = ">=0.23.1,<0.24.0"
-django-webserver = ">=1.2.0,<1.3.0"
-djangorestframework = ">=3.16.1,<3.17.0"
-drf-spectacular = {version = ">=0.28.0,<0.29.0", extras = ["sidecar"]}
-emoji = ">=2.15.0,<2.16.0"
-GitPython = ">=3.1.46,<3.2.0"
-graphene-django = ">=3.2.3,<3.3.0"
-graphene-django-optimizer = ">=0.10.0,<0.11.0"
-Jinja2 = ">=3.1.6,<3.2.0"
-jsonschema = ">=4.7.0,<5.0.0"
-kubernetes = ">=33.1.0,<34.0.0"
-Markdown = ">=3.8.2,<3.9.0"
-netaddr = ">=1.3.0,<1.4.0"
-netutils = ">=1.14.0,<2.0.0"
-nh3 = ">=0.3.4,<0.4.0"
-packaging = ">=23.1"
-Pillow = ">=12.1.1,<13.0.0"
-prometheus-client = ">=0.23.1,<0.24.0"
-psycopg2-binary = ">=2.9.11,<2.10.0"
-python-slugify = ">=8.0.4,<8.1.0"
-pyuwsgi = ">=2.0.30,<2.1.0"
-PyYAML = ">=6.0.3,<6.1.0"
-social-auth-app-django = ">=5.4.3,<5.5.0"
-svgwrite = ">=1.4.3,<1.5.0"
-
-[package.extras]
-all = ["django-auth-ldap (>=5.2.0,<5.3.0)", "django-storages (>=1.14.6,<1.15.0)", "mysqlclient (>=2.2.8,<2.3.0)", "napalm (>=4.1.0,<6.0.0)", "social-auth-core[saml] (>=4.8.5,<4.9.0)"]
-ldap = ["django-auth-ldap (>=5.2.0,<5.3.0)"]
-mysql = ["mysqlclient (>=2.2.8,<2.3.0)"]
-napalm = ["napalm (>=4.1.0,<6.0.0)"]
-remote-storage = ["django-storages (>=1.14.6,<1.15.0)"]
-sso = ["social-auth-core[saml] (>=4.8.5,<4.9.0)"]
-
[[package]]
name = "nautobot"
version = "3.1.0a5"
@@ -3102,7 +2842,6 @@ description = "Source of truth and network automation platform."
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
-markers = "python_version >= \"3.12\""
files = [
{file = "nautobot-3.1.0a5-py3-none-any.whl", hash = "sha256:45bfbba1d2eadb190f8b8a8558ec332092233886a6c6dbdbe9b6271db10a47c3"},
{file = "nautobot-3.1.0a5.tar.gz", hash = "sha256:9067b2a906203793128bff442f635d5a7ba7ae653d0476aee9451978635f0127"},
@@ -3695,22 +3434,6 @@ files = [
{file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"},
]
-[[package]]
-name = "prometheus-client"
-version = "0.23.1"
-description = "Python client for the Prometheus monitoring system."
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99"},
- {file = "prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce"},
-]
-
-[package.extras]
-twisted = ["twisted"]
-
[[package]]
name = "prometheus-client"
version = "0.24.1"
@@ -3718,7 +3441,6 @@ description = "Python client for the Prometheus monitoring system."
optional = false
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version >= \"3.12\""
files = [
{file = "prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055"},
{file = "prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9"},
@@ -3888,35 +3610,6 @@ publish = ["build"]
tests = ["pytest", "pyyaml"]
type-checks = ["mypy", "types-pyyaml"]
-[[package]]
-name = "pyasn1"
-version = "0.6.3"
-description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
- {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
-]
-
-[[package]]
-name = "pyasn1-modules"
-version = "0.4.2"
-description = "A collection of ASN.1-based protocols modules"
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"},
- {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"},
-]
-
-[package.dependencies]
-pyasn1 = ">=0.6.1,<0.7.0"
-
[[package]]
name = "pycparser"
version = "3.0"
@@ -5480,26 +5173,6 @@ files = [
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
-[[package]]
-name = "social-auth-app-django"
-version = "5.4.3"
-description = "Python Social Authentication, Django integration."
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "python_version < \"3.12\""
-files = [
- {file = "social_auth_app_django-5.4.3-py3-none-any.whl", hash = "sha256:db70b972faeb10ee1ec83d0dc7dbd0558d5f5830417bba317b712b10ff58d031"},
- {file = "social_auth_app_django-5.4.3.tar.gz", hash = "sha256:d1f4286d5ca1e512c9b2f686e7ecb2a0128148f1a33d853b69dc07b58508362e"},
-]
-
-[package.dependencies]
-Django = ">=3.2"
-social-auth-core = ">=4.4,<5.0"
-
-[package.extras]
-dev = ["coverage (>=3.6)"]
-
[[package]]
name = "social-auth-app-django"
version = "5.7.0"
@@ -5507,7 +5180,6 @@ description = "Python Social Authentication, Django integration."
optional = false
python-versions = ">=3.10"
groups = ["main"]
-markers = "python_version >= \"3.12\""
files = [
{file = "social_auth_app_django-5.7.0-py3-none-any.whl", hash = "sha256:492b6f64e1e9fa5ed62b24bc19bde31c235fd4eb7fbd5c310e999e752b8f699a"},
{file = "social_auth_app_django-5.7.0.tar.gz", hash = "sha256:c0cc118d1bb935dbf02acb8a57fabd7b07694452fce755a5956282c69f019c79"},
@@ -6183,4 +5855,4 @@ all = []
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.15"
-content-hash = "0c66d23a25bbfec84c3f44d170585578a3f0607f7f60cc3fec4d1dcf60f9208f"
+content-hash = "a062fab552b15694ff9e3b44ab47b92a6afc203ca75804c2069edab09ebbf645"
diff --git a/pyproject.toml b/pyproject.toml
index dfe364751..d659adf6c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,7 +34,6 @@ include = [
[tool.poetry.dependencies]
python = ">=3.10,<3.15"
deepdiff = ">=8.6.1,<9.0.0"
-django-pivot = ">=1.9.0,<1.10.0" # The signature changed to return a non-queryset, do not upgrade without ensuring it returns a queryset
nautobot-plugin-nornir = ">=3.0.0,<4.0.0"
toml = "^0.10.2"
netutils = "^1.17.0"
|