diff --git a/changes/configplan-approval.added b/changes/configplan-approval.added new file mode 100644 index 000000000..1881b45d1 --- /dev/null +++ b/changes/configplan-approval.added @@ -0,0 +1 @@ +Added an `approval_state` filter and column to the Config Plan list view and a read-only `approval_state` field to the Config Plan REST API. diff --git a/changes/configplan-approval.breaking b/changes/configplan-approval.breaking new file mode 100644 index 000000000..9fec62570 --- /dev/null +++ b/changes/configplan-approval.breaking @@ -0,0 +1 @@ +Replaced Config Plan status-based approval with Nautobot's native Approval Workflow. New plans now trigger a `Pending` approval workflow on creation and the Deploy Config Plans job refuses to push configuration until that workflow is `Approved`. diff --git a/changes/configplan-approval.documentation b/changes/configplan-approval.documentation new file mode 100644 index 000000000..2a817cc00 --- /dev/null +++ b/changes/configplan-approval.documentation @@ -0,0 +1 @@ +Updated the Config Plans user guide and the E3025 troubleshooting page to describe the new Approval Workflow integration, default approver/architect/operator groups, and admin override procedure. diff --git a/changes/configplan-approval.removed b/changes/configplan-approval.removed new file mode 100644 index 000000000..2bbe2dc51 --- /dev/null +++ b/changes/configplan-approval.removed @@ -0,0 +1 @@ +Removed the `default_deploy_status` plugin configuration key and the `Approved` and `Not Approved` Config Plan statuses; approval is now handled by Nautobot's Approval Workflow. diff --git a/development/nautobot_config.py b/development/nautobot_config.py index 45b81386b..22de2c5ec 100644 --- a/development/nautobot_config.py +++ b/development/nautobot_config.py @@ -156,7 +156,6 @@ "lstrip_blocks": is_truthy(os.getenv("NAUTOBOT_JINJA_ENV_LSTRIP_BLOCKS", "false")), }, # "get_custom_compliance": "my.custom_compliance.func", - # "default_deploy_status": "Not Approved", # # # custom_dispatcher is not required for preferring a framework such as netmiko or napalm. diff --git a/docs/admin/troubleshooting/E3025.md b/docs/admin/troubleshooting/E3025.md index 985793eb1..952738a10 100644 --- a/docs/admin/troubleshooting/E3025.md +++ b/docs/admin/troubleshooting/E3025.md @@ -1,19 +1,21 @@ -# E30XX Details +# E3025 Details -## Message emitted: +## Message emitted -`E30XX: Details coming soon` +`E3025: Cannot deploy configuration(s). One or more config plans have an approval workflow that is not in the` Approved `state.` -## Description: +## Description -Description that is coming soon. +The **Deploy Config Plans** job refuses to push configuration to devices when any plan in the batch has an associated Nautobot Approval Workflow that has not yet reached the `Approved` state (it is still `Pending`, was `Denied`, or was `Canceled`). Plans that have no associated workflow at all — i.e. an admin removed the `Config Plan Approval` workflow definition before the plan was generated — are allowed through. -## Troubleshooting: +## Troubleshooting -Troubleshooting that is coming soon. - -## Recommendation: - -Recommendation that is coming soon. +1. Identify the offending plan(s). The Config Plan list and detail views display an **Approval State** column / panel that surfaces each plan's current state and links to its workflow. +2. From the workflow detail page (or from the Approver Dashboard at `/extras/approver-dashboard/`), an approver in the `nautobot-default-configplan-approver` group can approve or deny the pending stage(s). +3. If the workflow was denied or canceled, generate a new Config Plan; the previous plan cannot be re-approved. +## Recommendation +- Approval is gated by the `Config Plan Approval` workflow seeded by the plugin. Admins can amend the approver group (or any other stage attribute) under **Extras → Approval Workflow Definitions**. +- To disable approvals entirely, delete the `Config Plan Approval` definition. New Config Plans generated afterwards will deploy without approval. Existing plans with pending workflows are still gated until those workflows reach `Approved`, are deleted, or the deploy is run with a different set of plans. +- The `Workflow Architect` persona (the `nautobot-default-configplan-architect` group) holds the permissions required to manage the workflow definition and its stages. diff --git a/docs/user/app_feature_config_plans.md b/docs/user/app_feature_config_plans.md index dfa94f451..a352cc608 100644 --- a/docs/user/app_feature_config_plans.md +++ b/docs/user/app_feature_config_plans.md @@ -26,7 +26,8 @@ You can view a plan by navigating to **Golden Config -> Config Plans** and choos - **Change Control ID** (Optional): A text field that be used for grouping and filtering plans. - **Change Control URL** (Optional): A URL field that can be used to link to an external system tracking change controls. - **Job Result**: The Job that generated the plan(s). -- **Status**: The status of the plan. +- **Status**: The deployment lifecycle status of the plan. Only deployment-related values (`In Progress`, `Completed`, `Failed`) are used; the field is empty until the deploy job runs. Approval is tracked separately, in the linked **Approval Workflow** (see [Approving Config Plans](#approving-config-plans)). +- **Approval State**: The current state of the plan's Approval Workflow (`Pending`, `Approved`, `Denied`, or `Canceled`). ![Config Plan View](../images/ss1_config_plan-view_light.png#only-light){ .on-glb } ![Config Plan View](../images/ss1_config_plan-view_dark.png#only-dark){ .on-glb } @@ -60,17 +61,18 @@ After a Config Plan is generated you have the ability to edit (or bulk edit) the - Change Control ID - Change Control URL -- Status - Notes - Tags +The `Status` field is managed by the Deploy Config Plans job (it tracks the deployment lifecycle only — `In Progress`, `Completed`, `Failed`). Approval is no longer expressed through `Status`; see [Approving Config Plans](#approving-config-plans) below. + !!! note You will not be able to modify the Config Set after generation. If it does not contain the desired commands, you will need to delete the plan and recreate it after ensuring the source of the generated commands has been updated. ![Config Plan Edit](../images/ss1_config_plan-edit_light.png#only-light){ .on-glb } ![Config Plan Edit](../images/ss1_config_plan-edit_dark.png#only-dark){ .on-glb } -If the Config Plan has post processing functions, you can render the post processed config to validate and approve a Config Plan. +If the Config Plan has post processing functions, you can render the post processed config before approving it. ![Config Plan Post Processing Button](../images/ss1_config_plan_pp_button_light.png#only-light){ .on-glb } ![Config Plan Post Processing Button](../images/ss1_config_plan_pp_button_dark.png#only-dark){ .on-glb } @@ -79,3 +81,33 @@ Post Processing occurs in a modal popup, and allows a user to view the configura ![Intended Configuration Web UI](../images/ss1_config_plan_pp-rendered_light.png#only-light){ .on-glb } ![Intended Configuration Web UI](../images/ss1_config_plan_pp-rendered_dark.png#only-dark){ .on-glb } + +## Approving Config Plans + +Config Plan approval is implemented on top of Nautobot's native [Approval Workflow](https://docs.nautobot.com/projects/core/en/stable/user-guide/platform-functionality/approval-workflow/) subsystem. Every new Config Plan automatically gets a `Pending` Approval Workflow attached to it; the **Deploy Config Plans** job refuses to push configuration (error `E3025`) until that workflow is in the `Approved` state. + +### Default Workflow & Groups + +On first install (or upgrade from a release that still used status-based approval) the plugin seeds: + +- A workflow definition named **`Config Plan Approval`** that targets `ConfigPlan` with no filter, so it matches every plan; +- A single approval stage requiring **1 approver** from the `nautobot-default-configplan-approver` group; +- Three groups modeling the standard Workflow personas: + - `nautobot-default-configplan-architect` — can create, change, and delete the workflow definition and its stages; + - `nautobot-default-configplan-approver` — can approve or deny pending stages; + - `nautobot-default-configplan-operator` — read-only view of all workflows. + +Add the appropriate users to these groups after install. Approvers will then see pending Config Plan approvals on the standard Nautobot **Approver Dashboard** at `/extras/approver-dashboard/`. + +### Customizing or Disabling Approval + +Administrators can amend the seeded workflow under **Extras → Approval Workflow Definitions → Config Plan Approval**: + +- Change `model_constraints` to limit approval to a subset of plans (for example, only plans where `plan_type="remediation"`). +- Edit the stage to require more approvers, change the approver group, or add additional stages. + +To disable approval entirely, delete the `Config Plan Approval` workflow definition. New plans generated afterwards will have no associated workflow and the deploy job will allow them through. Existing plans whose workflow was already attached remain gated until that workflow reaches `Approved` or is otherwise resolved. + +### Upgrade Note + +On the upgrade migration that introduces this feature, every existing Config Plan that had not yet been deployed (status `Approved`, `Not Approved`, or empty) is given a fresh `Pending` approval workflow. The `Approved` and `Not Approved` Status records are then removed because they are no longer used. Plans already in `In Progress`, `Completed`, or `Failed` are left untouched. diff --git a/nautobot_golden_config/__init__.py b/nautobot_golden_config/__init__.py index 3c3624f8a..dd4cec6dc 100644 --- a/nautobot_golden_config/__init__.py +++ b/nautobot_golden_config/__init__.py @@ -28,7 +28,6 @@ class GoldenConfig(NautobotAppConfig): "enable_postprocessing": False, "enable_plan": True, "enable_deploy": True, - "default_deploy_status": "Not Approved", "postprocessing_callables": [], "postprocessing_subscribed": [], "per_feature_bar_width": 0.3, diff --git a/nautobot_golden_config/api/serializers.py b/nautobot_golden_config/api/serializers.py index 9be99a287..95d2f5b88 100644 --- a/nautobot_golden_config/api/serializers.py +++ b/nautobot_golden_config/api/serializers.py @@ -124,12 +124,19 @@ class Meta: class ConfigPlanSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): """Serializer for ConfigPlan object.""" + approval_state = serializers.SerializerMethodField(read_only=True) + class Meta: """Set Meta Data for ConfigPlan, will serialize all fields.""" model = models.ConfigPlan fields = "__all__" - read_only_fields = ["device", "plan_type", "feature", "config_set"] + read_only_fields = ["device", "plan_type", "feature", "config_set", "status"] + + def get_approval_state(self, obj): + """Return the current approval workflow state for this plan, or None if no workflow exists.""" + workflow = obj.associated_approval_workflows.first() + return workflow.current_state if workflow else None class GenerateIntendedConfigSerializer(serializers.Serializer): # pylint: disable=abstract-method diff --git a/nautobot_golden_config/details.py b/nautobot_golden_config/details.py index c91e34239..1c6c64b66 100644 --- a/nautobot_golden_config/details.py +++ b/nautobot_golden_config/details.py @@ -199,6 +199,12 @@ def hyperlinked_field_with_icon(url, title, icon_class="mdi mdi-text-box-check-o "feature": [get_model_instances, helpers.placeholder], }, ), + ui.Panel( + label="Approval Workflow", + weight=150, + section=ui.SectionChoices.LEFT_HALF, + body_content_template_path="nautobot_golden_config/configplan_approval.html", + ), ui.ObjectFieldsPanel( section=ui.SectionChoices.RIGHT_HALF, label="Config Deployment Details", @@ -214,12 +220,12 @@ def hyperlinked_field_with_icon(url, title, icon_class="mdi mdi-text-box-check-o ), ui.Panel( label="Postprocessed Config Set", - weight=100, + weight=200, section=ui.SectionChoices.RIGHT_HALF, body_content_template_path="nautobot_golden_config/configplan_postprocessing.html", ), ui.ObjectTextPanel( - weight=200, + weight=300, label="Config Set", section=ui.SectionChoices.FULL_WIDTH, object_field="config_set", diff --git a/nautobot_golden_config/filters.py b/nautobot_golden_config/filters.py index b876cef3a..9eb5845bf 100644 --- a/nautobot_golden_config/filters.py +++ b/nautobot_golden_config/filters.py @@ -10,6 +10,7 @@ TreeNodeMultipleChoiceFilter, ) from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup +from nautobot.extras.choices import ApprovalWorkflowStateChoices from nautobot.extras.models import JobResult, Role, Status from nautobot.tenancy.models import Tenant, TenantGroup @@ -444,6 +445,12 @@ class ConfigPlanFilterSet(NautobotFilterSet): to_field_name="name", label="Status", ) + approval_state = django_filters.MultipleChoiceFilter( + field_name="associated_approval_workflows__current_state", + choices=ApprovalWorkflowStateChoices, + label="Approval State", + distinct=True, + ) class Meta: """Boilerplate filter Meta data for Config Plan.""" diff --git a/nautobot_golden_config/forms.py b/nautobot_golden_config/forms.py index 654e6be86..531a3315e 100644 --- a/nautobot_golden_config/forms.py +++ b/nautobot_golden_config/forms.py @@ -8,6 +8,7 @@ from nautobot.apps import forms from nautobot.apps.forms import NautobotBulkEditForm, NautobotFilterForm, NautobotModelForm from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup +from nautobot.extras.choices import ApprovalWorkflowStateChoices from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, JobResult, Role, Status, Tag from nautobot.tenancy.models import Tenant, TenantGroup from packaging import version @@ -568,12 +569,12 @@ class Meta: class ConfigPlanUpdateForm(NautobotModelForm): # pylint: disable=nb-sub-class-name - """Form for ConfigPlan instances.""" + """Form for ConfigPlan instances. + + Status is owned by the deploy job (lifecycle tracking only) and approval is managed + via Nautobot's Approval Workflow, so neither is editable here. + """ - status = forms.DynamicModelChoiceField( - queryset=Status.objects.all(), - query_params={"content_types": models.ConfigPlan._meta.label_lower}, - ) tags = forms.DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), query_params={"content_types": "dcim.device"}, required=False ) @@ -585,7 +586,6 @@ class Meta: fields = ( # pylint: disable=nb-use-fields-all "change_control_id", "change_control_url", - "status", "tags", ) @@ -637,20 +637,25 @@ class ConfigPlanFilterForm(DeviceRelatedFilterForm): label="Status", to_field_name="name", ) + approval_state = django_forms.MultipleChoiceField( + choices=ApprovalWorkflowStateChoices, + required=False, + label="Approval State", + widget=forms.StaticSelect2Multiple(), + ) tags = forms.TagFilterField(model) class ConfigPlanBulkEditForm(NautobotBulkEditForm): - """BulkEdit form for ConfigPlan instances.""" + """BulkEdit form for ConfigPlan instances. + + Status is owned by the deploy job and approval is managed via Nautobot's Approval + Workflow, so neither is editable here. + """ pk = django_forms.ModelMultipleChoiceField( queryset=models.ConfigPlan.objects.all(), widget=django_forms.MultipleHiddenInput ) - status = forms.DynamicModelChoiceField( - queryset=Status.objects.all(), - query_params={"content_types": models.ConfigPlan._meta.label_lower}, - required=False, - ) change_control_id = django_forms.CharField(required=False, label="Change Control ID") change_control_url = django_forms.URLField(required=False, label="Change Control URL", max_length=2048) diff --git a/nautobot_golden_config/jobs.py b/nautobot_golden_config/jobs.py index ff84d8612..94e95a47e 100644 --- a/nautobot_golden_config/jobs.py +++ b/nautobot_golden_config/jobs.py @@ -38,7 +38,6 @@ from nautobot_golden_config.nornir_plays.config_intended import config_intended from nautobot_golden_config.utilities import constant from nautobot_golden_config.utilities.config_plan import ( - config_plan_default_status, generate_config_set_from_compliance_feature, generate_config_set_from_manual, ) @@ -421,14 +420,6 @@ def __init__(self, *args, **kwargs): self._change_control_url = None self._commands = None self._device_qs = Device.objects.none() - self._plan_status = None - - @property - def plan_status(self): - """The default status for ConfigPlan.""" - if self._plan_status is None: - self._plan_status = config_plan_default_status() - return self._plan_status def _validate_inputs(self, data): self._plan_type = data["plan_type"] @@ -472,7 +463,6 @@ def _generate_config_plan_from_feature(self): config_set=config_set, change_control_id=self._change_control_id, change_control_url=self._change_control_url, - status=self.plan_status, plan_result=self.job_result, ) config_plan.feature.set(features) @@ -501,7 +491,6 @@ def _generate_config_plan_from_manual(self): config_set=config_set, change_control_id=self._change_control_id, change_control_url=self._change_control_url, - status=self.plan_status, plan_result=self.job_result, ) self.logger.info(f"Config plan created for {device} with manual commands.", extra={"object": config_plan}) diff --git a/nautobot_golden_config/migrations/0032_approval_workflow_seed_data.py b/nautobot_golden_config/migrations/0032_approval_workflow_seed_data.py new file mode 100644 index 000000000..44eae94b0 --- /dev/null +++ b/nautobot_golden_config/migrations/0032_approval_workflow_seed_data.py @@ -0,0 +1,122 @@ +"""Seed the default ApprovalWorkflow definition and architect/approver/operator groups for ConfigPlan.""" + +from django.db import migrations + +ARCHITECT_GROUP = "nautobot-default-configplan-architect" +APPROVER_GROUP = "nautobot-default-configplan-approver" +OPERATOR_GROUP = "nautobot-default-configplan-operator" + +WORKFLOW_NAME = "Config Plan Approval" +STAGE_NAME = "Approval by nautobot-default-configplan-approver" + +ARCHITECT_PERMS = "nautobot-default-configplan-architect-permissions" +APPROVER_PERMS = "nautobot-default-configplan-approver-permissions" +OPERATOR_PERMS = "nautobot-default-configplan-operator-permissions" + + +def create_default_configplan_workflow(apps, schema_editor): + Group = apps.get_model("auth", "Group") + ObjectPermission = apps.get_model("users", "ObjectPermission") + ApprovalWorkflow = apps.get_model("extras", "ApprovalWorkflow") + ApprovalWorkflowStage = apps.get_model("extras", "ApprovalWorkflowStage") + ApprovalWorkflowStageResponse = apps.get_model("extras", "ApprovalWorkflowStageResponse") + ApprovalWorkflowDefinition = apps.get_model("extras", "ApprovalWorkflowDefinition") + ApprovalWorkflowStageDefinition = apps.get_model("extras", "ApprovalWorkflowStageDefinition") + ContentType = apps.get_model("contenttypes", "ContentType") + ConfigPlan = apps.get_model("nautobot_golden_config", "ConfigPlan") + + groups = { + ARCHITECT_GROUP: Group.objects.get_or_create(name=ARCHITECT_GROUP)[0], + APPROVER_GROUP: Group.objects.get_or_create(name=APPROVER_GROUP)[0], + OPERATOR_GROUP: Group.objects.get_or_create(name=OPERATOR_GROUP)[0], + } + + ct_workflow = ContentType.objects.get_for_model(ApprovalWorkflow) + ct_stage = ContentType.objects.get_for_model(ApprovalWorkflowStage) + ct_response = ContentType.objects.get_for_model(ApprovalWorkflowStageResponse) + ct_workflow_def = ContentType.objects.get_for_model(ApprovalWorkflowDefinition) + ct_stage_def = ContentType.objects.get_for_model(ApprovalWorkflowStageDefinition) + ct_configplan = ContentType.objects.get_for_model(ConfigPlan) + + awf_def, _ = ApprovalWorkflowDefinition.objects.update_or_create( + name=WORKFLOW_NAME, + defaults={ + "model_content_type_id": ct_configplan.id, + "model_constraints": {}, + "weight": 100, + }, + ) + + ApprovalWorkflowStageDefinition.objects.update_or_create( + name=STAGE_NAME, + defaults={ + "approval_workflow_definition": awf_def, + "sequence": 10, + "min_approvers": 1, + "denial_message": "This Config Plan requires an approval from nautobot-default-configplan-approver.", + "approver_group": groups[APPROVER_GROUP], + }, + ) + + perms_data = [ + { + "name": ARCHITECT_PERMS, + "description": "Golden Config: permissions aligned to the Workflow Architect persona for ConfigPlan.", + "enabled": True, + "actions": ["view", "add", "change", "delete"], + "object_types": [ct_workflow_def.id, ct_stage_def.id], + "groups": [groups[ARCHITECT_GROUP]], + }, + { + "name": APPROVER_PERMS, + "description": "Golden Config: permissions aligned to the Workflow Approver persona for ConfigPlan.", + "enabled": True, + "actions": ["view", "change"], + "object_types": [ct_stage.id], + "groups": [groups[APPROVER_GROUP]], + }, + { + "name": OPERATOR_PERMS, + "description": "Golden Config: permissions aligned to the Workflow Operator persona for ConfigPlan.", + "enabled": True, + "actions": ["view"], + "object_types": [ct_workflow.id, ct_stage.id, ct_response.id], + "groups": [ + groups[OPERATOR_GROUP], + groups[APPROVER_GROUP], + groups[ARCHITECT_GROUP], + ], + }, + ] + + for perm in perms_data: + obj, _ = ObjectPermission.objects.update_or_create( + name=perm["name"], + defaults={ + "description": perm["description"], + "enabled": perm["enabled"], + "actions": perm["actions"], + }, + ) + obj.object_types.set(perm["object_types"]) + obj.groups.set(perm["groups"]) + obj.save() + + +def reverse_default_configplan_workflow(apps, schema_editor): + ObjectPermission = apps.get_model("users", "ObjectPermission") + ApprovalWorkflowDefinition = apps.get_model("extras", "ApprovalWorkflowDefinition") + + ObjectPermission.objects.filter(name__in=[ARCHITECT_PERMS, APPROVER_PERMS, OPERATOR_PERMS]).delete() + ApprovalWorkflowDefinition.objects.filter(name=WORKFLOW_NAME).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("nautobot_golden_config", "0031_alter_configplan_change_control_url"), + ("extras", "0132_approval_workflow_seed_data"), + ] + + operations = [ + migrations.RunPython(create_default_configplan_workflow, reverse_default_configplan_workflow), + ] diff --git a/nautobot_golden_config/migrations/0033_configplan_force_reapproval.py b/nautobot_golden_config/migrations/0033_configplan_force_reapproval.py new file mode 100644 index 000000000..222f19f8e --- /dev/null +++ b/nautobot_golden_config/migrations/0033_configplan_force_reapproval.py @@ -0,0 +1,102 @@ +"""Force re-approval of existing ConfigPlans that have not yet been deployed. + +Any ConfigPlan with status `Approved`, `Not Approved`, or no status is treated as +"not yet deployed" and gets a fresh `Pending` ApprovalWorkflow attached to the +default ConfigPlan workflow definition seeded by 0032. Their status is then nulled +so the `Approved` / `Not Approved` Status records can be cleaned up later. Plans +in `In Progress`, `Completed`, or `Failed` are left untouched. +""" + +from django.db import migrations + +WORKFLOW_NAME = "Config Plan Approval" +PENDING = "Pending" + + +def force_reapproval(apps, schema_editor): + ConfigPlan = apps.get_model("nautobot_golden_config", "ConfigPlan") + ApprovalWorkflow = apps.get_model("extras", "ApprovalWorkflow") + ApprovalWorkflowStage = apps.get_model("extras", "ApprovalWorkflowStage") + ApprovalWorkflowDefinition = apps.get_model("extras", "ApprovalWorkflowDefinition") + ContentType = apps.get_model("contenttypes", "ContentType") + + try: + workflow_def = ApprovalWorkflowDefinition.objects.get(name=WORKFLOW_NAME) + except ApprovalWorkflowDefinition.DoesNotExist: + # Admin removed the seeded definition before the migration ran; nothing to gate against. + return + + stage_defs = list(workflow_def.approval_workflow_stage_definitions.all()) + ct_configplan = ContentType.objects.get_for_model(ConfigPlan) + + plans = ConfigPlan.objects.filter(status__name__in=["Approved", "Not Approved"]) | ConfigPlan.objects.filter( + status__isnull=True + ) + plans = plans.distinct() + + for plan in plans.iterator(): + workflow = ApprovalWorkflow.objects.create( + approval_workflow_definition=workflow_def, + object_under_review_content_type=ct_configplan, + object_under_review_object_id=plan.pk, + current_state=PENDING, + user_name="", + ) + ApprovalWorkflowStage.objects.bulk_create( + [ + ApprovalWorkflowStage( + approval_workflow=workflow, + approval_workflow_stage_definition=stage_def, + state=PENDING, + ) + for stage_def in stage_defs + ] + ) + + plans.update(status=None) + + +def reverse_force_reapproval(apps, schema_editor): + """Best-effort reversal: drop the workflows we attached and re-apply `Not Approved` to plans without status. + + Note: plans that were previously `Approved` cannot be distinguished from those that were + `Not Approved` after this migration runs (both end up with status=None), so the reverse + restores them all to `Not Approved`. This is acceptable because deployment is gated by + workflow state, not status, and a downgrade would require the `Not Approved` Status + record to exist (re-created by the reverse of 0034 if applicable). + """ + ConfigPlan = apps.get_model("nautobot_golden_config", "ConfigPlan") + ApprovalWorkflow = apps.get_model("extras", "ApprovalWorkflow") + ApprovalWorkflowDefinition = apps.get_model("extras", "ApprovalWorkflowDefinition") + ContentType = apps.get_model("contenttypes", "ContentType") + Status = apps.get_model("extras", "Status") + + ct_configplan = ContentType.objects.get_for_model(ConfigPlan) + + try: + workflow_def = ApprovalWorkflowDefinition.objects.get(name=WORKFLOW_NAME) + except ApprovalWorkflowDefinition.DoesNotExist: + workflow_def = None + + if workflow_def is not None: + ApprovalWorkflow.objects.filter( + approval_workflow_definition=workflow_def, + object_under_review_content_type=ct_configplan, + ).delete() + + try: + not_approved = Status.objects.get(name="Not Approved") + except Status.DoesNotExist: + return + + ConfigPlan.objects.filter(status__isnull=True).update(status=not_approved) + + +class Migration(migrations.Migration): + dependencies = [ + ("nautobot_golden_config", "0032_approval_workflow_seed_data"), + ] + + operations = [ + migrations.RunPython(force_reapproval, reverse_force_reapproval), + ] diff --git a/nautobot_golden_config/migrations/0034_remove_unused_statuses.py b/nautobot_golden_config/migrations/0034_remove_unused_statuses.py new file mode 100644 index 000000000..8aeb35abd --- /dev/null +++ b/nautobot_golden_config/migrations/0034_remove_unused_statuses.py @@ -0,0 +1,60 @@ +"""Remove the retired `Approved` and `Not Approved` Status records, if unused. + +After migration 0033, no ConfigPlan should reference either status. We defensively +verify that, then unbind the records from the ConfigPlan ContentType and delete +them if no other content type references them. If either condition fails, we leave +the Status records alone and let an admin clean up manually. +""" + +from django.db import migrations + +RETIRED_STATUSES = ("Approved", "Not Approved") + + +def remove_unused_statuses(apps, schema_editor): + ConfigPlan = apps.get_model("nautobot_golden_config", "ConfigPlan") + Status = apps.get_model("extras", "Status") + ContentType = apps.get_model("contenttypes", "ContentType") + + ct_configplan = ContentType.objects.get_for_model(ConfigPlan) + + for name in RETIRED_STATUSES: + try: + status = Status.objects.get(name=name) + except Status.DoesNotExist: + continue + + if ConfigPlan.objects.filter(status=status).exists(): + # Defensive: 0033 should have nulled all references already. + continue + + status.content_types.remove(ct_configplan) + if not status.content_types.exists(): + status.delete() + + +def restore_statuses(apps, schema_editor): + """Re-create the retired Status records and re-associate them with ConfigPlan.""" + ConfigPlan = apps.get_model("nautobot_golden_config", "ConfigPlan") + Status = apps.get_model("extras", "Status") + ContentType = apps.get_model("contenttypes", "ContentType") + + ct_configplan = ContentType.objects.get_for_model(ConfigPlan) + + defaults = { + "Approved": {"description": "Config plan is approved", "color": "4caf50"}, + "Not Approved": {"description": "Config plan is not approved", "color": "f44336"}, + } + for name, fields in defaults.items(): + status, _ = Status.objects.get_or_create(name=name, defaults=fields) + status.content_types.add(ct_configplan) + + +class Migration(migrations.Migration): + dependencies = [ + ("nautobot_golden_config", "0033_configplan_force_reapproval"), + ] + + operations = [ + migrations.RunPython(remove_unused_statuses, restore_statuses), + ] diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index 472e1b206..8d6932a35 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -17,6 +17,7 @@ from nautobot.core.models.utils import serialize_object, serialize_object_v2 from nautobot.dcim.models import Device from nautobot.extras.models import ObjectChange +from nautobot.extras.models.mixins import ApprovableModelMixin from nautobot.extras.models.statuses import StatusField from netutils.config.compliance import feature_compliance from xmldiff import actions, main @@ -834,7 +835,7 @@ def __str__(self): "webhooks", "statuses", ) -class ConfigPlan(PrimaryModel): # pylint: disable=too-many-ancestors +class ConfigPlan(ApprovableModelMixin, PrimaryModel): # pylint: disable=too-many-ancestors """ConfigPlan for Golden Configuration Plan Model definition.""" plan_type = models.CharField(max_length=20, choices=ConfigPlanTypeChoice, verbose_name="Plan Type") @@ -885,3 +886,26 @@ class Meta: def __str__(self): """Return a simple string if model is called.""" return f"{self.device.name}-{self.plan_type}-{self.created}" + + def save(self, *args, **kwargs): + """Persist the plan, then trigger an approval workflow for new instances.""" + is_new = self._state.adding + super().save(*args, **kwargs) + if is_new: + self.begin_approval_workflow() + + def on_workflow_initiated(self, approval_workflow): + """Log when an approval workflow is initiated for this plan.""" + LOGGER.info("Approval workflow %s initiated for ConfigPlan %s.", approval_workflow.pk, self.pk) + + def on_workflow_approved(self, approval_workflow): + """Log when an approval workflow is approved. Deploy job consults workflow state directly.""" + LOGGER.info("Approval workflow %s approved for ConfigPlan %s.", approval_workflow.pk, self.pk) + + def on_workflow_denied(self, approval_workflow): + """Log when an approval workflow is denied. Deploy job consults workflow state directly.""" + LOGGER.info("Approval workflow %s denied for ConfigPlan %s.", approval_workflow.pk, self.pk) + + def on_workflow_canceled(self, approval_workflow): + """Log when an approval workflow is canceled. Deploy job consults workflow state directly.""" + LOGGER.info("Approval workflow %s canceled for ConfigPlan %s.", approval_workflow.pk, self.pk) diff --git a/nautobot_golden_config/nornir_plays/config_deployment.py b/nautobot_golden_config/nornir_plays/config_deployment.py index 79f66d552..f47c7766b 100644 --- a/nautobot_golden_config/nornir_plays/config_deployment.py +++ b/nautobot_golden_config/nornir_plays/config_deployment.py @@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model from django.utils.timezone import make_aware from nautobot.dcim.models import Device +from nautobot.extras.choices import ApprovalWorkflowStateChoices from nautobot.extras.models import Status from nautobot_plugin_nornir.constants import NORNIR_SETTINGS from nautobot_plugin_nornir.plugins.inventory.nautobot_orm import NautobotORMInventory @@ -19,7 +20,7 @@ from nautobot_golden_config.exceptions import ConfigPlanDeploymentFailure from nautobot_golden_config.nornir_plays.processor import ProcessGoldenConfig from nautobot_golden_config.utilities.config_postprocessing import get_config_postprocessing -from nautobot_golden_config.utilities.constant import DEFAULT_DEPLOY_STATUS, ENABLE_POSTPROCESSING +from nautobot_golden_config.utilities.constant import ENABLE_POSTPROCESSING from nautobot_golden_config.utilities.db_management import close_threaded_db_connections from nautobot_golden_config.utilities.helper import dispatch_params from nautobot_golden_config.utilities.logger import NornirLogger @@ -89,8 +90,17 @@ def config_deployment(job): logger.debug("Starting config deployment") config_plan_qs = job.data["config_plan"] - if config_plan_qs.filter(status__name=DEFAULT_DEPLOY_STATUS).exists(): - error_msg = "`E3025:` Cannot deploy configuration(s). One or more config plans are not approved." + # A plan that has any associated approval workflow whose current state is not "Approved" + # is treated as not yet approved. Plans without any associated workflow (admin disabled + # approvals by removing the ApprovalWorkflowDefinition) bypass this gate. + unapproved_qs = config_plan_qs.filter(associated_approval_workflows__isnull=False).exclude( + associated_approval_workflows__current_state=ApprovalWorkflowStateChoices.APPROVED + ) + if unapproved_qs.exists(): + error_msg = ( + "`E3025:` Cannot deploy configuration(s). One or more config plans have an " + "approval workflow that is not in the `Approved` state." + ) logger.error(error_msg) raise NornirNautobotException(error_msg) if config_plan_qs.filter(status__name="Completed").exists(): diff --git a/nautobot_golden_config/signals.py b/nautobot_golden_config/signals.py index c7ce3cce4..c104a7be8 100755 --- a/nautobot_golden_config/signals.py +++ b/nautobot_golden_config/signals.py @@ -14,20 +14,6 @@ def post_migrate_create_statuses(sender, apps=global_apps, **kwargs): # pylint: Status = apps.get_model("extras", "Status") # pylint: disable=invalid-name ContentType = apps.get_model("contenttypes", "ContentType") # pylint: disable=invalid-name for status_config in [ - { - "name": "Approved", - "defaults": { - "description": "Config plan is approved", - "color": ColorChoices.COLOR_GREEN, - }, - }, - { - "name": "Not Approved", - "defaults": { - "description": "Config plan is not approved", - "color": ColorChoices.COLOR_RED, - }, - }, { "name": "In Progress", "defaults": { diff --git a/nautobot_golden_config/tables.py b/nautobot_golden_config/tables.py index 03be49b30..822742663 100644 --- a/nautobot_golden_config/tables.py +++ b/nautobot_golden_config/tables.py @@ -524,6 +524,17 @@ class ConfigPlanTable(StatusTableMixin, BaseTable): ) config_set = TemplateColumn(template_code=CONFIG_SET_BUTTON, verbose_name="Config Set", orderable=False) + approval_state = TemplateColumn( + template_code=( + "{% with wf=record.associated_approval_workflows.first %}" + "{% if wf %}" + '{{ wf.get_current_state_display }}' + "{% else %}—{% endif %}" + "{% endwith %}" + ), + verbose_name="Approval State", + orderable=False, + ) tags = TagColumn(url_name="plugins:nautobot_golden_config:configplan_list") class Meta(BaseTable.Meta): @@ -542,6 +553,7 @@ class Meta(BaseTable.Meta): "deploy_result", "config_set", "status", + "approval_state", "tags", ) default_columns = ( @@ -556,4 +568,5 @@ class Meta(BaseTable.Meta): "deploy_result", "config_set", "status", + "approval_state", ) diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configplan_approval.html b/nautobot_golden_config/templates/nautobot_golden_config/configplan_approval.html new file mode 100644 index 000000000..d2822be3b --- /dev/null +++ b/nautobot_golden_config/templates/nautobot_golden_config/configplan_approval.html @@ -0,0 +1,30 @@ +{% with workflow=object.associated_approval_workflows.first %} + {% if workflow %} + + + + + + + + + + + + + +
State + {{ workflow.get_current_state_display }} +
Workflow Definition + {% if workflow.approval_workflow_definition %} + {{ workflow.approval_workflow_definition.name }} + {% else %} + — + {% endif %} +
Requested By{{ workflow.user_name|default:"—" }}
+ {% else %} +
+ No approval workflow is associated with this Config Plan. Deployment will proceed without approval. +
+ {% endif %} +{% endwith %} diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 3225f3270..dc1b12afd 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -11,7 +11,7 @@ from django.urls import reverse from nautobot.apps.testing import APITestCase, APIViewTestCases from nautobot.dcim.models import Device, Platform -from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status +from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery from packaging import version from rest_framework import status @@ -389,9 +389,6 @@ def setUpTestData(cls): features = [rule1.feature, rule2.feature, rule3.feature] plan_types = ["intended", "missing", "remediation"] job_result_ids = [job_result1.id, job_result2.id, job_result3.id] - not_approved_status = Status.objects.get(name="Not Approved") - approved_status = Status.objects.get(name="Approved") - for cont in range(1, 4): plan = ConfigPlan.objects.create( device=Device.objects.get(name=f"Device {cont}"), @@ -399,7 +396,6 @@ def setUpTestData(cls): config_set=f"Test Config Set {cont}", change_control_id=f"Test Change Control ID {cont}", change_control_url=f"https://{cont}.example.com/", - status=not_approved_status, plan_result_id=job_result_ids[cont - 1], ) plan.feature.add(features[cont - 1]) @@ -408,13 +404,11 @@ def setUpTestData(cls): cls.update_data = { "change_control_id": "Test Change Control ID 4", "change_control_url": "https://example.com/?" + "x" * 1000, - "status": approved_status.pk, } cls.bulk_update_data = { "change_control_id": "Test Change Control ID 5", "change_control_url": "https://example.com/?" + "x" * 1000, - "status": approved_status.pk, } # Account for test_options_returns_expected_choices behavior change for read_only choices fields diff --git a/nautobot_golden_config/tests/test_filters.py b/nautobot_golden_config/tests/test_filters.py index 95376d61a..e1009f2fa 100644 --- a/nautobot_golden_config/tests/test_filters.py +++ b/nautobot_golden_config/tests/test_filters.py @@ -413,8 +413,8 @@ def setUpTestData(cls): cls.feature2 = cls.rule2.feature cls.rule3 = create_feature_rule_cli(cls.device1, feature="Feature 3") cls.feature3 = cls.rule3.feature - cls.status1 = Status.objects.get(name="Not Approved") - cls.status2 = Status.objects.get(name="Approved") + cls.status1 = Status.objects.get(name="In Progress") + cls.status2 = Status.objects.get(name="Completed") cls.tag1, _ = Tag.objects.get_or_create(name="Tag 1") cls.tag2, _ = Tag.objects.get_or_create(name="Tag 2") cls.tag1.content_types.set([ContentType.objects.get_for_model(models.ConfigPlan)]) diff --git a/nautobot_golden_config/tests/test_models.py b/nautobot_golden_config/tests/test_models.py index e94bb3c8d..96ae40796 100644 --- a/nautobot_golden_config/tests/test_models.py +++ b/nautobot_golden_config/tests/test_models.py @@ -7,7 +7,8 @@ from django.db.models.deletion import ProtectedError from nautobot.apps.testing import TestCase from nautobot.dcim.models import Platform -from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status +from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery +from nautobot.extras.models.approvals import ApprovalWorkflowDefinition from nautobot_golden_config.choices import RemediationTypeChoice from nautobot_golden_config.models import ( @@ -437,7 +438,6 @@ def setUpTestData(cls): cls.device = create_device() cls.rule = create_feature_rule_json(cls.device) cls.feature = cls.rule.feature - cls.status = Status.objects.get(name="Not Approved") cls.job_result = create_job_result() def test_create_config_plan_intended(self): @@ -448,7 +448,6 @@ def test_create_config_plan_intended(self): config_set="test intended config", change_control_id="1234", change_control_url="https://1234.example.com/", - status=self.status, plan_result_id=self.job_result.id, ) config_plan.feature.add(self.feature) @@ -457,7 +456,7 @@ def test_create_config_plan_intended(self): self.assertEqual(config_plan.feature.first(), self.feature) self.assertEqual(config_plan.config_set, "test intended config") self.assertEqual(config_plan.change_control_id, "1234") - self.assertEqual(config_plan.status, self.status) + self.assertIsNone(config_plan.status) self.assertEqual(config_plan.plan_type, "intended") def test_create_config_plan_intended_multiple_features(self): @@ -469,7 +468,6 @@ def test_create_config_plan_intended_multiple_features(self): config_set="test intended config", change_control_id="1234", change_control_url="https://1234.example.com/", - status=self.status, plan_result_id=self.job_result.id, ) config_plan.feature.set([self.feature, rule2.feature]) @@ -479,7 +477,6 @@ def test_create_config_plan_intended_multiple_features(self): self.assertIn(rule2.feature.id, config_plan.feature.all().values_list("id", flat=True)) self.assertEqual(config_plan.config_set, "test intended config") self.assertEqual(config_plan.change_control_id, "1234") - self.assertEqual(config_plan.status, self.status) self.assertEqual(config_plan.plan_type, "intended") def test_create_config_plan_missing(self): @@ -490,7 +487,6 @@ def test_create_config_plan_missing(self): config_set="test missing config", change_control_id="2345", change_control_url="https://2345.example.com/", - status=self.status, plan_result_id=self.job_result.id, ) config_plan.feature.add(self.feature) @@ -499,7 +495,6 @@ def test_create_config_plan_missing(self): self.assertEqual(config_plan.feature.first(), self.feature) self.assertEqual(config_plan.config_set, "test missing config") self.assertEqual(config_plan.change_control_id, "2345") - self.assertEqual(config_plan.status, self.status) self.assertEqual(config_plan.plan_type, "missing") def test_create_config_plan_remediation(self): @@ -510,7 +505,6 @@ def test_create_config_plan_remediation(self): config_set="test remediation config", change_control_id="3456", change_control_url="https://3456.example.com/", - status=self.status, plan_result_id=self.job_result.id, ) config_plan.feature.add(self.feature) @@ -519,7 +513,6 @@ def test_create_config_plan_remediation(self): self.assertEqual(config_plan.feature.first(), self.feature) self.assertEqual(config_plan.config_set, "test remediation config") self.assertEqual(config_plan.change_control_id, "3456") - self.assertEqual(config_plan.status, self.status) self.assertEqual(config_plan.plan_type, "remediation") def test_create_config_plan_manual(self): @@ -534,6 +527,30 @@ def test_create_config_plan_manual(self): self.assertEqual(config_plan.config_set, "test manual config") self.assertEqual(config_plan.plan_type, "manual") + def test_create_config_plan_triggers_approval_workflow(self): + """A new ConfigPlan should attach a pending ApprovalWorkflow when a definition exists.""" + config_plan = ConfigPlan.objects.create( + device=self.device, + plan_type="manual", + config_set="approval workflow check", + plan_result_id=self.job_result.id, + ) + workflows = list(config_plan.associated_approval_workflows.all()) + self.assertEqual(len(workflows), 1) + self.assertEqual(workflows[0].current_state, "Pending") + + def test_create_config_plan_without_workflow_definition(self): + """If no ApprovalWorkflowDefinition exists, ConfigPlan creation skips workflow attachment.""" + ApprovalWorkflowDefinition.objects.filter(name="Config Plan Approval").delete() + + config_plan = ConfigPlan.objects.create( + device=self.device, + plan_type="manual", + config_set="no workflow", + plan_result_id=self.job_result.id, + ) + self.assertFalse(config_plan.associated_approval_workflows.exists()) + class RemediationSettingModelTestCase(TestCase): """Test Remediation Setting Model.""" diff --git a/nautobot_golden_config/tests/test_nornir_plays/test_config_deployment.py b/nautobot_golden_config/tests/test_nornir_plays/test_config_deployment.py index 56942f9df..3828afc92 100644 --- a/nautobot_golden_config/tests/test_nornir_plays/test_config_deployment.py +++ b/nautobot_golden_config/tests/test_nornir_plays/test_config_deployment.py @@ -3,9 +3,13 @@ from unittest.mock import MagicMock, Mock, patch from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from nautobot.apps.testing import TestCase from nautobot.dcim.models import Device +from nautobot.extras.choices import ApprovalWorkflowStateChoices from nautobot.extras.models import JobResult, Status +from nautobot.extras.models.approvals import ApprovalWorkflow, ApprovalWorkflowDefinition +from nornir_nautobot.exceptions import NornirNautobotException from nautobot_golden_config.exceptions import ConfigPlanDeploymentFailure from nautobot_golden_config.models import ConfigPlan @@ -13,6 +17,17 @@ from nautobot_golden_config.tests.conftest import create_device +def _attach_workflow(plan, state): + """Attach an ApprovalWorkflow in the given state to ``plan``.""" + workflow_def = ApprovalWorkflowDefinition.objects.filter(name="Config Plan Approval").first() + ApprovalWorkflow.objects.create( + approval_workflow_definition=workflow_def, + object_under_review_content_type=ContentType.objects.get_for_model(ConfigPlan), + object_under_review_object_id=plan.pk, + current_state=state, + ) + + class ConfigDeploymentTest(TestCase): """Unit tests for config_deployment.py.""" @@ -30,9 +45,11 @@ def setUpTestData(cls): plan_type="manual", device=cls.device, config_set="Test Config Set", - status=Status.objects.get(name="Approved"), plan_result=cls.plan_result, ) + # The save() override creates a Pending workflow because 0032 seeds a definition. + # Flip it to Approved so the deploy gate lets us through for the happy-path tests. + cls.config_plan.associated_approval_workflows.update(current_state=ApprovalWorkflowStateChoices.APPROVED) cls.user, _ = get_user_model().objects.get_or_create(username="testuser") # Create mock job @@ -92,3 +109,74 @@ def test_config_deployment_failure(self, mock_nornir): # Verify nornir was called mock_nornir.assert_called_once() mock_nr.run.assert_called_once() + + +class ConfigDeploymentApprovalGateTest(TestCase): + """Verify the deploy-time approval gate (E3025).""" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + create_device() + cls.device = Device.objects.first() + cls.plan_result = JobResult.objects.create( + name="Test Plan Result Approval", + status=Status.objects.get(name="Completed"), + ) + cls.user, _ = get_user_model().objects.get_or_create(username="testuser-approval") + + def _build_job(self, plan_qs): + job = MagicMock() + job.data = {"config_plan": plan_qs} + job.celery_kwargs = {"nautobot_job_user_id": self.user.id} + job.job_result = Mock() + job.logger.getEffectiveLevel = Mock(return_value=0) + return job + + def test_pending_workflow_blocks_deployment(self): + plan = ConfigPlan.objects.create( + plan_type="manual", + device=self.device, + config_set="Pending", + plan_result=self.plan_result, + ) + # The save() override created a Pending workflow; leave it Pending. + with self.assertRaises(NornirNautobotException) as cm: + config_deployment(self._build_job(ConfigPlan.objects.filter(pk=plan.pk))) + self.assertIn("E3025", str(cm.exception)) + + def test_denied_workflow_blocks_deployment(self): + plan = ConfigPlan.objects.create( + plan_type="manual", + device=self.device, + config_set="Denied", + plan_result=self.plan_result, + ) + plan.associated_approval_workflows.update(current_state=ApprovalWorkflowStateChoices.DENIED) + with self.assertRaises(NornirNautobotException) as cm: + config_deployment(self._build_job(ConfigPlan.objects.filter(pk=plan.pk))) + self.assertIn("E3025", str(cm.exception)) + + @patch("nautobot_golden_config.nornir_plays.config_deployment.InitNornir") + def test_no_workflow_definition_allows_deployment(self, mock_nornir): + # Simulate admin disabling approvals by removing the workflow definition. + # Plans created while disabled have no associated workflows. + ApprovalWorkflowDefinition.objects.filter(name="Config Plan Approval").delete() + + plan = ConfigPlan.objects.create( + plan_type="manual", + device=self.device, + config_set="No workflow", + plan_result=self.plan_result, + ) + self.assertFalse(plan.associated_approval_workflows.exists()) + + mock_nr = Mock() + mock_nr.with_processors.return_value = mock_nr + mock_results = Mock() + mock_results.failed = False + mock_nr.run.return_value = mock_results + mock_nornir.return_value.__enter__.return_value = mock_nr + + config_deployment(self._build_job(ConfigPlan.objects.filter(pk=plan.pk))) + mock_nr.run.assert_called_once() diff --git a/nautobot_golden_config/tests/test_utilities/test_config_plan.py b/nautobot_golden_config/tests/test_utilities/test_config_plan.py index f0e12b65d..fe22a758a 100644 --- a/nautobot_golden_config/tests/test_utilities/test_config_plan.py +++ b/nautobot_golden_config/tests/test_utilities/test_config_plan.py @@ -6,7 +6,6 @@ from nautobot_golden_config.tests.conftest import create_config_compliance, create_device, create_feature_rule_cli from nautobot_golden_config.utilities.config_plan import ( - config_plan_default_status, generate_config_set_from_compliance_feature, generate_config_set_from_manual, ) @@ -55,8 +54,3 @@ def test_generate_config_set_from_manual(self): commands = "hostname {{ obj.name }}" config_set = generate_config_set_from_manual(self.device, commands) self.assertEqual(config_set, "hostname config_plan_utility_test") - - def test_config_plan_default_status(self): - """Test config_plan_default_status.""" - status = config_plan_default_status() - self.assertEqual(status.name, "Not Approved") diff --git a/nautobot_golden_config/tests/test_views.py b/nautobot_golden_config/tests/test_views.py index 63da3abe7..1813236ba 100644 --- a/nautobot_golden_config/tests/test_views.py +++ b/nautobot_golden_config/tests/test_views.py @@ -18,7 +18,7 @@ from nautobot.core.utils import lookup from nautobot.core.views.mixins import PERMISSIONS_ACTION_MAP, NautobotViewSetMixin from nautobot.dcim.models import Device -from nautobot.extras.models import DynamicGroup, Status +from nautobot.extras.models import DynamicGroup from nautobot.users import models as users_models from packaging import version @@ -297,16 +297,12 @@ def setUpTestData(cls): job_result2 = create_job_result() job_result3 = create_job_result() - not_approved_status = Status.objects.get(name="Not Approved") - approved_status = Status.objects.get(name="Approved") - plan1 = models.ConfigPlan.objects.create( device=device1, plan_type="intended", config_set="Test Config Set 1", change_control_id="Test Change Control ID 1", change_control_url="https://1.example.com/", - status=not_approved_status, plan_result_id=job_result1.id, ) plan1.feature.add(rule1.feature) @@ -317,7 +313,6 @@ def setUpTestData(cls): config_set="Test Config Set 2", change_control_id="Test Change Control ID 2", change_control_url="https://2.example.com/", - status=not_approved_status, plan_result_id=job_result2.id, ) plan2.feature.add(rule2.feature) @@ -328,7 +323,6 @@ def setUpTestData(cls): config_set="Test Config Set 3", change_control_id="Test Change Control ID 3", change_control_url="https://3.example.com/", - status=not_approved_status, plan_result_id=job_result3.id, ) plan3.feature.set([rule3.feature, rule4.feature]) @@ -338,7 +332,6 @@ def setUpTestData(cls): cls.form_data = { "change_control_id": "Test Change Control ID 4", "change_control_url": "https://example.com/?" + "x" * 1000, - "status": approved_status.pk, } PLUGIN_CFG["postprocessing_subscribed"] = ["whatever"] diff --git a/nautobot_golden_config/utilities/config_plan.py b/nautobot_golden_config/utilities/config_plan.py index 01261976b..64e04342f 100644 --- a/nautobot_golden_config/utilities/config_plan.py +++ b/nautobot_golden_config/utilities/config_plan.py @@ -3,18 +3,11 @@ from django.template import engines from nautobot.core.utils.data import render_jinja2 from nautobot.dcim.models import Device -from nautobot.extras.models import Status from nautobot_golden_config.models import ComplianceFeature -from nautobot_golden_config.utilities.constant import DEFAULT_DEPLOY_STATUS from nautobot_golden_config.utilities.helper import get_django_env -def config_plan_default_status(): - """Return the default status for config plan.""" - return Status.objects.get(name=DEFAULT_DEPLOY_STATUS) - - def generate_config_set_from_compliance_feature(device: Device, plan_type: str, feature: ComplianceFeature): """Generate config set from config compliance. diff --git a/nautobot_golden_config/utilities/constant.py b/nautobot_golden_config/utilities/constant.py index f1f5db283..889fa80f1 100644 --- a/nautobot_golden_config/utilities/constant.py +++ b/nautobot_golden_config/utilities/constant.py @@ -12,7 +12,6 @@ ENABLE_PLAN = PLUGIN_CFG["enable_plan"] ENABLE_DEPLOY = PLUGIN_CFG["enable_deploy"] ENABLE_POSTPROCESSING = PLUGIN_CFG["enable_postprocessing"] -DEFAULT_DEPLOY_STATUS = PLUGIN_CFG["default_deploy_status"] CONFIG_FEATURES = { "intended": ENABLE_INTENDED,