nautobot 2.4.21__py3-none-any.whl → 3.0.0a3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/apps/choices.py +2 -2
- nautobot/apps/filters.py +9 -9
- nautobot/apps/forms.py +2 -0
- nautobot/apps/models.py +7 -2
- nautobot/apps/ui.py +20 -1
- nautobot/apps/utils.py +2 -3
- nautobot/apps/views.py +7 -1
- nautobot/circuits/filters.py +8 -23
- nautobot/circuits/navigation.py +3 -1
- nautobot/circuits/templates/circuits/circuit_create.html +9 -9
- nautobot/circuits/templates/circuits/circuit_terminations_swap.html +2 -2
- nautobot/circuits/templates/circuits/circuittermination_create.html +24 -33
- nautobot/circuits/templates/circuits/inc/circuit_termination.html +10 -10
- nautobot/circuits/templates/circuits/inc/circuit_termination_cable_fragment.html +13 -13
- nautobot/circuits/templates/circuits/inc/circuit_termination_header_extra_content.html +6 -6
- nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +3 -3
- nautobot/circuits/templates/circuits/inc/speed_widget.html +13 -13
- nautobot/circuits/templates/circuits/provider_create.html +9 -9
- nautobot/circuits/tests/integration/test_circuit.py +19 -19
- nautobot/circuits/tests/integration/test_circuits_bulk_operations.py +3 -0
- nautobot/circuits/tests/integration/test_relationships.py +4 -12
- nautobot/circuits/views.py +0 -2
- nautobot/cloud/filters.py +1 -13
- nautobot/cloud/navigation.py +3 -1
- nautobot/cloud/templates/cloud/cloudnetwork_update.html +9 -9
- nautobot/cloud/templates/cloud/cloudservice_update.html +6 -6
- nautobot/core/api/fields.py +30 -2
- nautobot/core/api/schema.py +1 -1
- nautobot/core/api/serializers.py +9 -2
- nautobot/core/api/urls.py +2 -0
- nautobot/core/api/views.py +58 -37
- nautobot/core/apps/__init__.py +6 -12
- nautobot/core/branching.py +83 -0
- nautobot/core/celery/__init__.py +11 -6
- nautobot/core/celery/backends.py +2 -0
- nautobot/core/celery/encoders.py +7 -0
- nautobot/core/celery/task.py +44 -0
- nautobot/core/checks.py +60 -0
- nautobot/core/cli/bootstrap_v3_to_v5.py +776 -0
- nautobot/core/constants.py +9 -0
- nautobot/core/context_processors.py +84 -0
- nautobot/core/filters.py +131 -2
- nautobot/core/forms/__init__.py +4 -2
- nautobot/core/forms/fields.py +10 -8
- nautobot/core/forms/forms.py +21 -9
- nautobot/core/forms/search.py +0 -15
- nautobot/core/forms/widgets.py +3 -2
- nautobot/core/graphql/__init__.py +8 -26
- nautobot/core/graphql/generators.py +16 -6
- nautobot/core/graphql/schema.py +1 -1
- nautobot/core/graphql/schema_init.py +1 -2
- nautobot/core/graphql/utils.py +7 -9
- nautobot/core/jobs/__init__.py +158 -0
- nautobot/core/management/commands/generate_test_data.py +28 -9
- nautobot/core/models/__init__.py +17 -2
- nautobot/core/models/fields.py +3 -2
- nautobot/core/models/generics.py +9 -1
- nautobot/core/models/name_color_content_types.py +1 -1
- nautobot/core/models/ordering.py +7 -5
- nautobot/core/models/querysets.py +77 -2
- nautobot/core/models/tree_queries.py +6 -4
- nautobot/core/settings.py +30 -16
- nautobot/core/settings.yaml +13 -7
- nautobot/core/tables.py +114 -44
- nautobot/core/templates/403.html +1 -1
- nautobot/core/templates/403_csrf_failure.html +1 -1
- nautobot/core/templates/404.html +1 -1
- nautobot/core/templates/40x.html +8 -8
- nautobot/core/templates/500.html +10 -10
- nautobot/core/templates/about.html +13 -12
- nautobot/core/templates/admin/actions.html +1 -1
- nautobot/core/templates/admin/app_index.html +3 -3
- nautobot/core/templates/admin/base.html +45 -52
- nautobot/core/templates/admin/base_site.html +0 -9
- nautobot/core/templates/admin/change_form.html +5 -5
- nautobot/core/templates/admin/change_list.html +8 -12
- nautobot/core/templates/admin/change_list_results.html +3 -3
- nautobot/core/templates/admin/config/config.html +24 -24
- nautobot/core/templates/admin/delete_confirmation.html +5 -5
- nautobot/core/templates/admin/edit_inline/stacked.html +5 -5
- nautobot/core/templates/admin/edit_inline/tabular.html +3 -3
- nautobot/core/templates/admin/includes/fieldset.html +15 -15
- nautobot/core/templates/admin/index.html +8 -8
- nautobot/core/templates/admin/submit_line.html +5 -5
- nautobot/core/templates/base_django.html +36 -32
- nautobot/core/templates/buttons/add.html +1 -1
- nautobot/core/templates/buttons/consolidated_detail_view_action_buttons.html +2 -2
- nautobot/core/templates/buttons/export.html +17 -18
- nautobot/core/templates/buttons/job_import.html +2 -2
- nautobot/core/templates/components/breadcrumbs.html +19 -17
- nautobot/core/templates/components/button/dropdown.html +7 -5
- nautobot/core/templates/components/echarts.html +2 -0
- nautobot/core/templates/components/layout/one_over_two.html +3 -3
- nautobot/core/templates/components/layout/two_over_one.html +3 -3
- nautobot/core/templates/components/panel/body_content_data_table.html +2 -2
- nautobot/core/templates/components/panel/body_content_tags.html +1 -1
- nautobot/core/templates/components/panel/body_wrapper_generic.html +4 -2
- nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
- nautobot/core/templates/components/panel/body_wrapper_key_value_table.html +5 -3
- nautobot/core/templates/components/panel/body_wrapper_table.html +4 -2
- nautobot/core/templates/components/panel/footer_contacts_table.html +4 -4
- nautobot/core/templates/components/panel/footer_content_table.html +3 -3
- nautobot/core/templates/components/panel/grouping_toggle.html +12 -11
- nautobot/core/templates/components/panel/header_extra_content_table.html +2 -11
- nautobot/core/templates/components/panel/panel.html +6 -3
- nautobot/core/templates/components/panel/stats_panel_body.html +9 -7
- nautobot/core/templates/components/tab/content_wrapper.html +29 -1
- nautobot/core/templates/components/tab/label_wrapper.html +10 -2
- nautobot/core/templates/components/tab/label_wrapper_distinct_view.html +11 -4
- nautobot/core/templates/echarts/echarts.html +20 -0
- nautobot/core/templates/exceptions/import_error.html +2 -2
- nautobot/core/templates/exceptions/permission_error.html +1 -1
- nautobot/core/templates/exceptions/programming_error.html +2 -2
- nautobot/core/templates/generic/object_bulk_add_component.html +29 -20
- nautobot/core/templates/generic/object_bulk_create.html +87 -75
- nautobot/core/templates/generic/object_bulk_destroy.html +35 -37
- nautobot/core/templates/generic/object_bulk_remove.html +30 -26
- nautobot/core/templates/generic/object_bulk_rename.html +53 -40
- nautobot/core/templates/generic/object_bulk_update.html +36 -29
- nautobot/core/templates/generic/object_create.html +40 -27
- nautobot/core/templates/generic/object_import.html +36 -24
- nautobot/core/templates/generic/object_list.html +279 -215
- nautobot/core/templates/generic/object_notes.html +21 -11
- nautobot/core/templates/generic/object_retrieve.html +161 -213
- nautobot/core/templates/graphene/graphiql.html +113 -60
- nautobot/core/templates/home.html +164 -87
- nautobot/core/templates/import_success.html +3 -2
- nautobot/core/templates/inc/ajax_loader.html +1 -1
- nautobot/core/templates/inc/computed_fields/panel_data.html +25 -13
- nautobot/core/templates/inc/created_updated.html +12 -7
- nautobot/core/templates/inc/custom_fields/panel_data.html +28 -16
- nautobot/core/templates/inc/custom_fields_panel.html +3 -3
- nautobot/core/templates/inc/dynamic_groups_panel.html +3 -3
- nautobot/core/templates/inc/extras_features_edit_form_fields.html +15 -15
- nautobot/core/templates/inc/footer.html +90 -40
- nautobot/core/templates/inc/form_static_field.html +6 -0
- nautobot/core/templates/inc/header.html +75 -0
- nautobot/core/templates/inc/header_banners.html +17 -0
- nautobot/core/templates/inc/header_messages.html +6 -0
- nautobot/core/templates/inc/image_attachments.html +9 -9
- nautobot/core/templates/inc/javascript.html +7 -24
- nautobot/core/templates/inc/media.html +4 -29
- nautobot/core/templates/inc/modal.html +2 -2
- nautobot/core/templates/inc/nav_favorites.html +27 -0
- nautobot/core/templates/inc/nav_menu.html +150 -108
- nautobot/core/templates/inc/object_details_advanced_panel.html +84 -71
- nautobot/core/templates/inc/page_title.html +23 -0
- nautobot/core/templates/inc/paginator.html +39 -28
- nautobot/core/templates/inc/relationships/panel_override.html +3 -3
- nautobot/core/templates/inc/relationships_panel.html +3 -3
- nautobot/core/templates/inc/relationships_table_rows.html +1 -1
- nautobot/core/templates/inc/search_panel.html +22 -16
- nautobot/core/templates/inc/table.html +61 -36
- nautobot/core/templates/inc/tenancy_form_panel.html +3 -3
- nautobot/core/templates/login.html +17 -59
- nautobot/core/templates/modals/modal_theme.html +12 -23
- nautobot/core/templates/nautobot_config.py.j2 +6 -5
- nautobot/core/templates/panel_table.html +8 -12
- nautobot/core/templates/redoc_ui.html +80 -0
- nautobot/core/templates/rest_framework/api.html +43 -21
- nautobot/core/templates/search.html +12 -13
- nautobot/core/templates/swagger_ui.html +19 -4
- nautobot/core/templates/system_jobs/import_objects.html +70 -58
- nautobot/core/templates/template.css +0 -6
- nautobot/core/templates/utilities/comment_form.html +34 -0
- nautobot/core/templates/utilities/confirmation_form.html +17 -9
- nautobot/core/templates/utilities/obj_table.html +19 -11
- nautobot/core/templates/utilities/render_field.html +27 -21
- nautobot/core/templates/utilities/render_jinja2.html +22 -25
- nautobot/core/templates/utilities/templatetags/advanced_filter_indicator.html +8 -0
- nautobot/core/templates/utilities/templatetags/badge.html +1 -1
- nautobot/core/templates/utilities/templatetags/dynamic_group_assignment_modal.html +2 -3
- nautobot/core/templates/utilities/templatetags/filter_form_drawer.html +482 -0
- nautobot/core/templates/utilities/templatetags/modal_form_as_dialog.html +14 -18
- nautobot/core/templates/utilities/templatetags/saved_view_modal.html +11 -11
- nautobot/core/templates/utilities/templatetags/table_config_form.html +51 -24
- nautobot/core/templates/utilities/templatetags/tag.html +1 -1
- nautobot/core/templates/utilities/templatetags/utilization_graph.html +3 -3
- nautobot/core/templates/utilities/theme_preview.html +829 -566
- nautobot/core/templates/utilities/worker_status.html +42 -41
- nautobot/core/templates/widgets/selectwithdisabled_option.html +3 -1
- nautobot/core/templates/widgets/sluginput.html +2 -2
- nautobot/core/templatetags/buttons.py +38 -40
- nautobot/core/templatetags/helpers.py +105 -28
- nautobot/core/templatetags/ui_framework.py +17 -0
- nautobot/core/testing/api.py +76 -12
- nautobot/core/testing/filters.py +11 -27
- nautobot/core/testing/integration.py +128 -10
- nautobot/core/testing/mixins.py +7 -4
- nautobot/core/testing/utils.py +28 -5
- nautobot/core/testing/views.py +125 -27
- nautobot/core/tests/integration/test_app_home.py +39 -35
- nautobot/core/tests/integration/test_app_navbar.py +60 -67
- nautobot/core/tests/integration/test_filters.py +123 -55
- nautobot/core/tests/integration/test_general_functionality.py +1 -1
- nautobot/core/tests/integration/test_home.py +10 -18
- nautobot/core/tests/integration/test_import_objects_ui.py +2 -9
- nautobot/core/tests/integration/test_navbar.py +41 -16
- nautobot/core/tests/integration/test_swagger.py +1 -7
- nautobot/core/tests/integration/test_theme.py +3 -0
- nautobot/core/tests/nautobot_config_without_example_apps.py +4 -0
- nautobot/core/tests/runner.py +6 -1
- nautobot/core/tests/test_api.py +5 -3
- nautobot/core/tests/test_branching.py +154 -0
- nautobot/core/tests/test_breadcrumbs.py +7 -8
- nautobot/core/tests/test_checks.py +28 -0
- nautobot/core/tests/test_commands.py +0 -41
- nautobot/core/tests/test_config.py +2 -1
- nautobot/core/tests/test_csv.py +4 -7
- nautobot/core/tests/test_filters.py +326 -318
- nautobot/core/tests/test_forms.py +19 -30
- nautobot/core/tests/test_graphql.py +67 -57
- nautobot/core/tests/test_models.py +1 -1
- nautobot/core/tests/test_nautobot_server.py +2 -0
- nautobot/core/tests/test_navigations.py +78 -10
- nautobot/core/tests/test_tables.py +3 -1
- nautobot/core/tests/test_templatetags_helpers.py +61 -21
- nautobot/core/tests/test_templatetags_ui_framework.py +36 -18
- nautobot/core/tests/test_ui.py +207 -2
- nautobot/core/tests/test_utils.py +147 -2
- nautobot/core/tests/test_views.py +201 -64
- nautobot/core/tests/test_views_utils.py +1 -1
- nautobot/core/ui/breadcrumbs.py +2 -12
- nautobot/core/ui/choices.py +190 -0
- nautobot/core/ui/constants.py +86 -0
- nautobot/core/ui/echarts.py +474 -0
- nautobot/core/ui/nav.py +5 -1
- nautobot/core/ui/object_detail.py +180 -16
- nautobot/core/urls.py +13 -1
- nautobot/core/utils/cache.py +71 -0
- nautobot/core/utils/data.py +8 -5
- nautobot/core/utils/filtering.py +8 -2
- nautobot/core/utils/git.py +3 -3
- nautobot/core/utils/lookup.py +87 -13
- nautobot/core/utils/migrations.py +22 -0
- nautobot/core/utils/module_loading.py +26 -0
- nautobot/core/utils/permissions.py +9 -5
- nautobot/core/views/__init__.py +114 -63
- nautobot/core/views/generic.py +34 -27
- nautobot/core/views/mixins.py +49 -27
- nautobot/core/views/renderers.py +3 -5
- nautobot/core/views/utils.py +10 -5
- nautobot/core/views/viewsets.py +2 -1
- nautobot/data_validation/__init__.py +0 -0
- nautobot/data_validation/api/__init__.py +1 -0
- nautobot/data_validation/api/serializers.py +80 -0
- nautobot/data_validation/api/urls.py +20 -0
- nautobot/data_validation/api/views.py +44 -0
- nautobot/data_validation/apps.py +18 -0
- nautobot/data_validation/custom_validators.py +330 -0
- nautobot/data_validation/filters.py +133 -0
- nautobot/data_validation/form_mixin.py +25 -0
- nautobot/data_validation/forms.py +342 -0
- nautobot/data_validation/migrations/0001_initial.py +224 -0
- nautobot/data_validation/migrations/0002_data_migration_from_app.py +324 -0
- nautobot/data_validation/migrations/__init__.py +0 -0
- nautobot/data_validation/models.py +361 -0
- nautobot/data_validation/navigation.py +74 -0
- nautobot/data_validation/signals.py +30 -0
- nautobot/data_validation/tables.py +259 -0
- nautobot/data_validation/templates/data_validation/datacompliance_retrieve.html +1 -0
- nautobot/data_validation/templates/data_validation/datacompliance_tab.html +11 -0
- nautobot/data_validation/templates/data_validation/device_constraints.html +61 -0
- nautobot/data_validation/tests/__init__.py +20 -0
- nautobot/data_validation/tests/migrations/__init__.py +0 -0
- nautobot/data_validation/tests/migrations/test_migrations.py +489 -0
- nautobot/data_validation/tests/test_api.py +238 -0
- nautobot/data_validation/tests/test_custom_validators.py +423 -0
- nautobot/data_validation/tests/test_data_compliance_rules.py +85 -0
- nautobot/data_validation/tests/test_filters.py +240 -0
- nautobot/data_validation/tests/test_form_mixin.py +115 -0
- nautobot/data_validation/tests/test_models.py +393 -0
- nautobot/data_validation/tests/test_views.py +435 -0
- nautobot/data_validation/urls.py +21 -0
- nautobot/data_validation/views.py +227 -0
- nautobot/dcim/api/serializers.py +10 -13
- nautobot/dcim/api/urls.py +2 -0
- nautobot/dcim/api/views.py +7 -0
- nautobot/dcim/apps.py +4 -0
- nautobot/dcim/choices.py +16 -0
- nautobot/dcim/custom_validators.py +84 -0
- nautobot/dcim/filter_mixins.py +353 -4
- nautobot/dcim/{filters/__init__.py → filters.py} +70 -157
- nautobot/dcim/forms.py +12 -6
- nautobot/dcim/graphql/types.py +1 -0
- nautobot/dcim/migrations/0075_add_deviceclusterassignment.py +52 -0
- nautobot/dcim/migrations/0076_device_cluster_to_clusters_data_migration.py +40 -0
- nautobot/dcim/migrations/0077_remove_device_cluster.py +14 -0
- nautobot/dcim/migrations/0078_remove_device_location_tenant_name_uniqueness.py +16 -0
- nautobot/dcim/migrations/0079_device_name_data_migration.py +59 -0
- nautobot/dcim/models/__init__.py +2 -0
- nautobot/dcim/models/device_components.py +3 -1
- nautobot/dcim/models/devices.py +115 -51
- nautobot/dcim/navigation.py +7 -3
- nautobot/dcim/querysets.py +6 -0
- nautobot/dcim/signals.py +19 -0
- nautobot/dcim/tables/devices.py +9 -5
- nautobot/dcim/tables/template_code.py +191 -102
- nautobot/dcim/templates/dcim/cable.html +1 -1
- nautobot/dcim/templates/dcim/cable_connect.html +62 -146
- nautobot/dcim/templates/dcim/cable_retrieve.html +10 -10
- nautobot/dcim/templates/dcim/cable_trace.html +15 -17
- nautobot/dcim/templates/dcim/console_port_connection_list.html +2 -2
- nautobot/dcim/templates/dcim/consoleport.html +18 -17
- nautobot/dcim/templates/dcim/consoleserverport.html +18 -17
- nautobot/dcim/templates/dcim/controller_create.html +12 -8
- nautobot/dcim/templates/dcim/controller_wirelessnetworks.html +1 -1
- nautobot/dcim/templates/dcim/controllermanageddevicegroup_create.html +6 -6
- nautobot/dcim/templates/dcim/controllermanageddevicegroup_retrieve.html +1 -1
- nautobot/dcim/templates/dcim/device/config.html +17 -19
- nautobot/dcim/templates/dcim/device/lldp_neighbors.html +4 -4
- nautobot/dcim/templates/dcim/device/status.html +20 -20
- nautobot/dcim/templates/dcim/device_component_add.html +24 -15
- nautobot/dcim/templates/dcim/device_create.html +120 -120
- nautobot/dcim/templates/dcim/device_list.html +75 -12
- nautobot/dcim/templates/dcim/devicebay.html +7 -7
- nautobot/dcim/templates/dcim/devicebay_populate.html +29 -23
- nautobot/dcim/templates/dcim/deviceredundancygroup_create.html +6 -6
- nautobot/dcim/templates/dcim/devicetype.html +1 -1
- nautobot/dcim/templates/dcim/devicetype_component_add.html +25 -19
- nautobot/dcim/templates/dcim/devicetype_list.html +4 -4
- nautobot/dcim/templates/dcim/devicetype_update.html +9 -9
- nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +3 -3
- nautobot/dcim/templates/dcim/frontport.html +21 -20
- nautobot/dcim/templates/dcim/inc/cable_form.html +7 -7
- nautobot/dcim/templates/dcim/inc/cable_termination.html +1 -1
- nautobot/dcim/templates/dcim/inc/cable_toggle_buttons.html +18 -9
- nautobot/dcim/templates/dcim/inc/detail_softwareversion_softwareimagefile_rows.html +1 -1
- nautobot/dcim/templates/dcim/inc/device_interface_filter.html +1 -1
- nautobot/dcim/templates/dcim/inc/devicetype_component_table.html +10 -10
- nautobot/dcim/templates/dcim/inc/edit_form_softwareversion_js.html +2 -2
- nautobot/dcim/templates/dcim/inc/homepage_connections.html +2 -2
- nautobot/dcim/templates/dcim/inc/moduletype_component_table.html +10 -10
- nautobot/dcim/templates/dcim/inc/rack_elevation.html +2 -2
- nautobot/dcim/templates/dcim/interface.html +42 -22
- nautobot/dcim/templates/dcim/interface_connection_list.html +2 -2
- nautobot/dcim/templates/dcim/interface_edit.html +26 -11
- nautobot/dcim/templates/dcim/interfaceredundancygroupassociation_create.html +3 -3
- nautobot/dcim/templates/dcim/inventoryitem.html +3 -3
- nautobot/dcim/templates/dcim/inventoryitem_add.html +21 -10
- nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
- nautobot/dcim/templates/dcim/inventoryitem_edit.html +6 -4
- nautobot/dcim/templates/dcim/location.html +1 -1
- nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +24 -18
- nautobot/dcim/templates/dcim/location_retrieve.html +1 -1
- nautobot/dcim/templates/dcim/location_update.html +9 -9
- nautobot/dcim/templates/dcim/locationtype.html +0 -1
- nautobot/dcim/templates/dcim/module/base.html +67 -27
- nautobot/dcim/templates/dcim/module_consoleports.html +13 -15
- nautobot/dcim/templates/dcim/module_consoleserverports.html +13 -15
- nautobot/dcim/templates/dcim/module_frontports.html +13 -15
- nautobot/dcim/templates/dcim/module_interfaces.html +14 -16
- nautobot/dcim/templates/dcim/module_list.html +59 -10
- nautobot/dcim/templates/dcim/module_modulebays.html +12 -14
- nautobot/dcim/templates/dcim/module_poweroutlets.html +13 -15
- nautobot/dcim/templates/dcim/module_powerports.html +13 -15
- nautobot/dcim/templates/dcim/module_rearports.html +13 -15
- nautobot/dcim/templates/dcim/module_retrieve.html +3 -3
- nautobot/dcim/templates/dcim/module_update.html +15 -9
- nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -93
- nautobot/dcim/templates/dcim/modulefamily_retrieve.html +7 -7
- nautobot/dcim/templates/dcim/moduletype_list.html +2 -2
- nautobot/dcim/templates/dcim/moduletype_retrieve.html +74 -35
- nautobot/dcim/templates/dcim/platform_create.html +9 -9
- nautobot/dcim/templates/dcim/power_port_connection_list.html +3 -3
- nautobot/dcim/templates/dcim/powerfeed.html +1 -1
- nautobot/dcim/templates/dcim/powerfeed_edit.html +15 -15
- nautobot/dcim/templates/dcim/poweroutlet.html +13 -13
- nautobot/dcim/templates/dcim/powerpanel.html +1 -1
- nautobot/dcim/templates/dcim/powerport.html +17 -16
- nautobot/dcim/templates/dcim/rack.html +1 -1
- nautobot/dcim/templates/dcim/rack_elevation.html +2 -2
- nautobot/dcim/templates/dcim/rack_elevation_list.html +21 -9
- nautobot/dcim/templates/dcim/rack_retrieve.html +75 -57
- nautobot/dcim/templates/dcim/rack_update.html +14 -14
- nautobot/dcim/templates/dcim/rackreservation.html +1 -1
- nautobot/dcim/templates/dcim/rackreservation_edit.html +6 -6
- nautobot/dcim/templates/dcim/rearport.html +19 -18
- nautobot/dcim/templates/dcim/trace/cable.html +1 -1
- nautobot/dcim/templates/dcim/trace/circuit.html +1 -1
- nautobot/dcim/templates/dcim/trace/device.html +1 -1
- nautobot/dcim/templates/dcim/trace/powerpanel.html +1 -1
- nautobot/dcim/templates/dcim/trace/termination.html +1 -1
- nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
- nautobot/dcim/templates/dcim/virtualchassis_add_member.html +25 -16
- nautobot/dcim/templates/dcim/virtualchassis_create.html +6 -6
- nautobot/dcim/templates/dcim/virtualchassis_edit.html +1 -1
- nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -1
- nautobot/dcim/templates/dcim/virtualchassis_update.html +36 -22
- nautobot/dcim/templates/dcim/virtualdevicecontext_update.html +9 -9
- nautobot/dcim/tests/integration/test_controller.py +6 -6
- nautobot/dcim/tests/integration/test_controller_managed_device_group.py +7 -7
- nautobot/dcim/tests/integration/test_create_device.py +9 -9
- nautobot/dcim/tests/integration/test_device_bulk_operations.py +7 -2
- nautobot/dcim/tests/integration/test_fileinputpicker.py +5 -7
- nautobot/dcim/tests/integration/test_location_bulk_operations.py +2 -0
- nautobot/dcim/tests/integration/test_module_bay_position.py +4 -1
- nautobot/dcim/tests/test_api.py +86 -6
- nautobot/dcim/tests/test_custom_validators.py +229 -0
- nautobot/dcim/tests/test_filters.py +159 -110
- nautobot/dcim/tests/test_graphql.py +32 -36
- nautobot/dcim/tests/test_jobs.py +1 -1
- nautobot/dcim/tests/test_models.py +229 -1
- nautobot/dcim/tests/test_views.py +31 -20
- nautobot/dcim/utils.py +3 -3
- nautobot/dcim/views.py +77 -41
- nautobot/extras/api/serializers.py +83 -19
- nautobot/extras/api/urls.py +7 -0
- nautobot/extras/api/views.py +243 -140
- nautobot/extras/choices.py +34 -13
- nautobot/extras/constants.py +1 -1
- nautobot/extras/context_managers.py +26 -26
- nautobot/extras/datasources/git.py +22 -0
- nautobot/extras/datasources/registry.py +3 -0
- nautobot/extras/exceptions.py +5 -0
- nautobot/extras/factory.py +11 -1
- nautobot/extras/{filters/mixins.py → filter_mixins.py} +4 -3
- nautobot/extras/{filters/__init__.py → filters.py} +203 -58
- nautobot/extras/forms/base.py +2 -1
- nautobot/extras/forms/forms.py +225 -20
- nautobot/extras/forms/mixins.py +0 -41
- nautobot/extras/homepage.py +21 -2
- nautobot/extras/jobs.py +2 -8
- nautobot/extras/jobs_ui.py +2 -2
- nautobot/extras/management/__init__.py +9 -0
- nautobot/extras/managers.py +31 -22
- nautobot/extras/migrations/0126_approval_workflow_pre_check.py +58 -0
- nautobot/extras/migrations/0127_approval_workflow_models.py +266 -0
- nautobot/extras/migrations/0128_remove_job_approval_required_and_more.py +29 -0
- nautobot/extras/migrations/0129_jobresult_debug_log_count_jobresult_error_log_count_and_more.py +37 -0
- nautobot/extras/migrations/0130_jobresult_generate_log_entry_counts.py +42 -0
- nautobot/extras/models/__init__.py +14 -3
- nautobot/extras/models/approvals.py +556 -0
- nautobot/extras/models/change_logging.py +1 -0
- nautobot/extras/models/contacts.py +2 -0
- nautobot/extras/models/customfields.py +57 -22
- nautobot/extras/models/datasources.py +21 -0
- nautobot/extras/models/groups.py +2 -0
- nautobot/extras/models/jobs.py +122 -39
- nautobot/extras/models/metadata.py +2 -3
- nautobot/extras/models/mixins.py +129 -1
- nautobot/extras/models/models.py +22 -14
- nautobot/extras/models/relationships.py +47 -10
- nautobot/extras/models/secrets.py +1 -0
- nautobot/extras/models/statuses.py +0 -15
- nautobot/extras/models/tags.py +1 -1
- nautobot/extras/navigation.py +42 -15
- nautobot/extras/plugins/__init__.py +33 -55
- nautobot/extras/plugins/marketplace_manifest.yml +1 -23
- nautobot/extras/plugins/tables.py +8 -6
- nautobot/extras/plugins/urls.py +2 -21
- nautobot/extras/plugins/utils.py +1 -33
- nautobot/extras/plugins/validators.py +10 -10
- nautobot/extras/plugins/views.py +1 -5
- nautobot/extras/querysets.py +17 -21
- nautobot/extras/signals.py +23 -8
- nautobot/extras/tables.py +420 -99
- nautobot/extras/templates/extras/approval_dashboard.html +15 -0
- nautobot/extras/templates/extras/approval_workflow/approve.html +11 -0
- nautobot/extras/templates/extras/approval_workflow/comment.html +9 -0
- nautobot/extras/templates/extras/approval_workflow/deny.html +10 -0
- nautobot/extras/templates/extras/approvalworkflowdefinition_update.html +77 -0
- nautobot/extras/templates/extras/approvalworkflowstage_retrieve.html +29 -0
- nautobot/extras/templates/extras/configcontext_update.html +12 -12
- nautobot/extras/templates/extras/configcontextschema.html +1 -1
- nautobot/extras/templates/extras/configcontextschema_retrieve.html +9 -9
- nautobot/extras/templates/extras/configcontextschema_update.html +6 -6
- nautobot/extras/templates/extras/configcontextschema_validation.html +2 -2
- nautobot/extras/templates/extras/customfield_update.html +12 -12
- nautobot/extras/templates/extras/dynamicgroup.html +1 -1
- nautobot/extras/templates/extras/dynamicgroup_edit.html +1 -1
- nautobot/extras/templates/extras/dynamicgroup_retrieve.html +17 -17
- nautobot/extras/templates/extras/dynamicgroup_update.html +24 -24
- nautobot/extras/templates/extras/externalintegration_update.html +6 -6
- nautobot/extras/templates/extras/gitrepository.html +1 -1
- nautobot/extras/templates/extras/gitrepository_object_edit.html +1 -1
- nautobot/extras/templates/extras/gitrepository_result.html +1 -1
- nautobot/extras/templates/extras/gitrepository_retrieve.html +12 -12
- nautobot/extras/templates/extras/gitrepository_update.html +25 -7
- nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -1
- nautobot/extras/templates/extras/inc/approval_buttons_column.html +38 -0
- nautobot/extras/templates/extras/inc/bulk_edit_overridable_field.html +14 -13
- nautobot/extras/templates/extras/inc/configcontext_format.html +11 -4
- nautobot/extras/templates/extras/inc/graphqlquery_execute.html +7 -7
- nautobot/extras/templates/extras/inc/job_label.html +5 -5
- nautobot/extras/templates/extras/inc/job_table.html +23 -10
- nautobot/extras/templates/extras/inc/job_tiles.html +33 -21
- nautobot/extras/templates/extras/inc/jobresult.html +6 -6
- nautobot/extras/templates/extras/inc/json_format.html +11 -4
- nautobot/extras/templates/extras/inc/object_contact_header.html +6 -6
- nautobot/extras/templates/extras/inc/overridable_field.html +16 -15
- nautobot/extras/templates/extras/inc/panel_approvalworkflowstage.html +34 -0
- nautobot/extras/templates/extras/inc/panel_changelog.html +9 -9
- nautobot/extras/templates/extras/inc/panel_jobhistory.html +8 -6
- nautobot/extras/templates/extras/inc/tags_panel.html +3 -3
- nautobot/extras/templates/extras/job.html +154 -155
- nautobot/extras/templates/extras/job_approval_confirmation.html +4 -27
- nautobot/extras/templates/extras/job_bulk_edit.html +18 -1
- nautobot/extras/templates/extras/job_detail.html +1 -1
- nautobot/extras/templates/extras/job_edit.html +69 -64
- nautobot/extras/templates/extras/job_list.html +37 -60
- nautobot/extras/templates/extras/jobresult.html +1 -1
- nautobot/extras/templates/extras/jobresult_retrieve.html +17 -17
- nautobot/extras/templates/extras/marketplace.html +62 -71
- nautobot/extras/templates/extras/metadatatype_create.html +9 -9
- nautobot/extras/templates/extras/note.html +1 -1
- nautobot/extras/templates/extras/object_approvalworkflow.html +36 -0
- nautobot/extras/templates/extras/object_assign_contact_or_team.html +16 -7
- nautobot/extras/templates/extras/object_configcontext.html +20 -20
- nautobot/extras/templates/extras/object_new_contact.html +6 -6
- nautobot/extras/templates/extras/object_new_team.html +6 -6
- nautobot/extras/templates/extras/objectchange.html +1 -1
- nautobot/extras/templates/extras/objectchange_retrieve.html +37 -56
- nautobot/extras/templates/extras/plugin_detail.html +40 -41
- nautobot/extras/templates/extras/plugins_list.html +23 -38
- nautobot/extras/templates/extras/plugins_tiles.html +28 -28
- nautobot/extras/templates/extras/role_retrieve.html +112 -48
- nautobot/extras/templates/extras/scheduledjob.html +25 -28
- nautobot/extras/templates/extras/secret_create.html +11 -11
- nautobot/extras/templates/extras/secretsgroup_update.html +6 -6
- nautobot/extras/templates/extras/staticgroupassociation_retrieve.html +3 -3
- nautobot/extras/templates/extras/status.html +1 -1
- nautobot/extras/templates/extras/tag.html +1 -1
- nautobot/extras/templates/extras/tag_update.html +3 -3
- nautobot/extras/templates/extras/templatetags/log_level.html +1 -1
- nautobot/extras/templates/extras/templatetags/plugin_object_detail_tabs.html +2 -2
- nautobot/extras/templates/extras/webhook.html +12 -12
- nautobot/extras/templatetags/approvals.py +19 -0
- nautobot/extras/templatetags/custom_links.py +12 -12
- nautobot/extras/templatetags/job_buttons.py +14 -12
- nautobot/extras/test_jobs/invalid_import.py +9 -0
- nautobot/extras/test_jobs/log_counts_by_level.py +23 -0
- nautobot/extras/test_jobs/missing_import.py +11 -0
- nautobot/extras/tests/integration/test_computedfields.py +5 -8
- nautobot/extras/tests/integration/test_configcontextschema.py +43 -48
- nautobot/extras/tests/integration/test_customfields.py +33 -33
- nautobot/extras/tests/integration/test_dynamicgroups.py +5 -10
- nautobot/extras/tests/integration/test_jobs.py +2 -4
- nautobot/extras/tests/integration/test_notes.py +3 -9
- nautobot/extras/tests/integration/test_plugin_banner.py +3 -0
- nautobot/extras/tests/integration/test_plugins.py +35 -27
- nautobot/extras/tests/integration/test_relationships.py +7 -11
- nautobot/extras/tests/integration/test_tagfilter.py +3 -11
- nautobot/extras/tests/test_api.py +786 -242
- nautobot/extras/tests/test_approvals.py +715 -0
- nautobot/extras/tests/test_changelog.py +18 -14
- nautobot/extras/tests/test_customfields.py +14 -13
- nautobot/extras/tests/test_datasources.py +1 -1
- nautobot/extras/tests/test_dynamicgroups.py +9 -4
- nautobot/extras/tests/test_filters.py +443 -13
- nautobot/extras/tests/test_forms.py +18 -57
- nautobot/extras/tests/test_jobs.py +25 -4
- nautobot/extras/tests/test_migrations.py +81 -1
- nautobot/extras/tests/test_models.py +378 -47
- nautobot/extras/tests/test_plugins.py +47 -13
- nautobot/extras/tests/test_relationships.py +7 -2
- nautobot/extras/tests/test_utils.py +2 -0
- nautobot/extras/tests/test_views.py +780 -493
- nautobot/extras/urls.py +36 -12
- nautobot/extras/utils.py +58 -12
- nautobot/extras/views.py +668 -209
- nautobot/ipam/factory.py +7 -0
- nautobot/ipam/filter_mixins.py +38 -0
- nautobot/ipam/filters.py +35 -71
- nautobot/ipam/formfields.py +1 -1
- nautobot/ipam/forms.py +6 -3
- nautobot/ipam/migrations/0030_ipam__namespaces.py +13 -0
- nautobot/ipam/migrations/0031_ipam___data_migrations.py +4 -1
- nautobot/ipam/migrations/0054_namespace_tenant.py +25 -0
- nautobot/ipam/models.py +29 -2
- nautobot/ipam/navigation.py +3 -1
- nautobot/ipam/querysets.py +1 -2
- nautobot/ipam/tables.py +26 -17
- nautobot/ipam/templates/ipam/inc/ipadress_edit_header.html +6 -6
- nautobot/ipam/templates/ipam/inc/service.html +8 -8
- nautobot/ipam/templates/ipam/inc/toggle_available.html +10 -10
- nautobot/ipam/templates/ipam/inc/vlangroup_header.html +3 -2
- nautobot/ipam/templates/ipam/ipaddress.html +27 -13
- nautobot/ipam/templates/ipam/ipaddress_assign.html +31 -24
- nautobot/ipam/templates/ipam/ipaddress_bulk_add.html +3 -3
- nautobot/ipam/templates/ipam/ipaddress_edit.html +9 -9
- nautobot/ipam/templates/ipam/ipaddress_interfaces.html +7 -9
- nautobot/ipam/templates/ipam/ipaddress_merge.html +195 -186
- nautobot/ipam/templates/ipam/ipaddress_vm_interfaces.html +7 -9
- nautobot/ipam/templates/ipam/ipaddresstointerface_retrieve.html +7 -5
- nautobot/ipam/templates/ipam/namespace_ip_addresses.html +1 -1
- nautobot/ipam/templates/ipam/namespace_prefixes.html +1 -1
- nautobot/ipam/templates/ipam/namespace_update.html +15 -0
- nautobot/ipam/templates/ipam/namespace_vrfs.html +1 -1
- nautobot/ipam/templates/ipam/prefix_create.html +9 -9
- nautobot/ipam/templates/ipam/prefix_list.html +15 -14
- nautobot/ipam/templates/ipam/prefix_retrieve.html +0 -1
- nautobot/ipam/templates/ipam/vlan.html +1 -1
- nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
- nautobot/ipam/templates/ipam/vlan_update.html +6 -6
- nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
- nautobot/ipam/templates/ipam/vrf_edit.html +15 -15
- nautobot/ipam/tests/integration/test_prefixes.py +5 -13
- nautobot/ipam/tests/migration/test_migrations.py +89 -0
- nautobot/ipam/tests/test_api.py +20 -7
- nautobot/ipam/tests/test_filters.py +10 -0
- nautobot/ipam/tests/test_forms.py +1 -1
- nautobot/ipam/tests/test_models.py +1 -1
- nautobot/ipam/tests/test_tables.py +1 -2
- nautobot/ipam/tests/test_utils.py +1 -1
- nautobot/ipam/tests/test_views.py +24 -21
- nautobot/ipam/ui.py +0 -17
- nautobot/ipam/utils/migrations.py +16 -2
- nautobot/ipam/utils/testing.py +9 -3
- nautobot/ipam/views.py +49 -7
- nautobot/project-static/dist/css/graphql-libraries.css +655 -0
- nautobot/project-static/dist/css/graphql-libraries.css.map +1 -0
- nautobot/project-static/dist/css/materialdesignicons.css +3 -0
- nautobot/project-static/dist/css/materialdesignicons.css.map +1 -0
- nautobot/project-static/dist/css/nautobot.css +13 -0
- nautobot/project-static/dist/css/nautobot.css.map +1 -0
- nautobot/project-static/dist/js/graphql-libraries.js +3 -0
- nautobot/project-static/dist/js/graphql-libraries.js.LICENSE.txt +62 -0
- nautobot/project-static/dist/js/graphql-libraries.js.map +1 -0
- nautobot/project-static/dist/js/libraries.js +3 -0
- nautobot/project-static/dist/js/libraries.js.LICENSE.txt +65 -0
- nautobot/project-static/dist/js/libraries.js.map +1 -0
- nautobot/project-static/dist/js/materialdesignicons.js +0 -0
- nautobot/project-static/dist/js/nautobot-graphiql.js +2 -0
- nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -0
- nautobot/project-static/dist/js/nautobot.js +2 -0
- nautobot/project-static/dist/js/nautobot.js.map +1 -0
- nautobot/project-static/fonts/Montserrat-v30-Bold.woff2 +0 -0
- nautobot/project-static/fonts/Montserrat-v30-Light.woff2 +0 -0
- nautobot/project-static/fonts/Montserrat-v30-Regular.woff2 +0 -0
- nautobot/project-static/fonts/Roboto-v48-Bold.woff2 +0 -0
- nautobot/project-static/fonts/Roboto-v48-Light.woff2 +0 -0
- nautobot/project-static/fonts/Roboto-v48-Regular.woff2 +0 -0
- nautobot/project-static/img/jinja_logo.svg +21 -92
- nautobot/project-static/js/cabletrace.js +1 -1
- nautobot/project-static/js/editor.js +4 -4
- nautobot/project-static/js/forms.js +67 -717
- nautobot/project-static/js/job_result.js +2 -2
- nautobot/project-static/nautobot-icons/360-degrees.svg +3 -0
- nautobot/project-static/nautobot-icons/arrow-decision.svg +3 -0
- nautobot/project-static/nautobot-icons/arrows-expand-rec.svg +3 -0
- nautobot/project-static/nautobot-icons/arrows-move-2-rec.svg +3 -0
- nautobot/project-static/nautobot-icons/arrows-move-rec.svg +3 -0
- nautobot/project-static/nautobot-icons/atom.svg +3 -0
- nautobot/project-static/nautobot-icons/battery-3.svg +3 -0
- nautobot/project-static/nautobot-icons/branch.svg +3 -0
- nautobot/project-static/nautobot-icons/briefcase-2.svg +3 -0
- nautobot/project-static/nautobot-icons/cable-data-2.svg +3 -0
- nautobot/project-static/nautobot-icons/cable-data.svg +3 -0
- nautobot/project-static/nautobot-icons/cast.svg +3 -0
- nautobot/project-static/nautobot-icons/check-circle.svg +3 -0
- nautobot/project-static/nautobot-icons/checkbox-circle.svg +3 -0
- nautobot/project-static/nautobot-icons/checkbox-rec.svg +3 -0
- nautobot/project-static/nautobot-icons/cloud-check.svg +3 -0
- nautobot/project-static/nautobot-icons/cloud-lightning.svg +3 -0
- nautobot/project-static/nautobot-icons/cloud-upload.svg +3 -0
- nautobot/project-static/nautobot-icons/cloud.svg +3 -0
- nautobot/project-static/nautobot-icons/compass.svg +3 -0
- nautobot/project-static/nautobot-icons/control-panel.svg +3 -0
- nautobot/project-static/nautobot-icons/credit-card.svg +3 -0
- nautobot/project-static/nautobot-icons/device-lifecycle.svg +3 -0
- nautobot/project-static/nautobot-icons/direction.svg +3 -0
- nautobot/project-static/nautobot-icons/elements.svg +3 -0
- nautobot/project-static/nautobot-icons/extensibility.svg +3 -0
- nautobot/project-static/nautobot-icons/globe-2.svg +3 -0
- nautobot/project-static/nautobot-icons/globe.svg +3 -0
- nautobot/project-static/nautobot-icons/hammer.svg +3 -0
- nautobot/project-static/nautobot-icons/history.svg +3 -0
- nautobot/project-static/nautobot-icons/ip.svg +3 -0
- nautobot/project-static/nautobot-icons/laptop.svg +3 -0
- nautobot/project-static/nautobot-icons/lightning.svg +3 -0
- nautobot/project-static/nautobot-icons/list-unordered.svg +3 -0
- nautobot/project-static/nautobot-icons/map-view.svg +3 -0
- nautobot/project-static/nautobot-icons/organization.svg +3 -0
- nautobot/project-static/nautobot-icons/pin-2.svg +3 -0
- nautobot/project-static/nautobot-icons/pin-3.svg +3 -0
- nautobot/project-static/nautobot-icons/plug.svg +3 -0
- nautobot/project-static/nautobot-icons/refresh-cw.svg +3 -0
- nautobot/project-static/nautobot-icons/rocket-2.svg +3 -0
- nautobot/project-static/nautobot-icons/rotate-cw.svg +3 -0
- nautobot/project-static/nautobot-icons/route.svg +3 -0
- nautobot/project-static/nautobot-icons/secrets.svg +3 -0
- nautobot/project-static/nautobot-icons/security.svg +3 -0
- nautobot/project-static/nautobot-icons/server-2.svg +3 -0
- nautobot/project-static/nautobot-icons/server.svg +3 -0
- nautobot/project-static/nautobot-icons/share.svg +3 -0
- nautobot/project-static/nautobot-icons/shield-check.svg +3 -0
- nautobot/project-static/nautobot-icons/sitemap-outline.svg +3 -0
- nautobot/project-static/nautobot-icons/sliders-vert-2.svg +3 -0
- nautobot/project-static/nautobot-icons/sliders-vert.svg +3 -0
- nautobot/project-static/nautobot-icons/star-filled.svg +3 -0
- nautobot/project-static/nautobot-icons/star.svg +3 -0
- nautobot/project-static/nautobot-icons/transform.svg +3 -0
- nautobot/project-static/nautobot-icons/wifi.svg +3 -0
- nautobot/tenancy/api/serializers.py +1 -0
- nautobot/tenancy/api/views.py +2 -1
- nautobot/tenancy/{filters/__init__.py → filters.py} +2 -10
- nautobot/tenancy/navigation.py +3 -1
- nautobot/tenancy/templates/tenancy/tenant_create.html +6 -6
- nautobot/tenancy/tests/test_filters.py +0 -2
- nautobot/tenancy/views.py +2 -1
- nautobot/ui/.gitignore +137 -0
- nautobot/ui/.node-version +1 -0
- nautobot/ui/.prettierignore +3 -0
- nautobot/ui/eslint.config.js +33 -0
- nautobot/ui/package-lock.json +6594 -0
- nautobot/ui/package.json +67 -0
- nautobot/ui/prettier.config.js +9 -0
- nautobot/ui/src/js/collapse.js +69 -0
- nautobot/ui/src/js/cookie.js +31 -0
- nautobot/ui/src/js/draggable.js +101 -0
- nautobot/ui/src/js/drawer.js +106 -0
- nautobot/ui/src/js/form.js +23 -0
- nautobot/ui/src/js/history.js +51 -0
- nautobot/ui/src/js/nautobot-graphiql.js +19 -0
- nautobot/ui/src/js/nautobot.js +128 -0
- nautobot/ui/src/js/search.js +274 -0
- nautobot/ui/src/js/select2.js +318 -0
- nautobot/ui/src/js/sidenav.js +87 -0
- nautobot/ui/src/js/tabs.js +139 -0
- nautobot/ui/src/js/theme.js +104 -0
- nautobot/ui/src/js/utils.js +54 -0
- nautobot/ui/src/scss/colors.scss +58 -0
- nautobot/ui/src/scss/nautobot.scss +2471 -0
- nautobot/ui/webpack.config.js +148 -0
- nautobot/users/apps.py +3 -0
- nautobot/users/filters.py +7 -11
- nautobot/users/forms.py +10 -0
- nautobot/users/models.py +8 -0
- nautobot/users/templates/users/advanced_settings_edit.html +31 -21
- nautobot/users/templates/users/api_tokens.html +61 -51
- nautobot/users/templates/users/base.html +23 -31
- nautobot/users/templates/users/change_password.html +29 -19
- nautobot/users/templates/users/preferences.html +55 -45
- nautobot/users/templates/users/profile.html +45 -14
- nautobot/users/tests/test_api.py +4 -0
- nautobot/users/urls.py +2 -0
- nautobot/users/views.py +70 -2
- nautobot/virtualization/api/views.py +1 -1
- nautobot/virtualization/filters.py +18 -32
- nautobot/virtualization/forms.py +22 -59
- nautobot/virtualization/models.py +1 -19
- nautobot/virtualization/navigation.py +3 -1
- nautobot/virtualization/tables.py +10 -6
- nautobot/virtualization/templates/virtualization/cluster.html +13 -13
- nautobot/virtualization/templates/virtualization/cluster_edit.html +6 -6
- nautobot/virtualization/templates/virtualization/inc/virtualmachine_vminterface_filter.html +1 -1
- nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
- nautobot/virtualization/templates/virtualization/virtualmachine_component_add.html +24 -16
- nautobot/virtualization/templates/virtualization/virtualmachine_edit.html +1 -1
- nautobot/virtualization/templates/virtualization/virtualmachine_list.html +4 -4
- nautobot/virtualization/templates/virtualization/virtualmachine_update.html +27 -25
- nautobot/virtualization/templates/virtualization/vminterface.html +5 -5
- nautobot/virtualization/templates/virtualization/vminterface_edit.html +27 -11
- nautobot/virtualization/tests/test_api.py +3 -0
- nautobot/virtualization/tests/test_models.py +20 -5
- nautobot/virtualization/tests/test_views.py +3 -5
- nautobot/virtualization/urls.py +0 -11
- nautobot/virtualization/views.py +5 -122
- nautobot/vpn/__init__.py +0 -0
- nautobot/vpn/api/serializers.py +113 -0
- nautobot/vpn/api/urls.py +19 -0
- nautobot/vpn/api/views.py +70 -0
- nautobot/vpn/apps.py +8 -0
- nautobot/vpn/choices.py +171 -0
- nautobot/vpn/factory.py +209 -0
- nautobot/vpn/filters.py +233 -0
- nautobot/vpn/forms.py +486 -0
- nautobot/vpn/homepage.py +19 -0
- nautobot/vpn/migrations/0001_initial.py +541 -0
- nautobot/vpn/migrations/0002_populate_defaults.py +199 -0
- nautobot/vpn/migrations/__init__.py +0 -0
- nautobot/vpn/models.py +527 -0
- nautobot/vpn/navigation.py +98 -0
- nautobot/vpn/tables.py +380 -0
- nautobot/vpn/templates/vpn/vpnprofile.html +2 -0
- nautobot/vpn/templates/vpn/vpnprofile_create.html +150 -0
- nautobot/vpn/tests/__init__.py +0 -0
- nautobot/vpn/tests/test_api.py +341 -0
- nautobot/vpn/tests/test_filters.py +139 -0
- nautobot/vpn/tests/test_forms.py +294 -0
- nautobot/vpn/tests/test_models.py +97 -0
- nautobot/vpn/tests/test_views.py +281 -0
- nautobot/vpn/urls.py +16 -0
- nautobot/vpn/views.py +437 -0
- nautobot/wireless/filters.py +0 -8
- nautobot/wireless/navigation.py +3 -1
- nautobot/wireless/templates/wireless/wirelessnetwork_create.html +6 -6
- nautobot/wireless/tests/integration/test_radio_profile.py +3 -7
- nautobot/wireless/tests/test_api.py +1 -1
- {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/METADATA +5 -4
- {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/RECORD +802 -707
- {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/entry_points.txt +1 -0
- nautobot/core/management/commands/check_job_approval_status.py +0 -47
- nautobot/core/templates/search_form.html +0 -9
- nautobot/core/templates/utilities/templatetags/filter_form_modal.html +0 -87
- nautobot/dcim/filters/mixins.py +0 -354
- nautobot/extras/templates/extras/job_approval_request.html +0 -134
- nautobot/extras/templates/extras/scheduled_jobs_approval_queue_list.html +0 -28
- nautobot/ipam/mixins.py +0 -32
- nautobot/ipam/templates/ipam/inc/prefix_header_extra_content_table.html +0 -4
- nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css +0 -587
- nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css.map +0 -1
- nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css +0 -6
- nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css.map +0 -1
- nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.css +0 -6865
- nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.css.map +0 -1
- nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css +0 -6
- nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css.map +0 -1
- nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.eot +0 -0
- nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.svg +0 -288
- nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.ttf +0 -0
- nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.woff +0 -0
- nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.woff2 +0 -0
- nautobot/project-static/bootstrap-3.4.1-dist/js/bootstrap.js +0 -2580
- nautobot/project-static/bootstrap-3.4.1-dist/js/bootstrap.min.js +0 -6
- nautobot/project-static/bootstrap-3.4.1-dist/js/npm.js +0 -13
- nautobot/project-static/clipboard.js-2.0.9/clipboard.min.js +0 -7
- nautobot/project-static/css/base.css +0 -1040
- nautobot/project-static/css/dark.css +0 -282
- nautobot/project-static/flatpickr-4.6.9/flatpickr.min.js +0 -2
- nautobot/project-static/flatpickr-4.6.9/themes/light.min.css +0 -1
- nautobot/project-static/graphiql-1.5.16/graphiql.min.css +0 -12
- nautobot/project-static/graphiql-1.5.16/graphiql.min.js +0 -11
- nautobot/project-static/highlight.js-11.9.0/github-dark.min.css +0 -10
- nautobot/project-static/highlight.js-11.9.0/github.min.css +0 -10
- nautobot/project-static/highlight.js-11.9.0/highlight.min.js +0 -378
- nautobot/project-static/jquery/jquery-3.7.1.min.js +0 -2
- nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_444444_256x240.png +0 -0
- nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_555555_256x240.png +0 -0
- nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_777620_256x240.png +0 -0
- nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_777777_256x240.png +0 -0
- nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_cc0000_256x240.png +0 -0
- nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_ffffff_256x240.png +0 -0
- nautobot/project-static/jquery-ui-1.13.2/jquery-ui.min.css +0 -7
- nautobot/project-static/jquery-ui-1.13.2/jquery-ui.min.js +0 -6
- nautobot/project-static/jquery-ui-1.13.2/jquery-ui.structure.min.css +0 -5
- nautobot/project-static/jquery-ui-1.13.2/jquery-ui.theme.min.css +0 -5
- nautobot/project-static/js/homepage_layout.js +0 -182
- nautobot/project-static/js/nav_menu.js +0 -250
- nautobot/project-static/js/theme.js +0 -133
- nautobot/project-static/materialdesignicons-7.4.47/LICENSE +0 -20
- nautobot/project-static/materialdesignicons-7.4.47/css/materialdesignicons.min.css +0 -3
- nautobot/project-static/react-16.14.0/react.production.min.js +0 -32
- nautobot/project-static/react-dom-16.14.0/react-dom.production.min.js +0 -239
- nautobot/project-static/select2-4.0.13/i18n/af.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ar.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/az.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/bg.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/bn.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/bs.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ca.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/cs.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/da.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/de.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/dsb.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/el.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/en.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/es.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/et.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/eu.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/fa.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/fi.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/fr.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/gl.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/he.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/hi.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/hr.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/hsb.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/hu.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/hy.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/id.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/is.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/it.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ja.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ka.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/km.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ko.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/lt.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/lv.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/mk.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ms.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/nb.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ne.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/nl.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/pl.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ps.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/pt-BR.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/pt.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ro.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/ru.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/sk.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/sl.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/sq.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/sr-Cyrl.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/sr.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/sv.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/th.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/tk.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/tr.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/uk.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/vi.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/zh-CN.js +0 -3
- nautobot/project-static/select2-4.0.13/i18n/zh-TW.js +0 -3
- nautobot/project-static/select2-4.0.13/select2.min.css +0 -1
- nautobot/project-static/select2-4.0.13/select2.min.js +0 -2
- nautobot/project-static/select2-bootstrap-0.1.0-beta.10/select2-bootstrap.min.css +0 -7
- nautobot/project-static/subscriptions-transport-ws-0.9.18/client.min.js +0 -8
- nautobot/project-static/whatwg-fetch-3.6.2/fetch.umd.min.js +0 -8
- nautobot/virtualization/templates/virtualization/cluster_add_devices.html +0 -37
- /nautobot/extras/{filters/customfields.py → filter_mixins_customfields.py} +0 -0
- /nautobot/project-static/{materialdesignicons-7.4.47/fonts/materialdesignicons-webfont.ttf → dist/1fcc36272ea3e53d0031.ttf} +0 -0
- /nautobot/project-static/{materialdesignicons-7.4.47/fonts/materialdesignicons-webfont.eot → dist/2146c3c82b553977abc7.eot} +0 -0
- /nautobot/project-static/{materialdesignicons-7.4.47/fonts/materialdesignicons-webfont.woff → dist/e55a20c80650829ec5fd.woff} +0 -0
- /nautobot/project-static/{materialdesignicons-7.4.47/fonts/materialdesignicons-webfont.woff2 → dist/ec024da790d2972da002.woff2} +0 -0
- /nautobot/tenancy/{filters/mixins.py → filter_mixins.py} +0 -0
- {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/NOTICE +0 -0
- {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/WHEEL +0 -0
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
from datetime import timedelta
|
|
2
|
-
import json
|
|
3
2
|
from unittest import mock
|
|
4
3
|
import urllib.parse
|
|
5
4
|
import uuid
|
|
6
5
|
|
|
7
6
|
from django.contrib.auth import get_user_model
|
|
7
|
+
from django.contrib.auth.models import Group
|
|
8
8
|
from django.contrib.contenttypes.models import ContentType
|
|
9
9
|
from django.core.exceptions import ValidationError
|
|
10
10
|
from django.db.models import Q
|
|
11
|
-
from django.test import override_settings
|
|
11
|
+
from django.test import override_settings, tag
|
|
12
12
|
from django.urls import reverse
|
|
13
13
|
from django.utils import timezone
|
|
14
14
|
from django.utils.html import escape, format_html
|
|
15
15
|
|
|
16
16
|
from nautobot.circuits.models import Circuit
|
|
17
|
-
from nautobot.core.celery import NautobotKombuJSONEncoder
|
|
18
17
|
from nautobot.core.choices import ColorChoices
|
|
19
18
|
from nautobot.core.models.fields import slugify_dashes_to_underscores
|
|
20
|
-
from nautobot.core.models.utils import serialize_object_v2
|
|
21
19
|
from nautobot.core.templatetags.helpers import bettertitle
|
|
22
|
-
from nautobot.core.testing import
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
from nautobot.core.testing import (
|
|
21
|
+
extract_form_failures,
|
|
22
|
+
extract_page_body,
|
|
23
|
+
ModelViewTestCase,
|
|
24
|
+
TestCase,
|
|
25
|
+
ViewTestCases,
|
|
26
|
+
)
|
|
27
|
+
from nautobot.core.testing.utils import get_deletable_objects, post_data
|
|
25
28
|
from nautobot.core.utils.permissions import get_permission_for_model
|
|
26
29
|
from nautobot.dcim.models import (
|
|
27
30
|
ConsolePort,
|
|
@@ -33,6 +36,7 @@ from nautobot.dcim.models import (
|
|
|
33
36
|
Manufacturer,
|
|
34
37
|
)
|
|
35
38
|
from nautobot.extras.choices import (
|
|
39
|
+
ApprovalWorkflowStateChoices,
|
|
36
40
|
CustomFieldTypeChoices,
|
|
37
41
|
DynamicGroupTypeChoices,
|
|
38
42
|
JobExecutionType,
|
|
@@ -46,6 +50,11 @@ from nautobot.extras.choices import (
|
|
|
46
50
|
)
|
|
47
51
|
from nautobot.extras.constants import HTTP_CONTENT_TYPE_JSON, JOB_OVERRIDABLE_FIELDS
|
|
48
52
|
from nautobot.extras.models import (
|
|
53
|
+
ApprovalWorkflow,
|
|
54
|
+
ApprovalWorkflowDefinition,
|
|
55
|
+
ApprovalWorkflowStage,
|
|
56
|
+
ApprovalWorkflowStageDefinition,
|
|
57
|
+
ApprovalWorkflowStageResponse,
|
|
49
58
|
ComputedField,
|
|
50
59
|
ConfigContext,
|
|
51
60
|
ConfigContextSchema,
|
|
@@ -88,7 +97,7 @@ from nautobot.extras.templatetags.job_buttons import NO_CONFIRM_BUTTON
|
|
|
88
97
|
from nautobot.extras.tests.constants import BIG_GRAPHQL_DEVICE_QUERY
|
|
89
98
|
from nautobot.extras.tests.test_jobs import get_job_class_and_model
|
|
90
99
|
from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
|
|
91
|
-
from nautobot.extras.utils import RoleModelsQuery, TaggableClassesQuery
|
|
100
|
+
from nautobot.extras.utils import get_pending_approval_workflow_stages, RoleModelsQuery, TaggableClassesQuery
|
|
92
101
|
from nautobot.ipam.models import IPAddress, Prefix, VLAN, VLANGroup, VRF
|
|
93
102
|
from nautobot.tenancy.models import Tenant
|
|
94
103
|
from nautobot.users.models import ObjectPermission
|
|
@@ -97,6 +106,461 @@ from nautobot.users.models import ObjectPermission
|
|
|
97
106
|
User = get_user_model()
|
|
98
107
|
|
|
99
108
|
|
|
109
|
+
class ApprovalWorkflowDefinitionViewTestCase(
|
|
110
|
+
ViewTestCases.GetObjectViewTestCase,
|
|
111
|
+
ViewTestCases.GetObjectChangelogViewTestCase,
|
|
112
|
+
ViewTestCases.GetObjectNotesViewTestCase,
|
|
113
|
+
ViewTestCases.CreateObjectViewTestCase,
|
|
114
|
+
ViewTestCases.EditObjectViewTestCase,
|
|
115
|
+
ViewTestCases.DeleteObjectViewTestCase,
|
|
116
|
+
ViewTestCases.ListObjectsViewTestCase,
|
|
117
|
+
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
|
118
|
+
# This is almost like ViewTestCases.PrimaryObjectViewTestCase, but without BulkEditObjectsViewTestCase,
|
|
119
|
+
# because ApprovalWorkflowDefinition doesn't have any fields that support bulk editing.
|
|
120
|
+
# Currently, `model_content_type` only accepts one content type: ScheduledJob.
|
|
121
|
+
):
|
|
122
|
+
"""Test the ApprovalWorkflowDefinition views."""
|
|
123
|
+
|
|
124
|
+
model = ApprovalWorkflowDefinition
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def setUpTestData(cls):
|
|
128
|
+
"""Set up test data."""
|
|
129
|
+
super().setUpTestData()
|
|
130
|
+
cls.scheduledjob_ct = ContentType.objects.get_for_model(ScheduledJob)
|
|
131
|
+
for i in range(5):
|
|
132
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
133
|
+
name=f"Test Approval Workflow {i}",
|
|
134
|
+
model_content_type=cls.scheduledjob_ct,
|
|
135
|
+
weight=i,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
cls.form_data = {
|
|
139
|
+
"name": "Test Approval Workflow Definition 5",
|
|
140
|
+
"model_content_type": cls.scheduledjob_ct.pk,
|
|
141
|
+
"model_constraints": '{"name": "Bulk Delete Objects"}',
|
|
142
|
+
"weight": 5,
|
|
143
|
+
# These are the "management_form" fields required by the dynamic CustomFieldChoice formsets.
|
|
144
|
+
"approval_workflow_stage_definitions-TOTAL_FORMS": "0", # Set to 0 so validation succeeds until we need it
|
|
145
|
+
"approval_workflow_stage_definitions-INITIAL_FORMS": "1",
|
|
146
|
+
"approval_workflow_stage_definitions-MIN_NUM_FORMS": "0",
|
|
147
|
+
"approval_workflow_stage_definitions-MAX_NUM_FORMS": "1000",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ApprovalWorkflowStageDefinitionViewTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
152
|
+
"""Test the ApprovalWorkflowStageDefinition views."""
|
|
153
|
+
|
|
154
|
+
model = ApprovalWorkflowStageDefinition
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def setUpTestData(cls):
|
|
158
|
+
"""Set up test data."""
|
|
159
|
+
super().setUpTestData()
|
|
160
|
+
cls.scheduledjob_ct = ContentType.objects.get_for_model(ScheduledJob)
|
|
161
|
+
cls.approval_workflow_definition = ApprovalWorkflowDefinition.objects.create(
|
|
162
|
+
name="Test Approval Workflow Definition 1",
|
|
163
|
+
model_content_type=cls.scheduledjob_ct,
|
|
164
|
+
weight=10,
|
|
165
|
+
)
|
|
166
|
+
cls.approver_group = Group.objects.create(name="Test Group 1")
|
|
167
|
+
cls.updated_approver_group = Group.objects.create(name="Test Group 2")
|
|
168
|
+
# Deletable objects
|
|
169
|
+
ApprovalWorkflowStageDefinition.objects.create(
|
|
170
|
+
approval_workflow_definition=cls.approval_workflow_definition,
|
|
171
|
+
sequence=100,
|
|
172
|
+
name="Test Approval Workflow 1 Stage 1 Definition",
|
|
173
|
+
min_approvers=2,
|
|
174
|
+
denial_message="Stage 1 Denial Message",
|
|
175
|
+
approver_group=cls.approver_group,
|
|
176
|
+
)
|
|
177
|
+
ApprovalWorkflowStageDefinition.objects.create(
|
|
178
|
+
approval_workflow_definition=cls.approval_workflow_definition,
|
|
179
|
+
sequence=200,
|
|
180
|
+
name="Test Approval Workflow 1 Stage 2 Definition",
|
|
181
|
+
min_approvers=3,
|
|
182
|
+
denial_message="Stage 2 Denial Message",
|
|
183
|
+
approver_group=cls.approver_group,
|
|
184
|
+
)
|
|
185
|
+
ApprovalWorkflowStageDefinition.objects.create(
|
|
186
|
+
approval_workflow_definition=cls.approval_workflow_definition,
|
|
187
|
+
sequence=300,
|
|
188
|
+
name="Test Approval Workflow 1 Stage 3 Definition",
|
|
189
|
+
min_approvers=4,
|
|
190
|
+
denial_message="Stage 3 Denial Message",
|
|
191
|
+
approver_group=cls.updated_approver_group,
|
|
192
|
+
)
|
|
193
|
+
ApprovalWorkflowStageDefinition.objects.create(
|
|
194
|
+
approval_workflow_definition=cls.approval_workflow_definition,
|
|
195
|
+
sequence=400,
|
|
196
|
+
name="Test Approval Workflow 1 Stage 4 Definition",
|
|
197
|
+
min_approvers=4,
|
|
198
|
+
denial_message="Stage 4 Denial Message",
|
|
199
|
+
approver_group=cls.updated_approver_group,
|
|
200
|
+
)
|
|
201
|
+
ApprovalWorkflowStageDefinition.objects.create(
|
|
202
|
+
approval_workflow_definition=cls.approval_workflow_definition,
|
|
203
|
+
sequence=500,
|
|
204
|
+
name="Test Approval Workflow 1 Stage 5 Definition",
|
|
205
|
+
min_approvers=4,
|
|
206
|
+
denial_message="Stage 5 Denial Message",
|
|
207
|
+
approver_group=cls.updated_approver_group,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
cls.form_data = {
|
|
211
|
+
"approval_workflow_definition": cls.approval_workflow_definition.pk,
|
|
212
|
+
"sequence": 600,
|
|
213
|
+
"name": "Approval Workflow Stage 1 Definition",
|
|
214
|
+
"min_approvers": 2,
|
|
215
|
+
"denial_message": "Stage 1 is denied",
|
|
216
|
+
"approver_group": cls.approver_group.pk,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
cls.update_data = {
|
|
220
|
+
"approval_workflow_definition": cls.approval_workflow_definition.pk,
|
|
221
|
+
"sequence": 700,
|
|
222
|
+
"name": "Updated approval workflow stage 1",
|
|
223
|
+
"min_approvers": 3,
|
|
224
|
+
"denial_message": "updated message",
|
|
225
|
+
"approver_group": cls.updated_approver_group.pk,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
cls.bulk_edit_data = {
|
|
229
|
+
"sequence": 800,
|
|
230
|
+
"min_approvers": 5,
|
|
231
|
+
"denial_message": "updated denial message",
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ApprovalWorkflowViewTestCase(
|
|
236
|
+
ViewTestCases.GetObjectViewTestCase,
|
|
237
|
+
ViewTestCases.GetObjectChangelogViewTestCase,
|
|
238
|
+
ViewTestCases.GetObjectNotesViewTestCase,
|
|
239
|
+
ViewTestCases.DeleteObjectViewTestCase,
|
|
240
|
+
ViewTestCases.ListObjectsViewTestCase,
|
|
241
|
+
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
|
242
|
+
):
|
|
243
|
+
"""Test the ApprovalWorkflow views."""
|
|
244
|
+
|
|
245
|
+
model = ApprovalWorkflow
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def setUpTestData(cls):
|
|
249
|
+
"""Set up test data."""
|
|
250
|
+
super().setUpTestData()
|
|
251
|
+
cls.scheduledjob_ct = ContentType.objects.get_for_model(ScheduledJob)
|
|
252
|
+
job_model = Job.objects.get_for_class_path("pass_job.TestPassJob")
|
|
253
|
+
user = User.objects.first()
|
|
254
|
+
cls.scheduled_jobs = [
|
|
255
|
+
ScheduledJob.objects.create(
|
|
256
|
+
name=f"TessPassJob Scheduled Job {i}",
|
|
257
|
+
task="pass_job.TestPassJob",
|
|
258
|
+
job_model=job_model,
|
|
259
|
+
interval=JobExecutionType.TYPE_IMMEDIATELY,
|
|
260
|
+
user=user,
|
|
261
|
+
start_time=timezone.now(),
|
|
262
|
+
)
|
|
263
|
+
for i in range(7)
|
|
264
|
+
]
|
|
265
|
+
approval_workflow_definitions = [
|
|
266
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
267
|
+
name=f"Test Approval Workflow {i}", model_content_type=cls.scheduledjob_ct, weight=i
|
|
268
|
+
)
|
|
269
|
+
for i in range(5)
|
|
270
|
+
]
|
|
271
|
+
cls.approval_workflows = [
|
|
272
|
+
ApprovalWorkflow.objects.create(
|
|
273
|
+
approval_workflow_definition=approval_workflow_definitions[i],
|
|
274
|
+
object_under_review_content_type=cls.scheduledjob_ct,
|
|
275
|
+
object_under_review_object_id=cls.scheduled_jobs[i].pk,
|
|
276
|
+
current_state=ApprovalWorkflowStateChoices.PENDING,
|
|
277
|
+
)
|
|
278
|
+
for i in range(5)
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
cls.form_data = {
|
|
282
|
+
"approval_workflow_definition": approval_workflow_definitions[3].pk,
|
|
283
|
+
"object_under_review_content_type": cls.scheduledjob_ct.pk,
|
|
284
|
+
"object_under_review_object_id": cls.scheduled_jobs[5].pk,
|
|
285
|
+
"current_state": ApprovalWorkflowStateChoices.PENDING,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
cls.update_data = {
|
|
289
|
+
"approval_workflow_definition": approval_workflow_definitions[3].pk,
|
|
290
|
+
"object_under_review_content_type": cls.scheduledjob_ct.pk,
|
|
291
|
+
"object_under_review_object_id": cls.scheduled_jobs[6].pk,
|
|
292
|
+
"current_state": ApprovalWorkflowStateChoices.APPROVED,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
cls.bulk_edit_data = {
|
|
296
|
+
"current_state": ApprovalWorkflowStateChoices.DENIED,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ApprovalWorkflowStageViewTestCase(
|
|
301
|
+
ViewTestCases.GetObjectViewTestCase,
|
|
302
|
+
ViewTestCases.GetObjectChangelogViewTestCase,
|
|
303
|
+
ViewTestCases.GetObjectNotesViewTestCase,
|
|
304
|
+
ViewTestCases.DeleteObjectViewTestCase,
|
|
305
|
+
ViewTestCases.ListObjectsViewTestCase,
|
|
306
|
+
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
|
307
|
+
):
|
|
308
|
+
"""Test the ApprovalWorkflowStage views."""
|
|
309
|
+
|
|
310
|
+
model = ApprovalWorkflowStage
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def setUpTestData(cls):
|
|
314
|
+
"""Set up test data."""
|
|
315
|
+
super().setUpTestData()
|
|
316
|
+
cls.scheduledjob_ct = ContentType.objects.get_for_model(ScheduledJob)
|
|
317
|
+
job_model = Job.objects.get_for_class_path("pass_job.TestPassJob")
|
|
318
|
+
user = User.objects.first()
|
|
319
|
+
cls.scheduled_jobs = [
|
|
320
|
+
ScheduledJob.objects.create(
|
|
321
|
+
name=f"TessPassJob Scheduled Job {i}",
|
|
322
|
+
task="pass_job.TestPassJob",
|
|
323
|
+
job_model=job_model,
|
|
324
|
+
interval=JobExecutionType.TYPE_IMMEDIATELY,
|
|
325
|
+
user=user,
|
|
326
|
+
start_time=timezone.now(),
|
|
327
|
+
)
|
|
328
|
+
for i in range(6)
|
|
329
|
+
]
|
|
330
|
+
cls.approver_groups = [Group.objects.create(name=f"Test Group {i}") for i in range(3)]
|
|
331
|
+
cls.approval_workflow_definitions = [
|
|
332
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
333
|
+
name=f"Test Approval Workflow {i}",
|
|
334
|
+
model_content_type=cls.scheduledjob_ct,
|
|
335
|
+
weight=i,
|
|
336
|
+
)
|
|
337
|
+
for i in range(5)
|
|
338
|
+
]
|
|
339
|
+
cls.approval_workflow_stage_definitions = []
|
|
340
|
+
for approval_workflow_definition in cls.approval_workflow_definitions:
|
|
341
|
+
for i in range(3):
|
|
342
|
+
cls.approval_workflow_stage_definitions.append(
|
|
343
|
+
ApprovalWorkflowStageDefinition.objects.create(
|
|
344
|
+
approval_workflow_definition=approval_workflow_definition,
|
|
345
|
+
sequence=i * 100,
|
|
346
|
+
name=f"Test Approval Workflow Stage {i} Definition",
|
|
347
|
+
min_approvers=i + 1,
|
|
348
|
+
denial_message=f"Stage {i} Denial Message",
|
|
349
|
+
approver_group=cls.approver_groups[i],
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
cls.approval_workflows = [
|
|
353
|
+
ApprovalWorkflow.objects.create(
|
|
354
|
+
approval_workflow_definition=cls.approval_workflow_definitions[i],
|
|
355
|
+
object_under_review_content_type=cls.scheduledjob_ct,
|
|
356
|
+
object_under_review_object_id=cls.scheduled_jobs[i].pk,
|
|
357
|
+
current_state=ApprovalWorkflowStateChoices.PENDING,
|
|
358
|
+
)
|
|
359
|
+
for i in range(5)
|
|
360
|
+
]
|
|
361
|
+
for i, approval_workflow in enumerate(cls.approval_workflows[:2]):
|
|
362
|
+
for j in range(3):
|
|
363
|
+
ApprovalWorkflowStage.objects.create(
|
|
364
|
+
approval_workflow=approval_workflow,
|
|
365
|
+
approval_workflow_stage_definition=cls.approval_workflow_stage_definitions[i * 3 + j],
|
|
366
|
+
state=ApprovalWorkflowStateChoices.PENDING,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
cls.form_data = {
|
|
370
|
+
"approval_workflow": cls.approval_workflows[2].pk,
|
|
371
|
+
"approval_workflow_stage_definition": cls.approval_workflow_stage_definitions[6].pk,
|
|
372
|
+
"state": ApprovalWorkflowStateChoices.PENDING,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
cls.bulk_edit_data = {
|
|
376
|
+
"state": ApprovalWorkflowStateChoices.DENIED,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
380
|
+
def test_approver_dashboard(self):
|
|
381
|
+
"""Test the approval dashboard endpoint."""
|
|
382
|
+
self.client.force_login(self.user)
|
|
383
|
+
self.add_permissions("extras.view_approvalworkflowstage")
|
|
384
|
+
|
|
385
|
+
# Try GET with model-level permission
|
|
386
|
+
url = reverse("extras:approver_dashboard")
|
|
387
|
+
response = self.client.get(url)
|
|
388
|
+
self.assertHttpStatus(response, 200)
|
|
389
|
+
self.assertBodyContains(response, "My Approvals") # Assert the dashboard title is present
|
|
390
|
+
stages = get_pending_approval_workflow_stages(self.user, ApprovalWorkflowStage.objects.all())
|
|
391
|
+
for stage in stages:
|
|
392
|
+
self.assertBodyContains(response, str(stage.pk)) # Assert the stage uuid is present in the response
|
|
393
|
+
|
|
394
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
395
|
+
def test_approvee_dashboard(self):
|
|
396
|
+
"""Test the approval dashboard endpoint."""
|
|
397
|
+
self.client.force_login(self.user)
|
|
398
|
+
self.add_permissions("extras.view_approvalworkflowstage")
|
|
399
|
+
|
|
400
|
+
# Try GET with model-level permission
|
|
401
|
+
url = reverse("extras:approvee_dashboard")
|
|
402
|
+
response = self.client.get(url)
|
|
403
|
+
self.assertHttpStatus(response, 200)
|
|
404
|
+
self.assertBodyContains(response, "My Requests") # Assert the dashboard title is present
|
|
405
|
+
stages = ApprovalWorkflow.objects.filter(user=self.user)
|
|
406
|
+
for stage in stages:
|
|
407
|
+
self.assertBodyContains(response, str(stage.pk)) # Assert the stage uuid is present in the response
|
|
408
|
+
|
|
409
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
410
|
+
def test_approve_endpoint(self):
|
|
411
|
+
"""Test the approve endpoint."""
|
|
412
|
+
approval_workflow_stage = ApprovalWorkflowStage.objects.first()
|
|
413
|
+
self.client.force_login(self.user)
|
|
414
|
+
self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
|
|
415
|
+
|
|
416
|
+
# Try GET with model-level permission
|
|
417
|
+
url = reverse("extras:approvalworkflowstage_approve", args=[approval_workflow_stage.pk])
|
|
418
|
+
response = self.client.get(url)
|
|
419
|
+
self.assertHttpStatus(response, 200)
|
|
420
|
+
self.assertBodyContains(response, '<div class="card border-success">') # Assert the success panel is present
|
|
421
|
+
|
|
422
|
+
# Try POST with model-level permission
|
|
423
|
+
request = {
|
|
424
|
+
"path": url,
|
|
425
|
+
"data": post_data({"comments": "Approved!"}),
|
|
426
|
+
}
|
|
427
|
+
response = self.client.post(**request, follow=True)
|
|
428
|
+
self.assertHttpStatus(response, 200)
|
|
429
|
+
approval_workflow_stage.refresh_from_db()
|
|
430
|
+
# New response should be created
|
|
431
|
+
new_response = ApprovalWorkflowStageResponse.objects.get(
|
|
432
|
+
approval_workflow_stage=approval_workflow_stage, user=self.user
|
|
433
|
+
)
|
|
434
|
+
self.assertEqual(new_response.state, ApprovalWorkflowStateChoices.APPROVED)
|
|
435
|
+
self.assertEqual(new_response.comments, "Approved!")
|
|
436
|
+
self.assertBodyContains(
|
|
437
|
+
response, f"You approved {approval_workflow_stage}."
|
|
438
|
+
) # Assert the approval message is present
|
|
439
|
+
|
|
440
|
+
# Check approval work flow stage detail view
|
|
441
|
+
url = reverse("extras:approvalworkflowstage", args=[approval_workflow_stage.pk])
|
|
442
|
+
response = self.client.get(url)
|
|
443
|
+
self.assertHttpStatus(response, 200)
|
|
444
|
+
self.assertBodyContains(response, "Approval Date") # Assert the approval date is present
|
|
445
|
+
|
|
446
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
447
|
+
def test_deny_endpoint(self):
|
|
448
|
+
"""Test the deny endpoint."""
|
|
449
|
+
approval_workflow_stage = ApprovalWorkflowStage.objects.first()
|
|
450
|
+
self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
|
|
451
|
+
|
|
452
|
+
# Try GET with model-level permission
|
|
453
|
+
url = reverse("extras:approvalworkflowstage_deny", args=[approval_workflow_stage.pk])
|
|
454
|
+
response = self.client.get(url)
|
|
455
|
+
self.assertHttpStatus(response, 200)
|
|
456
|
+
self.assertBodyContains(response, '<div class="card border-danger">') # Assert the danger panel is present
|
|
457
|
+
|
|
458
|
+
# Try POST with model-level permission
|
|
459
|
+
request = {
|
|
460
|
+
"path": url,
|
|
461
|
+
"data": post_data({"comments": "Denied!"}),
|
|
462
|
+
}
|
|
463
|
+
response = self.client.post(**request, follow=True)
|
|
464
|
+
self.assertHttpStatus(response, 200)
|
|
465
|
+
approval_workflow_stage.refresh_from_db()
|
|
466
|
+
# New response should be created
|
|
467
|
+
new_response = ApprovalWorkflowStageResponse.objects.get(
|
|
468
|
+
approval_workflow_stage=approval_workflow_stage, user=self.user
|
|
469
|
+
)
|
|
470
|
+
self.assertEqual(new_response.state, ApprovalWorkflowStateChoices.DENIED)
|
|
471
|
+
self.assertEqual(new_response.comments, "Denied!")
|
|
472
|
+
self.assertBodyContains(
|
|
473
|
+
response, f"You denied {approval_workflow_stage}."
|
|
474
|
+
) # Assert the denial message is present
|
|
475
|
+
|
|
476
|
+
# Check approval work flow stage detail view
|
|
477
|
+
url = reverse("extras:approvalworkflowstage", args=[approval_workflow_stage.pk])
|
|
478
|
+
response = self.client.get(url)
|
|
479
|
+
self.assertHttpStatus(response, 200)
|
|
480
|
+
self.assertBodyContains(response, "Denial Date") # Assert the denial date is present
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class ApprovalWorkflowStageResponseViewTestCase(
|
|
484
|
+
ViewTestCases.DeleteObjectViewTestCase,
|
|
485
|
+
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
|
486
|
+
):
|
|
487
|
+
"""Test the ApprovalWorkflowStageResponse views."""
|
|
488
|
+
|
|
489
|
+
model = ApprovalWorkflowStageResponse
|
|
490
|
+
|
|
491
|
+
@classmethod
|
|
492
|
+
def setUpTestData(cls):
|
|
493
|
+
"""Set up test data."""
|
|
494
|
+
super().setUpTestData()
|
|
495
|
+
cls.scheduledjob_ct = ContentType.objects.get_for_model(ScheduledJob)
|
|
496
|
+
cls.approver_groups = [Group.objects.create(name=f"Test Group {i}") for i in range(3)]
|
|
497
|
+
cls.users = User.objects.all()
|
|
498
|
+
for user in cls.users:
|
|
499
|
+
for group in cls.approver_groups:
|
|
500
|
+
user.groups.add(group)
|
|
501
|
+
|
|
502
|
+
job_model = Job.objects.get_for_class_path("pass_job.TestPassJob")
|
|
503
|
+
cls.scheduled_jobs = [
|
|
504
|
+
ScheduledJob.objects.create(
|
|
505
|
+
name=f"TessPassJob Scheduled Job {i}",
|
|
506
|
+
task="pass_job.TestPassJob",
|
|
507
|
+
job_model=job_model,
|
|
508
|
+
interval=JobExecutionType.TYPE_IMMEDIATELY,
|
|
509
|
+
user=cls.users[0],
|
|
510
|
+
start_time=timezone.now(),
|
|
511
|
+
)
|
|
512
|
+
for i in range(6)
|
|
513
|
+
]
|
|
514
|
+
|
|
515
|
+
cls.approval_workflow_definitions = [
|
|
516
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
517
|
+
name=f"Test Approval Workflow {i} Definition",
|
|
518
|
+
model_content_type=cls.scheduledjob_ct,
|
|
519
|
+
weight=i,
|
|
520
|
+
)
|
|
521
|
+
for i in range(5)
|
|
522
|
+
]
|
|
523
|
+
cls.approval_workflow_stage_definitions = []
|
|
524
|
+
for approval_workflow_definition in cls.approval_workflow_definitions:
|
|
525
|
+
for i in range(3):
|
|
526
|
+
cls.approval_workflow_stage_definitions.append(
|
|
527
|
+
ApprovalWorkflowStageDefinition.objects.create(
|
|
528
|
+
approval_workflow_definition=approval_workflow_definition,
|
|
529
|
+
sequence=i * 100,
|
|
530
|
+
name=f"Test Approval Workflow Stage {i} Definition",
|
|
531
|
+
min_approvers=i + 1,
|
|
532
|
+
denial_message=f"Stage {i} Denial Message",
|
|
533
|
+
approver_group=cls.approver_groups[i],
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
cls.approval_workflows = [
|
|
537
|
+
ApprovalWorkflow.objects.create(
|
|
538
|
+
approval_workflow_definition=cls.approval_workflow_definitions[i],
|
|
539
|
+
object_under_review_content_type=cls.scheduledjob_ct,
|
|
540
|
+
object_under_review_object_id=cls.scheduled_jobs[i].pk,
|
|
541
|
+
current_state=ApprovalWorkflowStateChoices.PENDING,
|
|
542
|
+
)
|
|
543
|
+
for i in range(5)
|
|
544
|
+
]
|
|
545
|
+
cls.approval_workflow_stages = []
|
|
546
|
+
for i, approval_workflow in enumerate(cls.approval_workflows):
|
|
547
|
+
for j in range(3):
|
|
548
|
+
approval_workflow_stage = ApprovalWorkflowStage.objects.create(
|
|
549
|
+
approval_workflow=approval_workflow,
|
|
550
|
+
approval_workflow_stage_definition=cls.approval_workflow_stage_definitions[i * 3 + j],
|
|
551
|
+
state=ApprovalWorkflowStateChoices.PENDING,
|
|
552
|
+
)
|
|
553
|
+
cls.approval_workflow_stages.append(approval_workflow_stage)
|
|
554
|
+
if i < 2:
|
|
555
|
+
# Create responses for the first two approval workflow instances
|
|
556
|
+
ApprovalWorkflowStageResponse.objects.create(
|
|
557
|
+
approval_workflow_stage=approval_workflow_stage,
|
|
558
|
+
user=cls.users[i],
|
|
559
|
+
comments=f"Test comment {i * 3 + j}",
|
|
560
|
+
state=ApprovalWorkflowStateChoices.PENDING,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
100
564
|
class ComputedFieldTestCase(
|
|
101
565
|
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
|
102
566
|
ViewTestCases.CreateObjectViewTestCase,
|
|
@@ -1339,13 +1803,13 @@ class GitRepositoryTestCase(
|
|
|
1339
1803
|
model = GitRepository
|
|
1340
1804
|
slugify_function = staticmethod(slugify_dashes_to_underscores)
|
|
1341
1805
|
expected_edit_form_buttons = [
|
|
1342
|
-
'<button type="submit" name="_dryrun_update" class="btn btn-warning"
|
|
1343
|
-
'<button type="submit" name="_update" class="btn btn-primary"
|
|
1806
|
+
'<button type="submit" name="_dryrun_update" class="btn btn-warning"><span aria-hidden="true" class="mdi mdi-check me-4"></span><!---->Update & Dry Run</button>',
|
|
1807
|
+
'<button type="submit" name="_update" class="btn btn-primary"><span aria-hidden="true" class="mdi mdi-check me-4"></span><!---->Update & Sync</button>',
|
|
1344
1808
|
]
|
|
1345
1809
|
expected_create_form_buttons = [
|
|
1346
|
-
'<button type="submit" name="_dryrun_create" class="btn btn-info"
|
|
1347
|
-
'<button type="submit" name="_create" class="btn btn-primary"
|
|
1348
|
-
'<button type="submit" name="_addanother" class="btn btn-primary"
|
|
1810
|
+
'<button type="submit" name="_dryrun_create" class="btn btn-info"><span aria-hidden="true" class="mdi mdi-check me-4"></span><!---->Create & Dry Run</button>',
|
|
1811
|
+
'<button type="submit" name="_create" class="btn btn-primary"><span aria-hidden="true" class="mdi mdi-check me-4"></span><!---->Create & Sync</button>',
|
|
1812
|
+
'<button type="submit" name="_addanother" class="btn btn-primary"><span aria-hidden="true" class="mdi mdi-check me-4"></span><!---->Create and Add Another</button>',
|
|
1349
1813
|
]
|
|
1350
1814
|
|
|
1351
1815
|
@classmethod
|
|
@@ -1753,7 +2217,11 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1753
2217
|
)
|
|
1754
2218
|
response = self.client.get(reverse(view_name), follow=True)
|
|
1755
2219
|
# Assert that Location List View got redirected to Saved View set as global default
|
|
1756
|
-
self.assertBodyContains(
|
|
2220
|
+
self.assertBodyContains(
|
|
2221
|
+
response,
|
|
2222
|
+
'<span aria-hidden="true" class="mdi mdi-check"></span>Global Location Default View<span class="mdi mdi-earth ms-auto" aria-hidden="true" data-bs-toggle="tooltip" data-bs-title="Global default" data-bs-fallback-placements="["top"]"></span>',
|
|
2223
|
+
html=True,
|
|
2224
|
+
)
|
|
1757
2225
|
|
|
1758
2226
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1759
2227
|
def test_user_default(self):
|
|
@@ -1767,7 +2235,11 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1767
2235
|
UserSavedViewAssociation.objects.create(user=self.user, saved_view=sv, view_name=sv.view)
|
|
1768
2236
|
response = self.client.get(reverse(view_name), follow=True)
|
|
1769
2237
|
# Assert that Location List View got redirected to Saved View set as user default
|
|
1770
|
-
self.assertBodyContains(
|
|
2238
|
+
self.assertBodyContains(
|
|
2239
|
+
response,
|
|
2240
|
+
'<span aria-hidden="true" class="mdi mdi-check"></span>User Location Default View<span class="mdi mdi-star ms-auto" aria-hidden="true" data-bs-toggle="tooltip" data-bs-title="Your default" data-bs-fallback-placements="["top"]"></span>',
|
|
2241
|
+
html=True,
|
|
2242
|
+
)
|
|
1771
2243
|
|
|
1772
2244
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1773
2245
|
def test_user_default_precedes_global_default(self):
|
|
@@ -1786,7 +2258,11 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1786
2258
|
UserSavedViewAssociation.objects.create(user=self.user, saved_view=sv, view_name=sv.view)
|
|
1787
2259
|
response = self.client.get(reverse(view_name), follow=True)
|
|
1788
2260
|
# Assert that Location List View got redirected to Saved View set as user default
|
|
1789
|
-
self.assertBodyContains(
|
|
2261
|
+
self.assertBodyContains(
|
|
2262
|
+
response,
|
|
2263
|
+
'<span aria-hidden="true" class="mdi mdi-check"></span>User Location Default View<span class="mdi mdi-star ms-auto" aria-hidden="true" data-bs-toggle="tooltip" data-bs-title="Your default" data-bs-fallback-placements="["top"]"></span>',
|
|
2264
|
+
html=True,
|
|
2265
|
+
)
|
|
1790
2266
|
|
|
1791
2267
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1792
2268
|
def test_filtered_view_precedes_global_default(self):
|
|
@@ -1807,7 +2283,7 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1807
2283
|
# Assert that the user is not redirected to the global default view
|
|
1808
2284
|
# But instead redirected to the filtered view
|
|
1809
2285
|
self.assertNotIn(
|
|
1810
|
-
"
|
|
2286
|
+
'<span aria-hidden="true" class="mdi mdi-check"></span>Global Location Default View<span class="mdi mdi-earth ms-auto" aria-hidden="true" data-bs-toggle="tooltip" data-bs-title="Global default" data-bs-fallback-placements="["top"]"></span>',
|
|
1811
2287
|
extract_page_body(response.content.decode(response.charset)),
|
|
1812
2288
|
)
|
|
1813
2289
|
|
|
@@ -1836,7 +2312,8 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1836
2312
|
# Assert that the user is not redirected to the user default view
|
|
1837
2313
|
# But instead redirected to the filtered view
|
|
1838
2314
|
self.assertNotIn(
|
|
1839
|
-
"
|
|
2315
|
+
'<span aria-hidden="true" class="mdi mdi-check"></span>User Location Default View<span class="mdi mdi-star ms-auto" aria-hidden="true" data-bs-toggle="tooltip" data-bs-title="Your default" data-bs-fallback-placements="["top"]"></span>',
|
|
2316
|
+
extract_page_body(response.content.decode(response.charset)),
|
|
1840
2317
|
)
|
|
1841
2318
|
# Floor type locations (Floor-<number>) should not be visible in the response
|
|
1842
2319
|
self.assertNotIn(
|
|
@@ -1869,6 +2346,7 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1869
2346
|
self.assertIn(str(sv_shared.pk), response_body, msg=response_body)
|
|
1870
2347
|
self.assertNotIn(str(sv_not_shared.pk), response_body, msg=response_body)
|
|
1871
2348
|
|
|
2349
|
+
@tag("example_app")
|
|
1872
2350
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1873
2351
|
def test_create_saved_views_contain_boolean_filter_params(self):
|
|
1874
2352
|
"""
|
|
@@ -1899,7 +2377,9 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1899
2377
|
self.assertHttpStatus(response, 200)
|
|
1900
2378
|
response_body = extract_page_body(response.content.decode(response.charset))
|
|
1901
2379
|
self.assertIn(str(instance.pk), response_body, msg=response_body)
|
|
1902
|
-
self.assertBodyContains(
|
|
2380
|
+
self.assertBodyContains(
|
|
2381
|
+
response, f'<span aria-hidden="true" class="mdi mdi-check"></span>{sv_name}', html=True
|
|
2382
|
+
)
|
|
1903
2383
|
# This is the description
|
|
1904
2384
|
self.assertBodyContains(response, "I should not show in the UI!", html=True)
|
|
1905
2385
|
|
|
@@ -1929,7 +2409,9 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1929
2409
|
self.assertHttpStatus(response, 200)
|
|
1930
2410
|
response_body = extract_page_body(response.content.decode(response.charset))
|
|
1931
2411
|
self.assertIn(str(instance.pk), response_body, msg=response_body)
|
|
1932
|
-
self.assertBodyContains(
|
|
2412
|
+
self.assertBodyContains(
|
|
2413
|
+
response, f'<span aria-hidden="true" class="mdi mdi-check"></span>{sv_name}', html=True
|
|
2414
|
+
)
|
|
1933
2415
|
|
|
1934
2416
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1935
2417
|
def test_update_saved_view_contain_boolean_filter_params(self):
|
|
@@ -1956,7 +2438,11 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1956
2438
|
self.assertHttpStatus(response, 200)
|
|
1957
2439
|
response_body = extract_page_body(response.content.decode(response.charset))
|
|
1958
2440
|
self.assertNotIn("Example hidden job", response_body, msg=response_body)
|
|
1959
|
-
self.assertBodyContains(
|
|
2441
|
+
self.assertBodyContains(
|
|
2442
|
+
response,
|
|
2443
|
+
f'<span aria-hidden="true" class="mdi mdi-check"></span>{sv_name}<span class="mdi mdi-account-group ms-auto" aria-hidden="true" data-bs-toggle="tooltip" data-bs-title="Shared" data-bs-fallback-placements="["top"]"></span>',
|
|
2444
|
+
html=True,
|
|
2445
|
+
)
|
|
1960
2446
|
|
|
1961
2447
|
with self.subTest("Update device Saved View with boolean filter parameters"):
|
|
1962
2448
|
view_name = "dcim:device_list"
|
|
@@ -1980,7 +2466,11 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1980
2466
|
# Assert that Job List View rendered with the boolean filter parameter without error
|
|
1981
2467
|
self.assertHttpStatus(response, 200)
|
|
1982
2468
|
response_body = extract_page_body(response.content.decode(response.charset))
|
|
1983
|
-
self.assertBodyContains(
|
|
2469
|
+
self.assertBodyContains(
|
|
2470
|
+
response,
|
|
2471
|
+
f'<span aria-hidden="true" class="mdi mdi-check"></span>{sv_name}<span class="mdi mdi-account-group ms-auto" aria-hidden="true" data-bs-toggle="tooltip" data-bs-title="Shared" data-bs-fallback-placements="["top"]"></span>',
|
|
2472
|
+
html=True,
|
|
2473
|
+
)
|
|
1984
2474
|
|
|
1985
2475
|
|
|
1986
2476
|
# Not a full-fledged PrimaryObjectViewTestCase as there's no BulkEditView for Secrets
|
|
@@ -2282,6 +2772,42 @@ class ScheduledJobTestCase(
|
|
|
2282
2772
|
self.assertHttpStatus(response, 200)
|
|
2283
2773
|
self.assertNotIn("test4", extract_page_body(response.content.decode(response.charset)))
|
|
2284
2774
|
|
|
2775
|
+
def test_approved_required_jobs_are_listed_only_when_approved(self):
|
|
2776
|
+
self.add_permissions("extras.view_scheduledjob")
|
|
2777
|
+
|
|
2778
|
+
# this should not appear, since it's not approved
|
|
2779
|
+
ScheduledJob.objects.create(
|
|
2780
|
+
enabled=True,
|
|
2781
|
+
approval_required=True,
|
|
2782
|
+
decision_date=None,
|
|
2783
|
+
name="test4",
|
|
2784
|
+
task="pass_job.TestPassJob",
|
|
2785
|
+
interval=JobExecutionType.TYPE_IMMEDIATELY,
|
|
2786
|
+
user=self.user,
|
|
2787
|
+
start_time=timezone.now(),
|
|
2788
|
+
)
|
|
2789
|
+
ScheduledJob.objects.create(
|
|
2790
|
+
enabled=True,
|
|
2791
|
+
approval_required=False,
|
|
2792
|
+
name="test5",
|
|
2793
|
+
task="pass_job.TestPassJob",
|
|
2794
|
+
interval=JobExecutionType.TYPE_IMMEDIATELY,
|
|
2795
|
+
user=self.user,
|
|
2796
|
+
start_time=timezone.now(),
|
|
2797
|
+
)
|
|
2798
|
+
response = self.client.get(self._get_url("list"))
|
|
2799
|
+
self.assertHttpStatus(response, 200)
|
|
2800
|
+
self.assertNotIn("test4", extract_page_body(response.content.decode(response.charset)))
|
|
2801
|
+
self.assertIn("test5", extract_page_body(response.content.decode(response.charset)))
|
|
2802
|
+
|
|
2803
|
+
scheduled_job = ScheduledJob.objects.get(name="test4")
|
|
2804
|
+
scheduled_job.decision_date = timezone.now()
|
|
2805
|
+
scheduled_job.save()
|
|
2806
|
+
|
|
2807
|
+
response = self.client.get(self._get_url("list"))
|
|
2808
|
+
self.assertHttpStatus(response, 200)
|
|
2809
|
+
self.assertIn("test4", extract_page_body(response.content.decode(response.charset)))
|
|
2810
|
+
|
|
2285
2811
|
def test_non_valid_crontab_syntax(self):
|
|
2286
2812
|
self.add_permissions("extras.view_scheduledjob")
|
|
2287
2813
|
|
|
@@ -2332,442 +2858,33 @@ class ScheduledJobTestCase(
|
|
|
2332
2858
|
self.assertIn("test11", extract_page_body(response.content.decode(response.charset)))
|
|
2333
2859
|
|
|
2334
2860
|
|
|
2335
|
-
class
|
|
2336
|
-
|
|
2337
|
-
# but we can't directly use it as it uses instance.get_absolute_url() rather than self._get_url("view", instance)
|
|
2338
|
-
ViewTestCases.ListObjectsViewTestCase,
|
|
2339
|
-
):
|
|
2340
|
-
model = ScheduledJob
|
|
2341
|
-
# Many interactions with a ScheduledJob also require permissions to view the associated Job
|
|
2342
|
-
user_permissions = ("extras.view_job",)
|
|
2861
|
+
class JobQueueTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
2862
|
+
model = JobQueue
|
|
2343
2863
|
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2864
|
+
@classmethod
|
|
2865
|
+
def setUpTestData(cls):
|
|
2866
|
+
cls.form_data = {
|
|
2867
|
+
"name": "Test Job Queue",
|
|
2868
|
+
"queue_type": JobQueueTypeChoices.TYPE_CELERY,
|
|
2869
|
+
"description": "This is a very detailed description",
|
|
2870
|
+
"tenant": Tenant.objects.first().pk,
|
|
2871
|
+
"tags": [t.pk for t in Tag.objects.get_for_model(JobQueue)],
|
|
2872
|
+
}
|
|
2873
|
+
cls.bulk_edit_data = {
|
|
2874
|
+
"queue_type": JobQueueTypeChoices.TYPE_KUBERNETES,
|
|
2875
|
+
"description": "This is a very detailed new description",
|
|
2876
|
+
"tenant": Tenant.objects.last().pk,
|
|
2877
|
+
# TODO add tests for add_tags/remove_tags fields in TagsBulkEditFormMixin
|
|
2878
|
+
}
|
|
2350
2879
|
|
|
2351
|
-
def get_list_url(self):
|
|
2352
|
-
return reverse("extras:scheduledjob_approval_queue_list")
|
|
2353
2880
|
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
task="dry_run.TestDryRun",
|
|
2362
|
-
job_model=self.job_model,
|
|
2363
|
-
interval=JobExecutionType.TYPE_IMMEDIATELY,
|
|
2364
|
-
user=self.user,
|
|
2365
|
-
approval_required=True,
|
|
2366
|
-
start_time=timezone.now(),
|
|
2367
|
-
)
|
|
2368
|
-
ScheduledJob.objects.create(
|
|
2369
|
-
name="test2",
|
|
2370
|
-
task="fail.TestFailJob",
|
|
2371
|
-
job_model=self.job_model_2,
|
|
2372
|
-
interval=JobExecutionType.TYPE_IMMEDIATELY,
|
|
2373
|
-
user=self.user,
|
|
2374
|
-
approval_required=True,
|
|
2375
|
-
start_time=timezone.now(),
|
|
2376
|
-
)
|
|
2377
|
-
|
|
2378
|
-
def test_only_approvable_is_listed(self):
|
|
2379
|
-
self.add_permissions("extras.view_scheduledjob")
|
|
2380
|
-
|
|
2381
|
-
ScheduledJob.objects.create(
|
|
2382
|
-
name="test4",
|
|
2383
|
-
task="pass_job.TestPassJob",
|
|
2384
|
-
job_model=self.job_model,
|
|
2385
|
-
interval=JobExecutionType.TYPE_IMMEDIATELY,
|
|
2386
|
-
user=self.user,
|
|
2387
|
-
approval_required=False,
|
|
2388
|
-
start_time=timezone.now(),
|
|
2389
|
-
)
|
|
2390
|
-
|
|
2391
|
-
response = self.client.get(self._get_url("list"))
|
|
2392
|
-
self.assertHttpStatus(response, 200)
|
|
2393
|
-
self.assertNotIn("test4", extract_page_body(response.content.decode(response.charset)))
|
|
2394
|
-
|
|
2395
|
-
#
|
|
2396
|
-
# Reimplementations of ViewTestCases.GetObjectViewTestCase test functions.
|
|
2397
|
-
# Needed because those use instance.get_absolute_url() instead of self._get_url("view", instance)...
|
|
2398
|
-
#
|
|
2399
|
-
|
|
2400
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2401
|
-
def test_get_object_anonymous(self):
|
|
2402
|
-
self.client.logout()
|
|
2403
|
-
response = self.client.get(self._get_url("view", self._get_queryset().first()))
|
|
2404
|
-
self.assertHttpStatus(response, 200)
|
|
2405
|
-
|
|
2406
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
2407
|
-
def test_get_object_without_permission(self):
|
|
2408
|
-
instance = self._get_queryset().first()
|
|
2409
|
-
|
|
2410
|
-
with disable_warnings("django.request"):
|
|
2411
|
-
self.assertHttpStatus(self.client.get(self._get_url("view", instance)), 403)
|
|
2412
|
-
|
|
2413
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
2414
|
-
def test_get_object_with_permission(self):
|
|
2415
|
-
instance = self._get_queryset().first()
|
|
2416
|
-
|
|
2417
|
-
# Add model-level permission
|
|
2418
|
-
obj_perm = ObjectPermission(name="Test permission", actions=["view"])
|
|
2419
|
-
obj_perm.save()
|
|
2420
|
-
obj_perm.users.add(self.user)
|
|
2421
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
|
|
2422
|
-
|
|
2423
|
-
# Try GET with model-level permission
|
|
2424
|
-
response = self.client.get(self._get_url("view", instance))
|
|
2425
|
-
# The object's display name or string representation should appear in the response
|
|
2426
|
-
self.assertBodyContains(response, getattr(instance, "display", str(instance)))
|
|
2427
|
-
|
|
2428
|
-
# skip GetObjectViewTestCase checks for Relationships and Custom Fields since this isn't actually a detail view
|
|
2429
|
-
|
|
2430
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
2431
|
-
def test_get_object_with_constrained_permission(self):
|
|
2432
|
-
instance1, instance2 = self._get_queryset().all()[:2]
|
|
2433
|
-
|
|
2434
|
-
# Add object-level permission
|
|
2435
|
-
obj_perm = ObjectPermission(
|
|
2436
|
-
name="Test permission",
|
|
2437
|
-
constraints={"pk": instance1.pk},
|
|
2438
|
-
# To get a different rendering flow than the "test_get_object_with_permission" test above,
|
|
2439
|
-
# enable additional permissions for this object so that interaction buttons are rendered.
|
|
2440
|
-
actions=["view", "add", "change", "delete"],
|
|
2441
|
-
)
|
|
2442
|
-
obj_perm.save()
|
|
2443
|
-
obj_perm.users.add(self.user)
|
|
2444
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
|
|
2445
|
-
|
|
2446
|
-
# Try GET to permitted object
|
|
2447
|
-
self.assertHttpStatus(self.client.get(self._get_url("view", instance1)), 200)
|
|
2448
|
-
|
|
2449
|
-
# Try GET to non-permitted object
|
|
2450
|
-
self.assertHttpStatus(self.client.get(self._get_url("view", instance2)), 404)
|
|
2451
|
-
|
|
2452
|
-
#
|
|
2453
|
-
# Additional test cases specific to the job approval view
|
|
2454
|
-
#
|
|
2455
|
-
|
|
2456
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2457
|
-
def test_post_anonymous(self):
|
|
2458
|
-
"""Anonymous users may not take any action with regard to job approval requests."""
|
|
2459
|
-
self.client.logout()
|
|
2460
|
-
response = self.client.post(self._get_url("view", self._get_queryset().first()))
|
|
2461
|
-
self.assertBodyContains(response, "You do not have permission to run jobs")
|
|
2462
|
-
# No job was submitted
|
|
2463
|
-
self.assertFalse(JobResult.objects.filter(name=self.job_model.name).exists())
|
|
2464
|
-
|
|
2465
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
2466
|
-
def test_post_dry_run_not_runnable(self):
|
|
2467
|
-
"""A non-enabled job cannot be dry-run."""
|
|
2468
|
-
self.add_permissions("extras.view_scheduledjob")
|
|
2469
|
-
instance = self._get_queryset().first()
|
|
2470
|
-
data = {"_dry_run": True}
|
|
2471
|
-
|
|
2472
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2473
|
-
self.assertBodyContains(response, "This job cannot be run at this time")
|
|
2474
|
-
# No job was submitted
|
|
2475
|
-
self.assertFalse(JobResult.objects.filter(name=instance.job_model.name).exists())
|
|
2476
|
-
|
|
2477
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
2478
|
-
def test_post_dry_run_needs_job_run_permission(self):
|
|
2479
|
-
"""A user without run_job permission cannot dry-run a job."""
|
|
2480
|
-
self.add_permissions("extras.view_scheduledjob")
|
|
2481
|
-
instance = self._get_queryset().first()
|
|
2482
|
-
instance.job_model.enabled = True
|
|
2483
|
-
instance.job_model.save()
|
|
2484
|
-
data = {"_dry_run": True}
|
|
2485
|
-
|
|
2486
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2487
|
-
self.assertBodyContains(response, "You do not have permission to run this job")
|
|
2488
|
-
# No job was submitted
|
|
2489
|
-
self.assertFalse(JobResult.objects.filter(name=instance.job_model.name).exists())
|
|
2490
|
-
|
|
2491
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
2492
|
-
def test_post_dry_run_needs_specific_job_run_permission(self):
|
|
2493
|
-
"""A user without run_job permission FOR THAT SPECIFIC JOB cannot dry-run a job."""
|
|
2494
|
-
self.add_permissions("extras.view_scheduledjob")
|
|
2495
|
-
instance1, instance2 = self._get_queryset().all()[:2]
|
|
2496
|
-
data = {"_dry_run": True}
|
|
2497
|
-
obj_perm = ObjectPermission(name="Test permission", constraints={"pk": instance1.job_model.pk}, actions=["run"])
|
|
2498
|
-
obj_perm.save()
|
|
2499
|
-
obj_perm.users.add(self.user)
|
|
2500
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
|
|
2501
|
-
instance1.job_model.enabled = True
|
|
2502
|
-
instance1.job_model.save()
|
|
2503
|
-
instance2.job_model.enabled = True
|
|
2504
|
-
instance2.job_model.save()
|
|
2505
|
-
|
|
2506
|
-
response = self.client.post(self._get_url("view", instance2), data)
|
|
2507
|
-
self.assertBodyContains(response, "You do not have permission to run this job")
|
|
2508
|
-
# No job was submitted
|
|
2509
|
-
job_names = [instance1.job_model.name, instance2.job_model.name]
|
|
2510
|
-
self.assertFalse(JobResult.objects.filter(name__in=job_names).exists())
|
|
2511
|
-
|
|
2512
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2513
|
-
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
2514
|
-
def test_post_dry_run_not_supported(self, _):
|
|
2515
|
-
"""Request a dry run on a job that doesn't support dryrun."""
|
|
2516
|
-
self.add_permissions("extras.view_scheduledjob")
|
|
2517
|
-
instance = ScheduledJob.objects.filter(name="test2").first()
|
|
2518
|
-
instance.job_model.enabled = True
|
|
2519
|
-
instance.job_model.save()
|
|
2520
|
-
obj_perm = ObjectPermission(name="Test permission", constraints={"pk": instance.job_model.pk}, actions=["run"])
|
|
2521
|
-
obj_perm.save()
|
|
2522
|
-
obj_perm.users.add(self.user)
|
|
2523
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
|
|
2524
|
-
data = {"_dry_run": True}
|
|
2525
|
-
|
|
2526
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2527
|
-
# Job was not submitted
|
|
2528
|
-
self.assertFalse(JobResult.objects.filter(name=instance.job_model.class_path).exists())
|
|
2529
|
-
self.assertContains(response, "This job does not support dryrun")
|
|
2530
|
-
|
|
2531
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2532
|
-
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
2533
|
-
@mock.patch("nautobot.extras.models.jobs.JobResult.enqueue_job")
|
|
2534
|
-
def test_post_dry_run_success(self, mock_enqueue_job, _):
|
|
2535
|
-
"""Successfully request a dry run based on object-based run_job permissions."""
|
|
2536
|
-
self.add_permissions("extras.view_scheduledjob")
|
|
2537
|
-
instance = ScheduledJob.objects.filter(name="test1").first()
|
|
2538
|
-
instance.job_model.enabled = True
|
|
2539
|
-
instance.job_model.save()
|
|
2540
|
-
obj_perm = ObjectPermission(name="Test permission", constraints={"pk": instance.job_model.pk}, actions=["run"])
|
|
2541
|
-
obj_perm.save()
|
|
2542
|
-
obj_perm.users.add(self.user)
|
|
2543
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
|
|
2544
|
-
data = {"_dry_run": True}
|
|
2545
|
-
|
|
2546
|
-
mock_enqueue_job.side_effect = lambda job_model, *args, **kwargs: JobResult.objects.create(name=job_model.name)
|
|
2547
|
-
|
|
2548
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2549
|
-
# Job was submitted
|
|
2550
|
-
mock_enqueue_job.assert_called_once()
|
|
2551
|
-
job_result = JobResult.objects.get(name=instance.job_model.name)
|
|
2552
|
-
self.assertRedirects(response, reverse("extras:jobresult", kwargs={"pk": job_result.pk}))
|
|
2553
|
-
|
|
2554
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2555
|
-
def test_post_deny_different_user_lacking_permissions(self):
|
|
2556
|
-
"""A user needs both delete_scheduledjob and approve_job permissions to deny a job request."""
|
|
2557
|
-
user1 = User.objects.create_user(username="testuser1")
|
|
2558
|
-
user2 = User.objects.create_user(username="testuser2")
|
|
2559
|
-
|
|
2560
|
-
# Give both users view_scheduledjob permission
|
|
2561
|
-
obj_perm = ObjectPermission(name="View", actions=["view"])
|
|
2562
|
-
obj_perm.save()
|
|
2563
|
-
obj_perm.users.add(user1, user2)
|
|
2564
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(ScheduledJob))
|
|
2565
|
-
|
|
2566
|
-
# Give user1 delete_scheduledjob permission but not approve_job permission
|
|
2567
|
-
obj_perm = ObjectPermission(name="Delete", actions=["delete"])
|
|
2568
|
-
obj_perm.save()
|
|
2569
|
-
obj_perm.users.add(user1)
|
|
2570
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(ScheduledJob))
|
|
2571
|
-
|
|
2572
|
-
# Give user2 approve_job permission but not delete_scheduledjob permission
|
|
2573
|
-
obj_perm = ObjectPermission(name="Approve", actions=["approve"])
|
|
2574
|
-
obj_perm.save()
|
|
2575
|
-
obj_perm.users.add(user2)
|
|
2576
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
|
|
2577
|
-
|
|
2578
|
-
instance = self._get_queryset().first()
|
|
2579
|
-
data = {"_deny": True}
|
|
2580
|
-
|
|
2581
|
-
for user in (user1, user2):
|
|
2582
|
-
self.client.force_login(user)
|
|
2583
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2584
|
-
self.assertBodyContains(response, "You do not have permission")
|
|
2585
|
-
# Request was not deleted
|
|
2586
|
-
self.assertEqual(1, len(ScheduledJob.objects.filter(pk=instance.pk)), msg=str(user))
|
|
2587
|
-
|
|
2588
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2589
|
-
@load_event_broker_override_settings(
|
|
2590
|
-
EVENT_BROKERS={
|
|
2591
|
-
"SyslogEventBroker": {
|
|
2592
|
-
"CLASS": "nautobot.core.events.SyslogEventBroker",
|
|
2593
|
-
"TOPICS": {
|
|
2594
|
-
"INCLUDE": ["*"],
|
|
2595
|
-
},
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
)
|
|
2599
|
-
def test_post_deny_different_user_permitted(self):
|
|
2600
|
-
"""A user with appropriate permissions can deny a job request."""
|
|
2601
|
-
user = User.objects.create_user(username="testuser1")
|
|
2602
|
-
instance = self._get_queryset().first()
|
|
2603
|
-
|
|
2604
|
-
# Give user view_scheduledjob and delete_scheduledjob permissions
|
|
2605
|
-
obj_perm = ObjectPermission(name="View", actions=["view", "delete"], constraints={"pk": instance.pk})
|
|
2606
|
-
obj_perm.save()
|
|
2607
|
-
obj_perm.users.add(user)
|
|
2608
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(ScheduledJob))
|
|
2609
|
-
|
|
2610
|
-
# Give user approve_job permission
|
|
2611
|
-
obj_perm = ObjectPermission(name="Approve", actions=["approve"], constraints={"pk": instance.job_model.pk})
|
|
2612
|
-
obj_perm.save()
|
|
2613
|
-
obj_perm.users.add(user)
|
|
2614
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
|
|
2615
|
-
|
|
2616
|
-
data = {"_deny": True}
|
|
2617
|
-
|
|
2618
|
-
self.client.force_login(user)
|
|
2619
|
-
with self.assertLogs("nautobot.events") as cm:
|
|
2620
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2621
|
-
self.assertRedirects(response, reverse("extras:scheduledjob_approval_queue_list"))
|
|
2622
|
-
# Request was deleted
|
|
2623
|
-
self.assertEqual(0, len(ScheduledJob.objects.filter(pk=instance.pk)))
|
|
2624
|
-
# Event was published
|
|
2625
|
-
expected_payload = {"data": serialize_object_v2(instance)}
|
|
2626
|
-
self.assertEqual(
|
|
2627
|
-
cm.output,
|
|
2628
|
-
[
|
|
2629
|
-
f"INFO:nautobot.events.nautobot.jobs.approval.denied:{json.dumps(expected_payload, cls=NautobotKombuJSONEncoder, indent=4)}"
|
|
2630
|
-
],
|
|
2631
|
-
)
|
|
2632
|
-
|
|
2633
|
-
# Check object-based permissions are enforced for a different instance
|
|
2634
|
-
instance = self._get_queryset().first()
|
|
2635
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2636
|
-
self.assertBodyContains(response, "You do not have permission")
|
|
2637
|
-
# Request was not deleted
|
|
2638
|
-
self.assertEqual(1, len(ScheduledJob.objects.filter(pk=instance.pk)), msg=str(user))
|
|
2639
|
-
|
|
2640
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2641
|
-
def test_post_approve_cannot_self_approve(self):
|
|
2642
|
-
self.add_permissions("extras.change_scheduledjob")
|
|
2643
|
-
self.add_permissions("extras.approve_job")
|
|
2644
|
-
instance = self._get_queryset().first()
|
|
2645
|
-
data = {"_approve": True}
|
|
2646
|
-
|
|
2647
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2648
|
-
self.assertBodyContains(response, "You cannot approve your own job request")
|
|
2649
|
-
# Job was not approved
|
|
2650
|
-
instance.refresh_from_db()
|
|
2651
|
-
self.assertIsNone(instance.approved_by_user)
|
|
2652
|
-
|
|
2653
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2654
|
-
def test_post_approve_different_user_lacking_permissions(self):
|
|
2655
|
-
"""A user needs both change_scheduledjob and approve_job permissions to approve a job request."""
|
|
2656
|
-
user1 = User.objects.create_user(username="testuser1")
|
|
2657
|
-
user2 = User.objects.create_user(username="testuser2")
|
|
2658
|
-
|
|
2659
|
-
# Give both users view_scheduledjob permission
|
|
2660
|
-
obj_perm = ObjectPermission(name="View", actions=["view"])
|
|
2661
|
-
obj_perm.save()
|
|
2662
|
-
obj_perm.users.add(user1, user2)
|
|
2663
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(ScheduledJob))
|
|
2664
|
-
|
|
2665
|
-
# Give user1 change_scheduledjob permission but not approve_job permission
|
|
2666
|
-
obj_perm = ObjectPermission(name="Change", actions=["change"])
|
|
2667
|
-
obj_perm.save()
|
|
2668
|
-
obj_perm.users.add(user1)
|
|
2669
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(ScheduledJob))
|
|
2670
|
-
|
|
2671
|
-
# Give user2 approve_job permission but not change_scheduledjob permission
|
|
2672
|
-
obj_perm = ObjectPermission(name="Approve", actions=["approve"])
|
|
2673
|
-
obj_perm.save()
|
|
2674
|
-
obj_perm.users.add(user2)
|
|
2675
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
|
|
2676
|
-
|
|
2677
|
-
instance = self._get_queryset().first()
|
|
2678
|
-
data = {"_approve": True}
|
|
2679
|
-
|
|
2680
|
-
for user in (user1, user2):
|
|
2681
|
-
self.client.force_login(user)
|
|
2682
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2683
|
-
self.assertBodyContains(response, "You do not have permission")
|
|
2684
|
-
# Job was not approved
|
|
2685
|
-
instance.refresh_from_db()
|
|
2686
|
-
self.assertIsNone(instance.approved_by_user)
|
|
2687
|
-
|
|
2688
|
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
2689
|
-
@load_event_broker_override_settings(
|
|
2690
|
-
EVENT_BROKERS={
|
|
2691
|
-
"SyslogEventBroker": {
|
|
2692
|
-
"CLASS": "nautobot.core.events.SyslogEventBroker",
|
|
2693
|
-
"TOPICS": {
|
|
2694
|
-
"INCLUDE": ["*"],
|
|
2695
|
-
},
|
|
2696
|
-
}
|
|
2697
|
-
}
|
|
2698
|
-
)
|
|
2699
|
-
def test_post_approve_different_user_permitted(self):
|
|
2700
|
-
"""A user with appropriate permissions can approve a job request."""
|
|
2701
|
-
user = User.objects.create_user(username="testuser1")
|
|
2702
|
-
instance = self._get_queryset().first()
|
|
2703
|
-
|
|
2704
|
-
# Give user view_scheduledjob and change_scheduledjob permissions
|
|
2705
|
-
obj_perm = ObjectPermission(name="View", actions=["view", "change"], constraints={"pk": instance.pk})
|
|
2706
|
-
obj_perm.save()
|
|
2707
|
-
obj_perm.users.add(user)
|
|
2708
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(ScheduledJob))
|
|
2709
|
-
|
|
2710
|
-
# Give user approve_job permission
|
|
2711
|
-
obj_perm = ObjectPermission(name="Approve", actions=["approve"], constraints={"pk": instance.job_model.pk})
|
|
2712
|
-
obj_perm.save()
|
|
2713
|
-
obj_perm.users.add(user)
|
|
2714
|
-
obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
|
|
2715
|
-
|
|
2716
|
-
data = {"_approve": True}
|
|
2717
|
-
|
|
2718
|
-
self.client.force_login(user)
|
|
2719
|
-
with self.assertLogs("nautobot.events") as cm:
|
|
2720
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2721
|
-
|
|
2722
|
-
self.assertRedirects(response, reverse("extras:scheduledjob_approval_queue_list"))
|
|
2723
|
-
# Job was scheduled
|
|
2724
|
-
instance.refresh_from_db()
|
|
2725
|
-
self.assertEqual(instance.approved_by_user, user)
|
|
2726
|
-
# Event was published
|
|
2727
|
-
expected_payload = {"data": serialize_object_v2(instance)}
|
|
2728
|
-
self.assertEqual(
|
|
2729
|
-
cm.output,
|
|
2730
|
-
[
|
|
2731
|
-
f"INFO:nautobot.events.nautobot.jobs.approval.approved:{json.dumps(expected_payload, cls=NautobotKombuJSONEncoder, indent=4)}"
|
|
2732
|
-
],
|
|
2733
|
-
)
|
|
2734
|
-
|
|
2735
|
-
# Check object-based permissions are enforced for a different instance
|
|
2736
|
-
instance = self._get_queryset().last()
|
|
2737
|
-
response = self.client.post(self._get_url("view", instance), data)
|
|
2738
|
-
self.assertBodyContains(response, "You do not have permission")
|
|
2739
|
-
# Job was not scheduled
|
|
2740
|
-
instance.refresh_from_db()
|
|
2741
|
-
self.assertIsNone(instance.approved_by_user)
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
class JobQueueTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
2745
|
-
model = JobQueue
|
|
2746
|
-
|
|
2747
|
-
@classmethod
|
|
2748
|
-
def setUpTestData(cls):
|
|
2749
|
-
cls.form_data = {
|
|
2750
|
-
"name": "Test Job Queue",
|
|
2751
|
-
"queue_type": JobQueueTypeChoices.TYPE_CELERY,
|
|
2752
|
-
"description": "This is a very detailed description",
|
|
2753
|
-
"tenant": Tenant.objects.first().pk,
|
|
2754
|
-
"tags": [t.pk for t in Tag.objects.get_for_model(JobQueue)],
|
|
2755
|
-
}
|
|
2756
|
-
cls.bulk_edit_data = {
|
|
2757
|
-
"queue_type": JobQueueTypeChoices.TYPE_KUBERNETES,
|
|
2758
|
-
"description": "This is a very detailed new description",
|
|
2759
|
-
"tenant": Tenant.objects.last().pk,
|
|
2760
|
-
# TODO add tests for add_tags/remove_tags fields in TagsBulkEditFormMixin
|
|
2761
|
-
}
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
class JobResultTestCase(
|
|
2765
|
-
ViewTestCases.GetObjectViewTestCase,
|
|
2766
|
-
ViewTestCases.ListObjectsViewTestCase,
|
|
2767
|
-
ViewTestCases.DeleteObjectViewTestCase,
|
|
2768
|
-
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
|
2769
|
-
):
|
|
2770
|
-
model = JobResult
|
|
2881
|
+
class JobResultTestCase(
|
|
2882
|
+
ViewTestCases.GetObjectViewTestCase,
|
|
2883
|
+
ViewTestCases.ListObjectsViewTestCase,
|
|
2884
|
+
ViewTestCases.DeleteObjectViewTestCase,
|
|
2885
|
+
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
|
2886
|
+
):
|
|
2887
|
+
model = JobResult
|
|
2771
2888
|
|
|
2772
2889
|
@classmethod
|
|
2773
2890
|
def setUpTestData(cls):
|
|
@@ -2839,6 +2956,18 @@ class JobTestCase(
|
|
|
2839
2956
|
reverse("extras:job_run", kwargs={"pk": cls.test_pass.pk}),
|
|
2840
2957
|
)
|
|
2841
2958
|
|
|
2959
|
+
cls.test_dryrun = Job.objects.get(job_class_name="TestDryRun")
|
|
2960
|
+
cls.test_dryrun.enabled = True
|
|
2961
|
+
cls.test_dryrun.has_sensitive_variables = False
|
|
2962
|
+
cls.test_dryrun.save()
|
|
2963
|
+
|
|
2964
|
+
cls.run_urls_dryrun = (
|
|
2965
|
+
# Legacy URL (job class path based)
|
|
2966
|
+
reverse("extras:job_run_by_class_path", kwargs={"class_path": cls.test_dryrun.class_path}),
|
|
2967
|
+
# Current URL (job model pk based)
|
|
2968
|
+
reverse("extras:job_run", kwargs={"pk": cls.test_dryrun.pk}),
|
|
2969
|
+
)
|
|
2970
|
+
|
|
2842
2971
|
cls.test_required_args = Job.objects.get(job_class_name="TestRequired")
|
|
2843
2972
|
cls.test_required_args.enabled = True
|
|
2844
2973
|
cls.test_pass.default_job_queue = default_job_queue
|
|
@@ -2882,8 +3011,6 @@ class JobTestCase(
|
|
|
2882
3011
|
"dryrun_default": True,
|
|
2883
3012
|
"hidden_override": True,
|
|
2884
3013
|
"hidden": False,
|
|
2885
|
-
"approval_required_override": True,
|
|
2886
|
-
"approval_required": True,
|
|
2887
3014
|
"soft_time_limit_override": True,
|
|
2888
3015
|
"soft_time_limit": 350,
|
|
2889
3016
|
"time_limit_override": True,
|
|
@@ -2905,8 +3032,6 @@ class JobTestCase(
|
|
|
2905
3032
|
"dryrun_default": "",
|
|
2906
3033
|
"clear_hidden_override": True,
|
|
2907
3034
|
"hidden": False,
|
|
2908
|
-
"clear_approval_required_override": True,
|
|
2909
|
-
"approval_required": True,
|
|
2910
3035
|
"clear_soft_time_limit_override": False,
|
|
2911
3036
|
"soft_time_limit": 350,
|
|
2912
3037
|
"clear_time_limit_override": True,
|
|
@@ -3151,7 +3276,8 @@ class JobTestCase(
|
|
|
3151
3276
|
self.assertEqual(errors, ["var: This field is required."])
|
|
3152
3277
|
|
|
3153
3278
|
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3154
|
-
|
|
3279
|
+
@mock.patch("nautobot.extras.models.mixins.ApprovableModelMixin.begin_approval_workflow")
|
|
3280
|
+
def test_immediate_job_run_with_args_no_trigger_approval(self, mock_begin_approval_workflow, _):
|
|
3155
3281
|
self.add_permissions("extras.run_job")
|
|
3156
3282
|
self.add_permissions("extras.view_jobresult")
|
|
3157
3283
|
|
|
@@ -3165,6 +3291,7 @@ class JobTestCase(
|
|
|
3165
3291
|
|
|
3166
3292
|
result = JobResult.objects.latest()
|
|
3167
3293
|
self.assertRedirects(response, reverse("extras:jobresult", kwargs={"pk": result.pk}))
|
|
3294
|
+
mock_begin_approval_workflow.assert_not_called()
|
|
3168
3295
|
|
|
3169
3296
|
def test_rerun_job(self):
|
|
3170
3297
|
self.add_permissions("extras.run_job")
|
|
@@ -3265,7 +3392,8 @@ class JobTestCase(
|
|
|
3265
3392
|
)
|
|
3266
3393
|
|
|
3267
3394
|
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3268
|
-
|
|
3395
|
+
@mock.patch("nautobot.extras.models.mixins.ApprovableModelMixin.begin_approval_workflow")
|
|
3396
|
+
def test_run_later_triggers_approval_workflow(self, mock_begin_approval_workflow, _):
|
|
3269
3397
|
self.add_permissions("extras.run_job")
|
|
3270
3398
|
self.add_permissions("extras.view_scheduledjob")
|
|
3271
3399
|
|
|
@@ -3283,6 +3411,7 @@ class JobTestCase(
|
|
|
3283
3411
|
|
|
3284
3412
|
scheduled = ScheduledJob.objects.get(name=f"test {i}")
|
|
3285
3413
|
self.assertEqual(scheduled.start_time, start_time)
|
|
3414
|
+
mock_begin_approval_workflow.assert_called()
|
|
3286
3415
|
|
|
3287
3416
|
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3288
3417
|
def test_run_job_with_sensitive_variables_for_future(self, _):
|
|
@@ -3329,69 +3458,221 @@ class JobTestCase(
|
|
|
3329
3458
|
)
|
|
3330
3459
|
|
|
3331
3460
|
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3332
|
-
def
|
|
3461
|
+
def test_run_job_with_sensitive_variables_and_approval_workflow_defined(self, _):
|
|
3462
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
3463
|
+
name="Test Approval Workflow Definition 1",
|
|
3464
|
+
model_content_type=ContentType.objects.get_for_model(ScheduledJob),
|
|
3465
|
+
weight=0,
|
|
3466
|
+
)
|
|
3467
|
+
|
|
3333
3468
|
self.add_permissions("extras.run_job")
|
|
3334
3469
|
self.add_permissions("extras.view_scheduledjob")
|
|
3335
3470
|
|
|
3336
3471
|
self.test_pass.has_sensitive_variables = True
|
|
3337
|
-
self.test_pass.approval_required = True
|
|
3338
3472
|
self.test_pass.save()
|
|
3339
3473
|
|
|
3340
3474
|
data = {
|
|
3341
3475
|
"_schedule_type": "immediately",
|
|
3342
3476
|
}
|
|
3343
3477
|
for run_url in self.run_urls:
|
|
3344
|
-
# Assert
|
|
3345
|
-
response = self.client.
|
|
3478
|
+
# Assert error message shows after post
|
|
3479
|
+
response = self.client.post(run_url, data)
|
|
3346
3480
|
self.assertBodyContains(
|
|
3347
3481
|
response,
|
|
3348
|
-
"
|
|
3482
|
+
"Unable to run or schedule job: "
|
|
3483
|
+
"This job is flagged as possibly having sensitive variables but also has an applicable approval workflow definition."
|
|
3484
|
+
"Modify or remove the approval workflow definition or modify the job to set `has_sensitive_variables` to False.",
|
|
3349
3485
|
)
|
|
3350
3486
|
|
|
3351
|
-
|
|
3352
|
-
|
|
3487
|
+
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3488
|
+
def test_run_immediate_job_triggers_approval_workflow_if_defined(self, _):
|
|
3489
|
+
self.add_permissions("extras.run_job")
|
|
3490
|
+
self.add_permissions("extras.view_scheduledjob")
|
|
3491
|
+
|
|
3492
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
3493
|
+
name="Approval Definition",
|
|
3494
|
+
model_content_type=ContentType.objects.get_for_model(ScheduledJob),
|
|
3495
|
+
weight=0,
|
|
3496
|
+
)
|
|
3497
|
+
data = {
|
|
3498
|
+
"_schedule_type": "immediately",
|
|
3499
|
+
}
|
|
3500
|
+
for run_url in self.run_urls:
|
|
3501
|
+
response = self.client.post(run_url, data)
|
|
3502
|
+
scheduled_job = ScheduledJob.objects.last()
|
|
3503
|
+
self.assertEqual(scheduled_job.interval, JobExecutionType.TYPE_FUTURE)
|
|
3504
|
+
self.assertRedirects(
|
|
3353
3505
|
response,
|
|
3354
|
-
""
|
|
3355
|
-
<button type="submit" name="_run" id="id__run" class="btn btn-primary" disabled="disabled">
|
|
3356
|
-
<i class="mdi mdi-play"></i> Run Job Now
|
|
3357
|
-
</button>
|
|
3358
|
-
""",
|
|
3359
|
-
html=True,
|
|
3506
|
+
reverse("extras:scheduledjob_approvalworkflow", args=[scheduled_job.pk]),
|
|
3360
3507
|
)
|
|
3508
|
+
|
|
3509
|
+
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3510
|
+
def test_scheduled_job_triggers_approval_workflow_if_defined(self, _):
|
|
3511
|
+
self.add_permissions("extras.run_job")
|
|
3512
|
+
self.add_permissions("extras.view_scheduledjob")
|
|
3513
|
+
|
|
3514
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
3515
|
+
name="Approval Definition",
|
|
3516
|
+
model_content_type=ContentType.objects.get_for_model(ScheduledJob),
|
|
3517
|
+
weight=0,
|
|
3518
|
+
)
|
|
3519
|
+
data = {
|
|
3520
|
+
"_schedule_type": "future",
|
|
3521
|
+
"_schedule_name": "test",
|
|
3522
|
+
"_schedule_start_time": str(timezone.now() + timedelta(minutes=1)),
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
for i, run_url in enumerate(self.run_urls):
|
|
3526
|
+
if "_schedule_name" in data:
|
|
3527
|
+
data["_schedule_name"] = f"test {i}"
|
|
3528
|
+
response = self.client.post(run_url, data)
|
|
3529
|
+
scheduled_job = ScheduledJob.objects.last()
|
|
3530
|
+
self.assertRedirects(
|
|
3531
|
+
response,
|
|
3532
|
+
reverse("extras:scheduledjob_approvalworkflow", args=[scheduled_job.pk]),
|
|
3533
|
+
)
|
|
3534
|
+
|
|
3535
|
+
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3536
|
+
def test_run_scheduled_job_with_no_approval_workflow_defined(self, _):
|
|
3537
|
+
self.add_permissions("extras.run_job")
|
|
3538
|
+
self.add_permissions("extras.view_scheduledjob")
|
|
3539
|
+
|
|
3540
|
+
data = {
|
|
3541
|
+
"_schedule_type": "future",
|
|
3542
|
+
"_schedule_name": "test",
|
|
3543
|
+
"_schedule_start_time": str(timezone.now() + timedelta(minutes=1)),
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
for i, run_url in enumerate(self.run_urls):
|
|
3547
|
+
if "_schedule_name" in data:
|
|
3548
|
+
data["_schedule_name"] = f"test {i}"
|
|
3549
|
+
response = self.client.post(run_url, data)
|
|
3550
|
+
scheduled_job = ScheduledJob.objects.last()
|
|
3551
|
+
self.assertRedirects(response, reverse("extras:scheduledjob_list"))
|
|
3552
|
+
self.assertFalse(scheduled_job.associated_approval_workflows.exists())
|
|
3553
|
+
|
|
3554
|
+
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3555
|
+
def test_run_immediate_job_with_no_approval_workflow_definded(self, _):
|
|
3556
|
+
self.add_permissions("extras.run_job")
|
|
3557
|
+
self.add_permissions("extras.view_jobresult")
|
|
3558
|
+
|
|
3559
|
+
data = {
|
|
3560
|
+
"_schedule_type": "immediately",
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
for run_url in self.run_urls:
|
|
3564
|
+
response = self.client.post(run_url, data)
|
|
3565
|
+
scheduled_job = ScheduledJob.objects.last()
|
|
3566
|
+
self.assertIsNone(scheduled_job)
|
|
3567
|
+
result = JobResult.objects.latest()
|
|
3568
|
+
self.assertRedirects(response, reverse("extras:jobresult", kwargs={"pk": result.pk}))
|
|
3569
|
+
|
|
3570
|
+
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3571
|
+
def test_run_dryrun_immediate_job_with_approval_workflow_definded(self, _):
|
|
3572
|
+
self.add_permissions("extras.run_job")
|
|
3573
|
+
self.add_permissions("extras.view_jobresult")
|
|
3574
|
+
|
|
3575
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
3576
|
+
name="Approval Definition",
|
|
3577
|
+
model_content_type=ContentType.objects.get_for_model(ScheduledJob),
|
|
3578
|
+
weight=0,
|
|
3579
|
+
)
|
|
3580
|
+
|
|
3581
|
+
data = {
|
|
3582
|
+
"_schedule_type": "immediately",
|
|
3583
|
+
"dryrun": True,
|
|
3584
|
+
}
|
|
3585
|
+
for run_url in self.run_urls_dryrun:
|
|
3586
|
+
response = self.client.post(run_url, data)
|
|
3587
|
+
scheduled_job = ScheduledJob.objects.last()
|
|
3588
|
+
self.assertIsNone(scheduled_job)
|
|
3589
|
+
result = JobResult.objects.latest()
|
|
3590
|
+
self.assertRedirects(response, reverse("extras:jobresult", kwargs={"pk": result.pk}))
|
|
3591
|
+
|
|
3592
|
+
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3593
|
+
def test_run_dryrun_job_with_sensitive_variables_and_approval_workflow_defined(self, _):
|
|
3594
|
+
self.test_dryrun.has_sensitive_variables = True
|
|
3595
|
+
self.test_dryrun.save()
|
|
3596
|
+
|
|
3597
|
+
self.add_permissions("extras.run_job")
|
|
3598
|
+
self.add_permissions("extras.view_jobresult")
|
|
3599
|
+
|
|
3600
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
3601
|
+
name="Approval Definition",
|
|
3602
|
+
model_content_type=ContentType.objects.get_for_model(ScheduledJob),
|
|
3603
|
+
weight=0,
|
|
3604
|
+
)
|
|
3605
|
+
|
|
3606
|
+
data = {
|
|
3607
|
+
"_schedule_type": "immediately",
|
|
3608
|
+
"dryrun": True,
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
for run_url in self.run_urls_dryrun:
|
|
3361
3612
|
# Assert error message shows after post
|
|
3362
3613
|
response = self.client.post(run_url, data)
|
|
3363
3614
|
self.assertBodyContains(
|
|
3364
3615
|
response,
|
|
3365
3616
|
"Unable to run or schedule job: "
|
|
3366
|
-
"This job is flagged as possibly having sensitive variables but
|
|
3367
|
-
"
|
|
3617
|
+
"This job is flagged as possibly having sensitive variables but also has an applicable approval workflow definition."
|
|
3618
|
+
"Modify or remove the approval workflow definition or modify the job to set `has_sensitive_variables` to False.",
|
|
3368
3619
|
)
|
|
3369
3620
|
|
|
3370
3621
|
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3371
|
-
def
|
|
3622
|
+
def test_run_dryrun_schedule_job_with_approval_workflow_definded(self, _):
|
|
3372
3623
|
self.add_permissions("extras.run_job")
|
|
3373
3624
|
self.add_permissions("extras.view_scheduledjob")
|
|
3374
3625
|
|
|
3375
|
-
|
|
3376
|
-
|
|
3626
|
+
ApprovalWorkflowDefinition.objects.create(
|
|
3627
|
+
name="Approval Definition",
|
|
3628
|
+
model_content_type=ContentType.objects.get_for_model(ScheduledJob),
|
|
3629
|
+
weight=0,
|
|
3630
|
+
)
|
|
3377
3631
|
data = {
|
|
3378
|
-
"_schedule_type": "
|
|
3632
|
+
"_schedule_type": "future",
|
|
3633
|
+
"_schedule_name": "test",
|
|
3634
|
+
"_schedule_start_time": str(timezone.now() + timedelta(minutes=1)),
|
|
3635
|
+
"dryrun": True,
|
|
3379
3636
|
}
|
|
3380
|
-
|
|
3637
|
+
|
|
3638
|
+
for i, run_url in enumerate(self.run_urls_dryrun):
|
|
3639
|
+
if "_schedule_name" in data:
|
|
3640
|
+
data["_schedule_name"] = f"test {i}"
|
|
3381
3641
|
response = self.client.post(run_url, data)
|
|
3382
3642
|
scheduled_job = ScheduledJob.objects.last()
|
|
3383
|
-
self.assertTrue(scheduled_job.interval, JobExecutionType.TYPE_FUTURE)
|
|
3384
3643
|
self.assertRedirects(
|
|
3385
3644
|
response,
|
|
3386
|
-
reverse("extras:
|
|
3645
|
+
reverse("extras:scheduledjob_approvalworkflow", args=[scheduled_job.pk]),
|
|
3387
3646
|
)
|
|
3388
3647
|
|
|
3648
|
+
@mock.patch("nautobot.extras.views.get_worker_count", return_value=1)
|
|
3649
|
+
def test_run_dryrun_schedule_job_with_no_approval_workflow_definded(self, _):
|
|
3650
|
+
self.add_permissions("extras.run_job")
|
|
3651
|
+
self.add_permissions("extras.view_scheduledjob")
|
|
3652
|
+
|
|
3653
|
+
data = {
|
|
3654
|
+
"_schedule_type": "future",
|
|
3655
|
+
"_schedule_name": "test",
|
|
3656
|
+
"_schedule_start_time": str(timezone.now() + timedelta(minutes=1)),
|
|
3657
|
+
"dryrun": True,
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
for i, run_url in enumerate(self.run_urls_dryrun):
|
|
3661
|
+
if "_schedule_name" in data:
|
|
3662
|
+
data["_schedule_name"] = f"test {i}"
|
|
3663
|
+
response = self.client.post(run_url, data)
|
|
3664
|
+
scheduled_job = ScheduledJob.objects.last()
|
|
3665
|
+
self.assertRedirects(response, reverse("extras:scheduledjob_list"))
|
|
3666
|
+
self.assertFalse(scheduled_job.associated_approval_workflows.exists())
|
|
3667
|
+
|
|
3389
3668
|
def test_job_object_change_log_view(self):
|
|
3390
3669
|
"""Assert Job change log view displays appropriate header"""
|
|
3391
3670
|
instance = self.test_pass
|
|
3392
3671
|
self.add_permissions("extras.view_objectchange", "extras.view_job")
|
|
3393
3672
|
response = self.client.get(instance.get_changelog_url())
|
|
3394
|
-
self.assertBodyContains(response, f"{instance
|
|
3673
|
+
self.assertBodyContains(response, f"{instance}")
|
|
3674
|
+
changelog_table = "<thead><tr><th>Time</th><th>User name</th><th>Action</th><th>Type</th><th>Object</th><th>Request ID</th></tr></thead>"
|
|
3675
|
+
self.assertBodyContains(response, changelog_table, html=True)
|
|
3395
3676
|
|
|
3396
3677
|
|
|
3397
3678
|
class JobButtonTestCase(
|
|
@@ -3566,8 +3847,9 @@ class JobButtonRenderingTestCase(TestCase):
|
|
|
3566
3847
|
NO_CONFIRM_BUTTON.format(
|
|
3567
3848
|
button_id=self.job_button_1.pk,
|
|
3568
3849
|
button_text=f"JobButton {self.location_type.name}",
|
|
3569
|
-
button_class=self.job_button_1.
|
|
3850
|
+
button_class=self.job_button_1.button_class_css_class,
|
|
3570
3851
|
disabled="",
|
|
3852
|
+
menu_item="",
|
|
3571
3853
|
),
|
|
3572
3854
|
content,
|
|
3573
3855
|
)
|
|
@@ -3575,8 +3857,9 @@ class JobButtonRenderingTestCase(TestCase):
|
|
|
3575
3857
|
NO_CONFIRM_BUTTON.format(
|
|
3576
3858
|
button_id=self.job_button_2.pk,
|
|
3577
3859
|
button_text="Click me!",
|
|
3578
|
-
button_class=self.job_button_2.
|
|
3860
|
+
button_class=self.job_button_2.button_class_css_class,
|
|
3579
3861
|
disabled="disabled",
|
|
3862
|
+
menu_item="",
|
|
3580
3863
|
),
|
|
3581
3864
|
content,
|
|
3582
3865
|
)
|
|
@@ -3597,6 +3880,7 @@ class JobButtonRenderingTestCase(TestCase):
|
|
|
3597
3880
|
button_text=f"JobButton {self.location_type.name}",
|
|
3598
3881
|
button_class="link",
|
|
3599
3882
|
disabled="",
|
|
3883
|
+
menu_item="dropdown-item",
|
|
3600
3884
|
)
|
|
3601
3885
|
+ "</li>",
|
|
3602
3886
|
content,
|
|
@@ -3608,12 +3892,14 @@ class JobButtonRenderingTestCase(TestCase):
|
|
|
3608
3892
|
button_text="Click me!",
|
|
3609
3893
|
button_class="link",
|
|
3610
3894
|
disabled="disabled",
|
|
3895
|
+
menu_item="dropdown-item",
|
|
3611
3896
|
)
|
|
3612
3897
|
+ "</li>",
|
|
3613
3898
|
content,
|
|
3614
3899
|
)
|
|
3615
3900
|
|
|
3616
3901
|
|
|
3902
|
+
@tag("example_app")
|
|
3617
3903
|
class JobCustomTemplateTestCase(TestCase):
|
|
3618
3904
|
@classmethod
|
|
3619
3905
|
def setUpTestData(cls):
|
|
@@ -4160,9 +4446,9 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|
|
4160
4446
|
}
|
|
4161
4447
|
self.assertHttpStatus(self.client.post(**request), 302)
|
|
4162
4448
|
|
|
4163
|
-
|
|
4164
|
-
self.assertTrue(
|
|
4165
|
-
self.assertEqual(
|
|
4449
|
+
tag_object = Tag.objects.filter(name=self.form_data["name"])
|
|
4450
|
+
self.assertTrue(tag_object.exists())
|
|
4451
|
+
self.assertEqual(tag_object[0].content_types.first(), location_content_type)
|
|
4166
4452
|
|
|
4167
4453
|
def test_create_tags_with_invalid_content_types(self):
|
|
4168
4454
|
self.add_permissions("extras.add_tag")
|
|
@@ -4179,8 +4465,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|
|
4179
4465
|
}
|
|
4180
4466
|
|
|
4181
4467
|
response = self.client.post(**request)
|
|
4182
|
-
|
|
4183
|
-
self.assertFalse(
|
|
4468
|
+
tag_object = Tag.objects.filter(name=self.form_data["name"])
|
|
4469
|
+
self.assertFalse(tag_object.exists())
|
|
4184
4470
|
self.assertBodyContains(response, "content_types: Select a valid choice")
|
|
4185
4471
|
|
|
4186
4472
|
def test_update_tags_remove_content_type(self):
|
|
@@ -4306,6 +4592,7 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase, ViewTestCases
|
|
|
4306
4592
|
"remove_content_types": [device_ct.pk],
|
|
4307
4593
|
}
|
|
4308
4594
|
|
|
4595
|
+
@tag("fix_in_v3")
|
|
4309
4596
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
4310
4597
|
def test_view_with_content_types(self):
|
|
4311
4598
|
"""
|
|
@@ -4325,12 +4612,12 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase, ViewTestCases
|
|
|
4325
4612
|
if result == "Contact Associations":
|
|
4326
4613
|
# AssociationContact Table in the contact tab should be there.
|
|
4327
4614
|
self.assertInHTML(
|
|
4328
|
-
f'<strong>{result}</strong><div class="
|
|
4615
|
+
f'<strong>{result}</strong><div class="float-end d-print-none">',
|
|
4329
4616
|
response_body,
|
|
4330
4617
|
)
|
|
4331
4618
|
# ContactAssociationTable related to this role instances should not be there.
|
|
4332
4619
|
self.assertNotIn(
|
|
4333
|
-
f'<strong>{result}</strong>\n </div>\n \n\n<table class="table table-hover table-headings">\n',
|
|
4620
|
+
f'<strong>{result}</strong>\n </div>\n \n\n<table class="table table-hover nb-table-headings">\n',
|
|
4334
4621
|
response_body,
|
|
4335
4622
|
)
|
|
4336
4623
|
else:
|