diff --git a/.github/workflows/upstream_testing.yml b/.github/workflows/upstream_testing.yml index 32cfee847..6f4d0b266 100644 --- a/.github/workflows/upstream_testing.yml +++ b/.github/workflows/upstream_testing.yml @@ -18,8 +18,6 @@ jobs: app_branch: "main" - nautobot_branch: "ltm-2.4" app_branch: "ltm-2.4" - # - nautobot_branch: "next-4.0" - # app_branch: "next-4.0" uses: "nautobot/nautobot/.github/workflows/plugin_upstream_testing_base.yml@develop" with: # Below could potentially be collapsed into a single argument if a concrete relationship between both is enforced invoke_context_name: "NAUTOBOT_GOLDEN_CONFIG" diff --git a/changes/421.added b/changes/421.added new file mode 100644 index 000000000..a74dd9b0a --- /dev/null +++ b/changes/421.added @@ -0,0 +1 @@ +Added configurable `empty_compliance_behavior` setting to `GoldenConfigSetting` to control how compliance is evaluated when configurations are empty. diff --git a/changes/421.housekeeping b/changes/421.housekeeping new file mode 100644 index 000000000..afa556eee --- /dev/null +++ b/changes/421.housekeeping @@ -0,0 +1 @@ +Replaced `django_pivot` dependency with hand-built pivot query for the compliance overview table. diff --git a/changes/421.removed b/changes/421.removed new file mode 100644 index 000000000..5bdacf2da --- /dev/null +++ b/changes/421.removed @@ -0,0 +1 @@ +Removed `compliance_int` database field from `ConfigCompliance` model in favor of a backwards-compatible property. Custom compliance functions returning `compliance_int` will see a deprecation warning. diff --git a/docs/user/app_feature_compliance.md b/docs/user/app_feature_compliance.md index ccfb7ef23..35a335acf 100644 --- a/docs/user/app_feature_compliance.md +++ b/docs/user/app_feature_compliance.md @@ -26,6 +26,126 @@ In order to generate the intended configurations, a minimum of two repositories 3. The [intended_path_template](./app_use_cases.md#application-settings) configuration parameter. 4. The [backup_path_template](./app_use_cases.md#application-settings) configuration parameter. +## Common Pitfalls + +Understanding how Golden Config compliance works is straightforward once you grasp its mental model, but it often differs from what many users expect. Many compliance engines ask questions like "we should have NTP servers," focusing on the presence of certain configuration lines. In contrast, Golden Config compliance asks, "Does the intended NTP server configuration exactly match the actual configuration?" This approach is further distinguished by separating data from syntax using Jinja2 templates, rather than relying on regex matching or similar techniques. + +Because of this shift in perspective, new users commonly encounter the following pitfalls. + +### Data-Driven Templates vs Hardcoded Logic + +A common pitfall when getting started with configuration compliance is to hardcode configuration per group directly in templates: + +```jinja +{# Avoid this pattern — hardcoding config per region #} +{% if obj.location.parent.name == 'Americas' %} +ntp server 10.1.1.1 +ntp server 10.1.1.2 +{% elif obj.location.parent.name == 'Europe' %} +ntp server 10.2.1.1 +ntp server 10.2.1.2 +{% endif %} +``` + +This may work at first, but does not scale. Every new region, tenant, or role requires a template change. The recommended approach is to separate data from the cli syntax: + +```jinja +{# Recommended — data-driven config generation #} +{% for ntp_server in config_context.ntp_servers %} +ntp server {{ ntp_server }} +{% endfor %} +``` + +Different scopes produce different data, and the same template generates the correct configuration for each device. This means: + +- Adding a new region does not require a template change — just assign the correct config context data. +- The same template works for all devices on the platform regardless of tenant, role, or site. +- The compliance rule remains one rule per platform per feature. Different devices produce different intended configurations because the *data* is different, not the rule or the template logic. + +This same principle applies to the question of per-tenant, per-role, per-tag or literally any data point compliance rules. Different tenants using TACACS+ vs RADIUS do not need separate `aaa` compliance rules. They need one `aaa` rule and templates that generate the correct AAA configuration based on the device's data. + +### Compliance Rules Define Sections, Not Content + +Another common pitfall is to put specific configuration lines into the compliance rule's "Config to Match" field: + +``` +ntp server 10.2.1.1 +ntp server 10.2.1.2 +``` + +This conflates the role of the compliance rule with the role of the intended configuration. The "Config to Match" field is a **section matcher** — it identifies which section of the running configuration to extract for comparison. For NTP, the correct match config is: + +``` +ntp server +``` + +This captures all lines beginning with `ntp server` from both the backup and the intended configuration. The compliance engine then compares the two sets. The *specific NTP servers* that should be present are defined in the intended configuration, which is generated from your templates and data — not from the compliance rule. + +Putting full configuration lines in the rule creates several problems: + +- The rule becomes device-specific and cannot be shared across the platform. +- Changing an NTP server requires updating the rule and the template instead of updating data. +- It signals that this is how configurations are generated when it is not the case. + + +## Empty Compliance Behavior + +A common point of confusion is what happens when a compliance rule exists for a platform, but not every device on that platform needs every feature. For example: + +- A BGP compliance rule applies to all Cisco IOS devices, but campus access switches will never have BGP configuration. +- Multiple teams share a platform, but Team B hasn't built intended configurations for a feature that Team A already manages. +- Core routers need `dot1x` compliance, but access switches on the same platform do not. + +In all of these cases, the compliance engine compares the intended configuration against the actual configuration. **If you are generating the correct intended configuration, the answer is already handled.** A campus switch that should never have BGP should generate an empty intended BGP configuration — and an empty actual BGP section is a valid match. This is by design. + +However, this can create confusion when reviewing compliance results: + +- A device shows "Compliant" for BGP even though BGP was never relevant to it. +- A device shows "Non-Compliant" with "extra" configuration because Team B hasn't built the intended config yet, but the feature already exists on the device. + +### When Empty Results Are Misleading + +The real problem arises when a feature is not yet relevant or will never be relevant to a device, and the empty-vs-empty comparison produces a "Compliant" result that is misleading — or when a device has actual configuration but no intended configuration has been generated yet, producing a "Non-Compliant" result prematurely. + +The **Empty Compliance Behavior** setting addresses this. Navigate to `Golden Config -> Settings`, select a settings entry, and choose one of the three options: + +| Behavior | Use Case | Intended Empty + Actual Empty | Intended Empty + Actual Populated | Intended Populated | +|:--|:--|:--|:--|:--| +| **Validated** (default) | Standard compliance — empty configs are a valid match | Compliant | Normal compliance check | Normal compliance check | +| **Empty Both** | Features that will never apply to certain devices (e.g., BGP on campus switches) | N/A | Normal compliance check | Normal compliance check | +| **Empty Intended** | Gradual rollout — intended config is not yet generated for all devices | N/A | N/A | Normal compliance check | + +**Empty Both** is best when a feature permanently does not apply to a device. Neither the intended nor the actual configuration will ever exist, so there is nothing meaningful to validate. This removes the noise of "Compliant" results for features that were never relevant. + +**Empty Intended** is best when you are progressively rolling out intended configurations across teams or groups. Some devices may already have actual configuration on the device for a feature, but the intended configuration has not been built yet. Without this setting, those devices would show as "Non-Compliant" with extra configuration — even though no one has defined what the intended state should be. With **Empty Intended**, compliance is deferred until the intended configuration is generated. + +When a compliance record is marked as N/A: + +- The device tab shows a gray "N/A" badge instead of Compliant/Non-Compliant. +- The compliance overview table shows a dash for that feature. +- N/A records are excluded from compliance percentage calculations, compliant counts, and non-compliant counts. +- Remediation configuration is not generated for N/A records. + +### Practical Usage: Controlling Empty Configurations via Templates + +The Empty Compliance Behavior setting works in conjunction with your Jinja2 templates. By conditionally omitting configuration in your intended templates, you control which devices produce empty intended configs for a given feature — and the setting determines how those empty results are handled. + +For example, if you are gradually rolling out NTP compliance and only certain tenants are ready: + +```jinja +{# Only generate NTP configuration for tenants that have been onboarded #} +{% if obj.tenant and obj.tenant.name in ['Wayne Enterprise', 'Acme Corp'] %} +ntp server 10.1.1.1 +ntp server 10.2.2.2 +ntp source Loopback0 +{% endif %} +``` + +Devices belonging to tenants not in the list will produce an empty intended configuration for this feature. With **Empty Intended** selected, those devices will show N/A instead of being flagged as non-compliant. As you onboard additional tenants, simply add them to the template condition — their compliance will begin to be evaluated automatically. + +!!! note "Why not add filters to compliance rules?" + Compliance rules are intentionally scoped to the platform level only. Adding tenant, role, site, tag or series of other filters directly to compliance rules would significantly increase the complexity of the solution, as rules would need to support multiple filter combinations, priority resolution, and overlapping scope. The Empty Compliance Behavior setting, combined with data-driven templates, provides the same outcome with far less complexity. The compliance rule stays simple (one rule per platform per feature), and the templates encode your business logic for which devices are in scope. + ## Starting a Compliance Job To start a compliance job manually: @@ -50,15 +170,11 @@ Each configuration can be added and edits from this table. When editing/adding t ![Configuration Rule Edit](../images/ss1_ss_compliance-rule_light.png#only-light){ .on-glb } ![Configuration Rule Edit](../images/ss1_ss_compliance-rule_dark.png#only-dark){ .on-glb } -The platform must refer to a platform with a valid network_driver supported by the configuration compliance engine. While there is no enforcement of this data from -a database perspective, the job will never run successfully, rendering the additional configuration ineffective. +The platform must refer to a platform with a valid network_driver supported by the configuration compliance engine. While there is no enforcement of this data from a database perspective, the job will never run successfully, rendering the additional configuration ineffective. -The Feature is a unique identifier, that should prefer shorter names, as this effects the width of the compliance overview and thus it's readability as a -best practice. +The Feature is a unique identifier, that should prefer shorter names, as this effects the width of the compliance overview and thus it's readability as a best practice. -The "Config to Match" section represents the configuration root elements. This would be the parent most key only. Additionally, the match is based on -"Config Type", which could be JSON or CLI. For CLI based configs, the match is based on what a line starts with only. Meaning, there is an implicit greediness to the matching. All matches must start form the beginning of the line. -For JSON based configs, the match is based on JSON's structure top level key name. +The "Config to Match" section represents the configuration root elements. This would be the parent most key only. Additionally, the match is based on "Config Type", which could be JSON or CLI. For CLI based configs, the match is based on what a line starts with only. Meaning, there is an implicit greediness to the matching. All matches must start form the beginning of the line. For JSON based configs, the match is based on JSON's structure top level key name. !!! note "Config to Match" is mandatory for CLI configurations. If config to match is not defined for JSON, the complete JSON configuration will be compared. If the config to match is defined, comparison will take place only for defined keys. @@ -69,9 +185,13 @@ For JSON based configs, the match is based on JSON's structure top level key nam !!! note The mapping of "network_os" as defined by netutils is provided via the app settings in your nautobot_config.py, and documented on the primary Readme. +!!! note + See [Compliance Rules Define Sections, Not Content](#compliance-rules-define-sections-not-content) for guidance on what belongs in Config to Match versus in your templates. + ## Compliance View The compliance overview will provide a per device and feature overview on the compliance of your network devices. From here you can navigate to the details view. + ![Compliance Overview](../images/ss1_ss_compliance-overview_light.png#only-light){ .on-glb } ![Compliance Overview](../images/ss1_ss_compliance-overview_dark.png#only-dark){ .on-glb } diff --git a/docs/user/app_feature_compliancecustom.md b/docs/user/app_feature_compliancecustom.md index 43e2f958f..9fe19f32e 100644 --- a/docs/user/app_feature_compliancecustom.md +++ b/docs/user/app_feature_compliancecustom.md @@ -40,15 +40,17 @@ The interface of contract provided to your custom function is based on the follo ### Outputs -- The function should return a single dictionary, with the keys of `compliance`, `compliance_int`, `ordered`, `missing`, and `extra`. +- The function should return a single dictionary, with the keys of `compliance`, `ordered`, `missing`, and `extra`. - The `compliance` key should be a boolean with either True or False as acceptable responses, which determines if the config is compliant or not. -- The `compliance_int` key should be an integer with either 1 (when compliance is True) or 0 (when compliance is False) as acceptable responses. This is required to handle a counting use case where boolean does not suffice. - The `ordered` key should be a boolean with either True or False as acceptable responses, which determines if the config is compliant and ordered or not. - The `missing` key should be a string or json, empty when nothing is missing and appropriate string or json data when configuration is missing. - The `extra` key should be a string or json, empty when nothing is extra and appropriate string or json data when there is extra configuration. There is validation to ensure the data structure returned is compliant to the above assertions. +!!! warning "Deprecated" + The `compliance_int` key is deprecated and should no longer be returned. If present, a deprecation warning will be logged and the value will be ignored. The `compliance` boolean is the sole source of truth. + The function provided in string path format, must be installed in the same environment as nautobot and the workers. ## Configuration @@ -73,14 +75,12 @@ To provide boiler plate code for any future use case, the following is provided ```python def custom_compliance_func(obj): # Modify with actual logic, this would always presume compliant. - compliance_int = 1 compliance = True ordered = True missing = "" extra = "" return { "compliance": compliance, - "compliance_int": compliance_int, "ordered": ordered, "missing": missing, "extra": extra, @@ -116,20 +116,17 @@ def custom_compliance_func(obj): neighbors = list(set(neighbors)) secrets = list(set(secrets)) if secrets != neighbors: - compliance_int = 0 compliance = False ordered = False missing = f"neighbors Found: {str(neighbors)}\nneigbors with secrets found: {str(secrets)}" extra = "" else: - compliance_int = 1 compliance = True ordered = True missing = "" extra = "" return { "compliance": compliance, - "compliance_int": compliance_int, "ordered": ordered, "missing": missing, "extra": extra, diff --git a/docs/user/app_use_cases.md b/docs/user/app_use_cases.md index f1a10addf..8e668c914 100644 --- a/docs/user/app_use_cases.md +++ b/docs/user/app_use_cases.md @@ -65,6 +65,7 @@ To update existing settings click on one of the `Settings` name. |Jinja Path|A Jinja template which defines the path (within the repository) and name of the Jinja template file. e.g. `{{obj.platform.network_driver}}/{{obj.role.name}}/main.j2`| |Dynamic Group|The scope of devices on which Golden Config's jobs can operate. | |GraphQL Query|A query that is evaluated and used to render the config. The query must start with `query ($device_id: ID!)`.| +|Empty Compliance Behavior|Controls how compliance is evaluated when configurations are empty. **Validated** (default) treats empty-matches-empty as compliant. **Empty Both** marks as N/A when both actual and intended are empty. **Empty Intended** marks as N/A when intended is empty, regardless of actual. See [Empty Compliance Behavior](./app_feature_compliance.md#empty-compliance-behavior) for details.| !!! note Each of these will be further detailed in their respective sections. diff --git a/nautobot_golden_config/choices.py b/nautobot_golden_config/choices.py index ef896fa1b..0a12d2140 100644 --- a/nautobot_golden_config/choices.py +++ b/nautobot_golden_config/choices.py @@ -29,6 +29,20 @@ class RemediationTypeChoice(ChoiceSet): ) +class EmptyComplianceBehaviorChoice(ChoiceSet): + """Choiceset for how to handle compliance when configurations are empty.""" + + TYPE_VALIDATED = "validated" + TYPE_EMPTY_BOTH = "empty_both" + TYPE_EMPTY_INTENDED = "empty_intended" + + CHOICES = ( + (TYPE_VALIDATED, "Validated"), + (TYPE_EMPTY_BOTH, "Empty Both"), + (TYPE_EMPTY_INTENDED, "Empty Intended"), + ) + + class ConfigPlanTypeChoice(ChoiceSet): """Choiceset used by ConfigPlan.""" diff --git a/nautobot_golden_config/details.py b/nautobot_golden_config/details.py index c91e34239..ce91d8d44 100644 --- a/nautobot_golden_config/details.py +++ b/nautobot_golden_config/details.py @@ -94,7 +94,7 @@ def hyperlinked_field_with_icon(url, title, icon_class="mdi mdi-text-box-check-o label="General Settings", section=ui.SectionChoices.LEFT_HALF, weight=100, - fields=("weight", "description"), + fields=("weight", "description", "empty_compliance_behavior"), ), ui.KeyValueTablePanel( section=ui.SectionChoices.LEFT_HALF, diff --git a/nautobot_golden_config/management/commands/generate_gc_test_data.py b/nautobot_golden_config/management/commands/generate_gc_test_data.py index 02cc89494..adda759fe 100644 --- a/nautobot_golden_config/management/commands/generate_gc_test_data.py +++ b/nautobot_golden_config/management/commands/generate_gc_test_data.py @@ -90,7 +90,6 @@ def _generate_static_data(self, db): device=device, rule=rule, compliance=is_compliant, - compliance_int=int(is_compliant), intended=rule.match_config, actual=rule.match_config if is_compliant else f"mismatch {rule.feature.name}", ) diff --git a/nautobot_golden_config/management/commands/populate_compliance_data.py b/nautobot_golden_config/management/commands/populate_compliance_data.py index 368391b2b..57282ab3b 100644 --- a/nautobot_golden_config/management/commands/populate_compliance_data.py +++ b/nautobot_golden_config/management/commands/populate_compliance_data.py @@ -171,7 +171,6 @@ def handle(self, *args, **options): device=device, rule=rule, compliance=is_compliant, - compliance_int=1 if is_compliant else 0, actual=actual, intended=intended, missing=missing_config, diff --git a/nautobot_golden_config/metrics.py b/nautobot_golden_config/metrics.py index f5b2815aa..1bc746d1c 100644 --- a/nautobot_golden_config/metrics.py +++ b/nautobot_golden_config/metrics.py @@ -92,7 +92,7 @@ def metric_compliance_devices(): ) queryset = ConfigCompliance.objects.values("rule__feature__slug").annotate( compliant=Count("rule__feature__slug", filter=Q(compliance=True)), - non_compliant=Count("rule__feature__slug", filter=~Q(compliance=True)), + non_compliant=Count("rule__feature__slug", filter=Q(compliance=False)), ) counters = {item["rule__feature__slug"]: {"compliant": 0, "non_compliant": 0} for item in queryset} diff --git a/nautobot_golden_config/migrations/0032_remove_configcompliance_compliance_int_and_more.py b/nautobot_golden_config/migrations/0032_remove_configcompliance_compliance_int_and_more.py new file mode 100644 index 000000000..6c93902de --- /dev/null +++ b/nautobot_golden_config/migrations/0032_remove_configcompliance_compliance_int_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.29 on 2026-04-06 18:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("nautobot_golden_config", "0031_alter_configplan_change_control_url"), + ] + + operations = [ + migrations.RemoveField( + model_name="configcompliance", + name="compliance_int", + ), + migrations.AddField( + model_name="goldenconfigsetting", + name="empty_compliance_behavior", + field=models.CharField(default="validated", max_length=20), + ), + migrations.AlterField( + model_name="configcompliance", + name="compliance", + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index 472e1b206..7e0e3bb78 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -21,7 +21,12 @@ from netutils.config.compliance import feature_compliance from xmldiff import actions, main -from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice, ConfigPlanTypeChoice, RemediationTypeChoice +from nautobot_golden_config.choices import ( + ComplianceRuleConfigTypeChoice, + ConfigPlanTypeChoice, + EmptyComplianceBehaviorChoice, + RemediationTypeChoice, +) from nautobot_golden_config.utilities.constant import ENABLE_SOTAGG, PLUGIN_CFG LOGGER = logging.getLogger(__name__) @@ -72,17 +77,11 @@ def _get_cli_compliance(obj): feature, obj.actual, obj.intended, obj.device.platform.network_driver_mappings.get("netutils_parser") ) compliance = value["compliant"] - if compliance: - compliance_int = 1 - ordered = value["ordered_compliant"] - else: - compliance_int = 0 - ordered = value["ordered_compliant"] + ordered = value["ordered_compliant"] missing = _null_to_empty(value["missing"]) extra = _null_to_empty(value["extra"]) return { "compliance": compliance, - "compliance_int": compliance_int, "ordered": ordered, "missing": missing, "extra": extra, @@ -104,13 +103,11 @@ def _normalize_diff(diff, path_to_diff): obj.actual, obj.intended, ignore_order=obj.ordered, report_repetition=True, threshold_to_diff_deeper=0 ) if not diff: - compliance_int = 1 compliance = True ordered = True missing = "" extra = "" else: - compliance_int = 0 compliance = False ordered = False missing = _null_to_empty(_normalize_diff(diff, "added")) @@ -118,7 +115,6 @@ def _normalize_diff(diff, path_to_diff): return { "compliance": compliance, - "compliance_int": compliance_int, "ordered": ordered, "missing": missing, "extra": extra, @@ -146,14 +142,12 @@ def _normalize_diff(diff): extra = main.diff_texts(obj.intended, obj.actual, diff_options=diff_options) compliance = not missing and not extra - compliance_int = int(compliance) ordered = obj.ordered missing = _null_to_empty(_normalize_diff(missing)) extra = _null_to_empty(_normalize_diff(extra)) return { "compliance": compliance, - "compliance_int": compliance_int, "ordered": ordered, "missing": missing, "extra": extra, @@ -162,16 +156,16 @@ def _normalize_diff(diff): def _verify_get_custom_compliance_data(compliance_details): """This function verifies the data is as expected when a custom function is used.""" - for val in ["compliance", "compliance_int", "ordered", "missing", "extra"]: + for val in ["compliance", "ordered", "missing", "extra"]: try: compliance_details[val] except KeyError: raise ValidationError(MISSING_MSG.format(val)) from KeyError + if "compliance_int" in compliance_details: + LOGGER.warning("The 'compliance_int' key in custom compliance return data is deprecated and will be ignored. Please remove this key from your custom compliance return result going forward.") for val in ["compliance", "ordered"]: if compliance_details[val] not in [True, False]: raise ValidationError(VALIDATION_MSG.format(val, "Boolean", compliance_details[val])) - if compliance_details["compliance_int"] not in [0, 1]: - raise ValidationError(VALIDATION_MSG.format("compliance_int", "0 or 1", compliance_details["compliance_int"])) for val in ["missing", "extra"]: if not isinstance(compliance_details[val], str) and not _is_jsonable(compliance_details[val]): raise ValidationError(VALIDATION_MSG.format(val, "String or Json", compliance_details[val])) @@ -371,7 +365,7 @@ class ConfigCompliance(PrimaryModel): # pylint: disable=too-many-ancestors device = models.ForeignKey(to="dcim.Device", on_delete=models.CASCADE, help_text="The device") rule = models.ForeignKey(to="ComplianceRule", on_delete=models.CASCADE, related_name="rule") - compliance = models.BooleanField(blank=True) + compliance = models.BooleanField(blank=True, null=True) actual = models.JSONField(blank=True, help_text="Actual Configuration for feature") intended = models.JSONField(blank=True, help_text="Intended Configuration for feature") # these three are config snippets exposed for the ConfigDeployment. @@ -379,8 +373,17 @@ class ConfigCompliance(PrimaryModel): # pylint: disable=too-many-ancestors missing = models.JSONField(blank=True, help_text="Configuration that should be on the device.") extra = models.JSONField(blank=True, help_text="Configuration that should not be on the device.") ordered = models.BooleanField(default=False) - # Used for django-pivot, both compliance and compliance_int should be set. - compliance_int = models.IntegerField(blank=True) + + @property + def compliance_int(self): + """Backwards-compatible property mapping compliance boolean to integer.""" + if self.compliance is None: + return None + return int(self.compliance) + + @compliance_int.setter + def compliance_int(self, value): + """No-op setter for backwards compatibility.""" is_saved_view_model = False @@ -415,6 +418,23 @@ def __str__(self): def compliance_on_save(self): """The actual configuration compliance happens here, but the details for actual compliance job would be found in FUNC_MAPPER.""" + if self.intended == "": + setting = getattr(self, "_golden_config_setting", None) or GoldenConfigSetting.objects.get_for_device( + self.device + ) + behavior = setting.empty_compliance_behavior if setting else EmptyComplianceBehaviorChoice.TYPE_VALIDATED + if behavior == EmptyComplianceBehaviorChoice.TYPE_EMPTY_INTENDED: + self.compliance = None + self.missing = self.missing or "" + self.extra = self.extra or "" + return + if behavior == EmptyComplianceBehaviorChoice.TYPE_EMPTY_BOTH and self.actual == "": + self.compliance = None + self.missing = self.missing or "" + self.extra = self.extra or "" + return + # TYPE_VALIDATED falls through to normal compliance check + if self.rule.custom_compliance: if not FUNC_MAPPER.get("custom"): raise ValidationError( @@ -426,14 +446,13 @@ def compliance_on_save(self): compliance_details = FUNC_MAPPER[self.rule.config_type](obj=self) self.compliance = compliance_details["compliance"] - self.compliance_int = compliance_details["compliance_int"] self.ordered = compliance_details["ordered"] self.missing = compliance_details["missing"] self.extra = compliance_details["extra"] def remediation_on_save(self): """The actual remediation happens here, before saving the object.""" - if self.compliance: + if self.compliance or self.compliance is None: self.remediation = "" return @@ -457,9 +476,7 @@ def save(self, *args, **kwargs): # This accounts for django 4.2 `Setting update_fields in Model.save() may now be required` change # in behavior if kwargs.get("update_fields"): - kwargs["update_fields"].update( - {"compliance", "compliance_int", "ordered", "missing", "extra", "remediation"} - ) + kwargs["update_fields"].update({"compliance", "ordered", "missing", "extra", "remediation"}) super().save(*args, **kwargs) @@ -609,6 +626,16 @@ class GoldenConfigSetting(PrimaryModel): # pylint: disable=too-many-ancestors verbose_name="Backup Test", help_text="Whether or not to pretest the connectivity of the device by verifying there is a resolvable IP that can connect to port 22.", ) + empty_compliance_behavior = models.CharField( + max_length=20, + choices=EmptyComplianceBehaviorChoice, + default=EmptyComplianceBehaviorChoice.TYPE_VALIDATED, + verbose_name="Empty Compliance Behavior", + help_text="How to handle compliance when configurations are empty. " + "'Validated' (default) treats empty-matches-empty as compliant. " + "'Empty Both' marks as N/A when both actual and intended are empty. " + "'Empty Intended' marks as N/A when intended is empty, regardless of actual.", + ) sot_agg_query = models.ForeignKey( to="extras.GraphQLQuery", on_delete=models.PROTECT, diff --git a/nautobot_golden_config/tables.py b/nautobot_golden_config/tables.py index 03be49b30..26a48b4f4 100644 --- a/nautobot_golden_config/tables.py +++ b/nautobot_golden_config/tables.py @@ -136,16 +136,24 @@ def render(self, value): class ComplianceColumn(Column): - """Column used to display config compliance status (True/False/None).""" + """Column used to display config compliance status. + + Values from the pivot subquery: + "True" - compliant + "False" - non-compliant + "None" - N/A (record exists, compliance is NULL) + None - no record for this device+feature (no subquery match) + """ def render(self, value): """Render an entry in this column.""" - if value == 1: # pylint: disable=no-else-return + if value == "True": return format_html('') - elif value == 0: + if value == "False": return format_html('') - else: # value is None - return format_html('') + if value == "None": + return format_html('- -') + return format_html('') # diff --git a/nautobot_golden_config/template_content.py b/nautobot_golden_config/template_content.py index 8b11292e6..3e5dc166b 100644 --- a/nautobot_golden_config/template_content.py +++ b/nautobot_golden_config/template_content.py @@ -64,7 +64,7 @@ def right_page(self): .annotate( count=Count("rule__feature__name"), compliant=Count("rule__feature__name", filter=Q(compliance=True)), - non_compliant=Count("rule__feature__name", filter=~Q(compliance=True)), + non_compliant=Count("rule__feature__name", filter=Q(compliance=False)), ) .order_by("rule__feature__name") .values("rule__feature__name", "compliant", "non_compliant") @@ -122,7 +122,7 @@ def right_page(self): .annotate( count=Count("rule__feature__name"), compliant=Count("rule__feature__name", filter=Q(compliance=True)), - non_compliant=Count("rule__feature__name", filter=~Q(compliance=True)), + non_compliant=Count("rule__feature__name", filter=Q(compliance=False)), ) .order_by("rule__feature__name") .values("rule__feature__name", "compliant", "non_compliant") diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_devicetab.html b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_devicetab.html index 8b006aa6a..78e9caa8b 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_devicetab.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_devicetab.html @@ -72,6 +72,7 @@
Compliant Non-Compliant + N/A Clear
@@ -79,7 +80,9 @@ {% for item in compliance_details %} - {% if item.compliance %} + {% if item.compliance == None %} +
+ {% elif item.compliance %} {% else %} @@ -120,7 +123,9 @@ - {% if item.rule.config_ordered %} + {% if item.compliance == None %} + + {% elif item.rule.config_ordered %} {% if item.compliance %} {% 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"
StatusN/ACompliant