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.

Files changed (919) hide show
  1. nautobot/apps/choices.py +2 -2
  2. nautobot/apps/filters.py +9 -9
  3. nautobot/apps/forms.py +2 -0
  4. nautobot/apps/models.py +7 -2
  5. nautobot/apps/ui.py +20 -1
  6. nautobot/apps/utils.py +2 -3
  7. nautobot/apps/views.py +7 -1
  8. nautobot/circuits/filters.py +8 -23
  9. nautobot/circuits/navigation.py +3 -1
  10. nautobot/circuits/templates/circuits/circuit_create.html +9 -9
  11. nautobot/circuits/templates/circuits/circuit_terminations_swap.html +2 -2
  12. nautobot/circuits/templates/circuits/circuittermination_create.html +24 -33
  13. nautobot/circuits/templates/circuits/inc/circuit_termination.html +10 -10
  14. nautobot/circuits/templates/circuits/inc/circuit_termination_cable_fragment.html +13 -13
  15. nautobot/circuits/templates/circuits/inc/circuit_termination_header_extra_content.html +6 -6
  16. nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +3 -3
  17. nautobot/circuits/templates/circuits/inc/speed_widget.html +13 -13
  18. nautobot/circuits/templates/circuits/provider_create.html +9 -9
  19. nautobot/circuits/tests/integration/test_circuit.py +19 -19
  20. nautobot/circuits/tests/integration/test_circuits_bulk_operations.py +3 -0
  21. nautobot/circuits/tests/integration/test_relationships.py +4 -12
  22. nautobot/circuits/views.py +0 -2
  23. nautobot/cloud/filters.py +1 -13
  24. nautobot/cloud/navigation.py +3 -1
  25. nautobot/cloud/templates/cloud/cloudnetwork_update.html +9 -9
  26. nautobot/cloud/templates/cloud/cloudservice_update.html +6 -6
  27. nautobot/core/api/fields.py +30 -2
  28. nautobot/core/api/schema.py +1 -1
  29. nautobot/core/api/serializers.py +9 -2
  30. nautobot/core/api/urls.py +2 -0
  31. nautobot/core/api/views.py +58 -37
  32. nautobot/core/apps/__init__.py +6 -12
  33. nautobot/core/branching.py +83 -0
  34. nautobot/core/celery/__init__.py +11 -6
  35. nautobot/core/celery/backends.py +2 -0
  36. nautobot/core/celery/encoders.py +7 -0
  37. nautobot/core/celery/task.py +44 -0
  38. nautobot/core/checks.py +60 -0
  39. nautobot/core/cli/bootstrap_v3_to_v5.py +776 -0
  40. nautobot/core/constants.py +9 -0
  41. nautobot/core/context_processors.py +84 -0
  42. nautobot/core/filters.py +131 -2
  43. nautobot/core/forms/__init__.py +4 -2
  44. nautobot/core/forms/fields.py +10 -8
  45. nautobot/core/forms/forms.py +21 -9
  46. nautobot/core/forms/search.py +0 -15
  47. nautobot/core/forms/widgets.py +3 -2
  48. nautobot/core/graphql/__init__.py +8 -26
  49. nautobot/core/graphql/generators.py +16 -6
  50. nautobot/core/graphql/schema.py +1 -1
  51. nautobot/core/graphql/schema_init.py +1 -2
  52. nautobot/core/graphql/utils.py +7 -9
  53. nautobot/core/jobs/__init__.py +158 -0
  54. nautobot/core/management/commands/generate_test_data.py +28 -9
  55. nautobot/core/models/__init__.py +17 -2
  56. nautobot/core/models/fields.py +3 -2
  57. nautobot/core/models/generics.py +9 -1
  58. nautobot/core/models/name_color_content_types.py +1 -1
  59. nautobot/core/models/ordering.py +7 -5
  60. nautobot/core/models/querysets.py +77 -2
  61. nautobot/core/models/tree_queries.py +6 -4
  62. nautobot/core/settings.py +30 -16
  63. nautobot/core/settings.yaml +13 -7
  64. nautobot/core/tables.py +114 -44
  65. nautobot/core/templates/403.html +1 -1
  66. nautobot/core/templates/403_csrf_failure.html +1 -1
  67. nautobot/core/templates/404.html +1 -1
  68. nautobot/core/templates/40x.html +8 -8
  69. nautobot/core/templates/500.html +10 -10
  70. nautobot/core/templates/about.html +13 -12
  71. nautobot/core/templates/admin/actions.html +1 -1
  72. nautobot/core/templates/admin/app_index.html +3 -3
  73. nautobot/core/templates/admin/base.html +45 -52
  74. nautobot/core/templates/admin/base_site.html +0 -9
  75. nautobot/core/templates/admin/change_form.html +5 -5
  76. nautobot/core/templates/admin/change_list.html +8 -12
  77. nautobot/core/templates/admin/change_list_results.html +3 -3
  78. nautobot/core/templates/admin/config/config.html +24 -24
  79. nautobot/core/templates/admin/delete_confirmation.html +5 -5
  80. nautobot/core/templates/admin/edit_inline/stacked.html +5 -5
  81. nautobot/core/templates/admin/edit_inline/tabular.html +3 -3
  82. nautobot/core/templates/admin/includes/fieldset.html +15 -15
  83. nautobot/core/templates/admin/index.html +8 -8
  84. nautobot/core/templates/admin/submit_line.html +5 -5
  85. nautobot/core/templates/base_django.html +36 -32
  86. nautobot/core/templates/buttons/add.html +1 -1
  87. nautobot/core/templates/buttons/consolidated_detail_view_action_buttons.html +2 -2
  88. nautobot/core/templates/buttons/export.html +17 -18
  89. nautobot/core/templates/buttons/job_import.html +2 -2
  90. nautobot/core/templates/components/breadcrumbs.html +19 -17
  91. nautobot/core/templates/components/button/dropdown.html +7 -5
  92. nautobot/core/templates/components/echarts.html +2 -0
  93. nautobot/core/templates/components/layout/one_over_two.html +3 -3
  94. nautobot/core/templates/components/layout/two_over_one.html +3 -3
  95. nautobot/core/templates/components/panel/body_content_data_table.html +2 -2
  96. nautobot/core/templates/components/panel/body_content_tags.html +1 -1
  97. nautobot/core/templates/components/panel/body_wrapper_generic.html +4 -2
  98. nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
  99. nautobot/core/templates/components/panel/body_wrapper_key_value_table.html +5 -3
  100. nautobot/core/templates/components/panel/body_wrapper_table.html +4 -2
  101. nautobot/core/templates/components/panel/footer_contacts_table.html +4 -4
  102. nautobot/core/templates/components/panel/footer_content_table.html +3 -3
  103. nautobot/core/templates/components/panel/grouping_toggle.html +12 -11
  104. nautobot/core/templates/components/panel/header_extra_content_table.html +2 -11
  105. nautobot/core/templates/components/panel/panel.html +6 -3
  106. nautobot/core/templates/components/panel/stats_panel_body.html +9 -7
  107. nautobot/core/templates/components/tab/content_wrapper.html +29 -1
  108. nautobot/core/templates/components/tab/label_wrapper.html +10 -2
  109. nautobot/core/templates/components/tab/label_wrapper_distinct_view.html +11 -4
  110. nautobot/core/templates/echarts/echarts.html +20 -0
  111. nautobot/core/templates/exceptions/import_error.html +2 -2
  112. nautobot/core/templates/exceptions/permission_error.html +1 -1
  113. nautobot/core/templates/exceptions/programming_error.html +2 -2
  114. nautobot/core/templates/generic/object_bulk_add_component.html +29 -20
  115. nautobot/core/templates/generic/object_bulk_create.html +87 -75
  116. nautobot/core/templates/generic/object_bulk_destroy.html +35 -37
  117. nautobot/core/templates/generic/object_bulk_remove.html +30 -26
  118. nautobot/core/templates/generic/object_bulk_rename.html +53 -40
  119. nautobot/core/templates/generic/object_bulk_update.html +36 -29
  120. nautobot/core/templates/generic/object_create.html +40 -27
  121. nautobot/core/templates/generic/object_import.html +36 -24
  122. nautobot/core/templates/generic/object_list.html +279 -215
  123. nautobot/core/templates/generic/object_notes.html +21 -11
  124. nautobot/core/templates/generic/object_retrieve.html +161 -213
  125. nautobot/core/templates/graphene/graphiql.html +113 -60
  126. nautobot/core/templates/home.html +164 -87
  127. nautobot/core/templates/import_success.html +3 -2
  128. nautobot/core/templates/inc/ajax_loader.html +1 -1
  129. nautobot/core/templates/inc/computed_fields/panel_data.html +25 -13
  130. nautobot/core/templates/inc/created_updated.html +12 -7
  131. nautobot/core/templates/inc/custom_fields/panel_data.html +28 -16
  132. nautobot/core/templates/inc/custom_fields_panel.html +3 -3
  133. nautobot/core/templates/inc/dynamic_groups_panel.html +3 -3
  134. nautobot/core/templates/inc/extras_features_edit_form_fields.html +15 -15
  135. nautobot/core/templates/inc/footer.html +90 -40
  136. nautobot/core/templates/inc/form_static_field.html +6 -0
  137. nautobot/core/templates/inc/header.html +75 -0
  138. nautobot/core/templates/inc/header_banners.html +17 -0
  139. nautobot/core/templates/inc/header_messages.html +6 -0
  140. nautobot/core/templates/inc/image_attachments.html +9 -9
  141. nautobot/core/templates/inc/javascript.html +7 -24
  142. nautobot/core/templates/inc/media.html +4 -29
  143. nautobot/core/templates/inc/modal.html +2 -2
  144. nautobot/core/templates/inc/nav_favorites.html +27 -0
  145. nautobot/core/templates/inc/nav_menu.html +150 -108
  146. nautobot/core/templates/inc/object_details_advanced_panel.html +84 -71
  147. nautobot/core/templates/inc/page_title.html +23 -0
  148. nautobot/core/templates/inc/paginator.html +39 -28
  149. nautobot/core/templates/inc/relationships/panel_override.html +3 -3
  150. nautobot/core/templates/inc/relationships_panel.html +3 -3
  151. nautobot/core/templates/inc/relationships_table_rows.html +1 -1
  152. nautobot/core/templates/inc/search_panel.html +22 -16
  153. nautobot/core/templates/inc/table.html +61 -36
  154. nautobot/core/templates/inc/tenancy_form_panel.html +3 -3
  155. nautobot/core/templates/login.html +17 -59
  156. nautobot/core/templates/modals/modal_theme.html +12 -23
  157. nautobot/core/templates/nautobot_config.py.j2 +6 -5
  158. nautobot/core/templates/panel_table.html +8 -12
  159. nautobot/core/templates/redoc_ui.html +80 -0
  160. nautobot/core/templates/rest_framework/api.html +43 -21
  161. nautobot/core/templates/search.html +12 -13
  162. nautobot/core/templates/swagger_ui.html +19 -4
  163. nautobot/core/templates/system_jobs/import_objects.html +70 -58
  164. nautobot/core/templates/template.css +0 -6
  165. nautobot/core/templates/utilities/comment_form.html +34 -0
  166. nautobot/core/templates/utilities/confirmation_form.html +17 -9
  167. nautobot/core/templates/utilities/obj_table.html +19 -11
  168. nautobot/core/templates/utilities/render_field.html +27 -21
  169. nautobot/core/templates/utilities/render_jinja2.html +22 -25
  170. nautobot/core/templates/utilities/templatetags/advanced_filter_indicator.html +8 -0
  171. nautobot/core/templates/utilities/templatetags/badge.html +1 -1
  172. nautobot/core/templates/utilities/templatetags/dynamic_group_assignment_modal.html +2 -3
  173. nautobot/core/templates/utilities/templatetags/filter_form_drawer.html +482 -0
  174. nautobot/core/templates/utilities/templatetags/modal_form_as_dialog.html +14 -18
  175. nautobot/core/templates/utilities/templatetags/saved_view_modal.html +11 -11
  176. nautobot/core/templates/utilities/templatetags/table_config_form.html +51 -24
  177. nautobot/core/templates/utilities/templatetags/tag.html +1 -1
  178. nautobot/core/templates/utilities/templatetags/utilization_graph.html +3 -3
  179. nautobot/core/templates/utilities/theme_preview.html +829 -566
  180. nautobot/core/templates/utilities/worker_status.html +42 -41
  181. nautobot/core/templates/widgets/selectwithdisabled_option.html +3 -1
  182. nautobot/core/templates/widgets/sluginput.html +2 -2
  183. nautobot/core/templatetags/buttons.py +38 -40
  184. nautobot/core/templatetags/helpers.py +105 -28
  185. nautobot/core/templatetags/ui_framework.py +17 -0
  186. nautobot/core/testing/api.py +76 -12
  187. nautobot/core/testing/filters.py +11 -27
  188. nautobot/core/testing/integration.py +128 -10
  189. nautobot/core/testing/mixins.py +7 -4
  190. nautobot/core/testing/utils.py +28 -5
  191. nautobot/core/testing/views.py +125 -27
  192. nautobot/core/tests/integration/test_app_home.py +39 -35
  193. nautobot/core/tests/integration/test_app_navbar.py +60 -67
  194. nautobot/core/tests/integration/test_filters.py +123 -55
  195. nautobot/core/tests/integration/test_general_functionality.py +1 -1
  196. nautobot/core/tests/integration/test_home.py +10 -18
  197. nautobot/core/tests/integration/test_import_objects_ui.py +2 -9
  198. nautobot/core/tests/integration/test_navbar.py +41 -16
  199. nautobot/core/tests/integration/test_swagger.py +1 -7
  200. nautobot/core/tests/integration/test_theme.py +3 -0
  201. nautobot/core/tests/nautobot_config_without_example_apps.py +4 -0
  202. nautobot/core/tests/runner.py +6 -1
  203. nautobot/core/tests/test_api.py +5 -3
  204. nautobot/core/tests/test_branching.py +154 -0
  205. nautobot/core/tests/test_breadcrumbs.py +7 -8
  206. nautobot/core/tests/test_checks.py +28 -0
  207. nautobot/core/tests/test_commands.py +0 -41
  208. nautobot/core/tests/test_config.py +2 -1
  209. nautobot/core/tests/test_csv.py +4 -7
  210. nautobot/core/tests/test_filters.py +326 -318
  211. nautobot/core/tests/test_forms.py +19 -30
  212. nautobot/core/tests/test_graphql.py +67 -57
  213. nautobot/core/tests/test_models.py +1 -1
  214. nautobot/core/tests/test_nautobot_server.py +2 -0
  215. nautobot/core/tests/test_navigations.py +78 -10
  216. nautobot/core/tests/test_tables.py +3 -1
  217. nautobot/core/tests/test_templatetags_helpers.py +61 -21
  218. nautobot/core/tests/test_templatetags_ui_framework.py +36 -18
  219. nautobot/core/tests/test_ui.py +207 -2
  220. nautobot/core/tests/test_utils.py +147 -2
  221. nautobot/core/tests/test_views.py +201 -64
  222. nautobot/core/tests/test_views_utils.py +1 -1
  223. nautobot/core/ui/breadcrumbs.py +2 -12
  224. nautobot/core/ui/choices.py +190 -0
  225. nautobot/core/ui/constants.py +86 -0
  226. nautobot/core/ui/echarts.py +474 -0
  227. nautobot/core/ui/nav.py +5 -1
  228. nautobot/core/ui/object_detail.py +180 -16
  229. nautobot/core/urls.py +13 -1
  230. nautobot/core/utils/cache.py +71 -0
  231. nautobot/core/utils/data.py +8 -5
  232. nautobot/core/utils/filtering.py +8 -2
  233. nautobot/core/utils/git.py +3 -3
  234. nautobot/core/utils/lookup.py +87 -13
  235. nautobot/core/utils/migrations.py +22 -0
  236. nautobot/core/utils/module_loading.py +26 -0
  237. nautobot/core/utils/permissions.py +9 -5
  238. nautobot/core/views/__init__.py +114 -63
  239. nautobot/core/views/generic.py +34 -27
  240. nautobot/core/views/mixins.py +49 -27
  241. nautobot/core/views/renderers.py +3 -5
  242. nautobot/core/views/utils.py +10 -5
  243. nautobot/core/views/viewsets.py +2 -1
  244. nautobot/data_validation/__init__.py +0 -0
  245. nautobot/data_validation/api/__init__.py +1 -0
  246. nautobot/data_validation/api/serializers.py +80 -0
  247. nautobot/data_validation/api/urls.py +20 -0
  248. nautobot/data_validation/api/views.py +44 -0
  249. nautobot/data_validation/apps.py +18 -0
  250. nautobot/data_validation/custom_validators.py +330 -0
  251. nautobot/data_validation/filters.py +133 -0
  252. nautobot/data_validation/form_mixin.py +25 -0
  253. nautobot/data_validation/forms.py +342 -0
  254. nautobot/data_validation/migrations/0001_initial.py +224 -0
  255. nautobot/data_validation/migrations/0002_data_migration_from_app.py +324 -0
  256. nautobot/data_validation/migrations/__init__.py +0 -0
  257. nautobot/data_validation/models.py +361 -0
  258. nautobot/data_validation/navigation.py +74 -0
  259. nautobot/data_validation/signals.py +30 -0
  260. nautobot/data_validation/tables.py +259 -0
  261. nautobot/data_validation/templates/data_validation/datacompliance_retrieve.html +1 -0
  262. nautobot/data_validation/templates/data_validation/datacompliance_tab.html +11 -0
  263. nautobot/data_validation/templates/data_validation/device_constraints.html +61 -0
  264. nautobot/data_validation/tests/__init__.py +20 -0
  265. nautobot/data_validation/tests/migrations/__init__.py +0 -0
  266. nautobot/data_validation/tests/migrations/test_migrations.py +489 -0
  267. nautobot/data_validation/tests/test_api.py +238 -0
  268. nautobot/data_validation/tests/test_custom_validators.py +423 -0
  269. nautobot/data_validation/tests/test_data_compliance_rules.py +85 -0
  270. nautobot/data_validation/tests/test_filters.py +240 -0
  271. nautobot/data_validation/tests/test_form_mixin.py +115 -0
  272. nautobot/data_validation/tests/test_models.py +393 -0
  273. nautobot/data_validation/tests/test_views.py +435 -0
  274. nautobot/data_validation/urls.py +21 -0
  275. nautobot/data_validation/views.py +227 -0
  276. nautobot/dcim/api/serializers.py +10 -13
  277. nautobot/dcim/api/urls.py +2 -0
  278. nautobot/dcim/api/views.py +7 -0
  279. nautobot/dcim/apps.py +4 -0
  280. nautobot/dcim/choices.py +16 -0
  281. nautobot/dcim/custom_validators.py +84 -0
  282. nautobot/dcim/filter_mixins.py +353 -4
  283. nautobot/dcim/{filters/__init__.py → filters.py} +70 -157
  284. nautobot/dcim/forms.py +12 -6
  285. nautobot/dcim/graphql/types.py +1 -0
  286. nautobot/dcim/migrations/0075_add_deviceclusterassignment.py +52 -0
  287. nautobot/dcim/migrations/0076_device_cluster_to_clusters_data_migration.py +40 -0
  288. nautobot/dcim/migrations/0077_remove_device_cluster.py +14 -0
  289. nautobot/dcim/migrations/0078_remove_device_location_tenant_name_uniqueness.py +16 -0
  290. nautobot/dcim/migrations/0079_device_name_data_migration.py +59 -0
  291. nautobot/dcim/models/__init__.py +2 -0
  292. nautobot/dcim/models/device_components.py +3 -1
  293. nautobot/dcim/models/devices.py +115 -51
  294. nautobot/dcim/navigation.py +7 -3
  295. nautobot/dcim/querysets.py +6 -0
  296. nautobot/dcim/signals.py +19 -0
  297. nautobot/dcim/tables/devices.py +9 -5
  298. nautobot/dcim/tables/template_code.py +191 -102
  299. nautobot/dcim/templates/dcim/cable.html +1 -1
  300. nautobot/dcim/templates/dcim/cable_connect.html +62 -146
  301. nautobot/dcim/templates/dcim/cable_retrieve.html +10 -10
  302. nautobot/dcim/templates/dcim/cable_trace.html +15 -17
  303. nautobot/dcim/templates/dcim/console_port_connection_list.html +2 -2
  304. nautobot/dcim/templates/dcim/consoleport.html +18 -17
  305. nautobot/dcim/templates/dcim/consoleserverport.html +18 -17
  306. nautobot/dcim/templates/dcim/controller_create.html +12 -8
  307. nautobot/dcim/templates/dcim/controller_wirelessnetworks.html +1 -1
  308. nautobot/dcim/templates/dcim/controllermanageddevicegroup_create.html +6 -6
  309. nautobot/dcim/templates/dcim/controllermanageddevicegroup_retrieve.html +1 -1
  310. nautobot/dcim/templates/dcim/device/config.html +17 -19
  311. nautobot/dcim/templates/dcim/device/lldp_neighbors.html +4 -4
  312. nautobot/dcim/templates/dcim/device/status.html +20 -20
  313. nautobot/dcim/templates/dcim/device_component_add.html +24 -15
  314. nautobot/dcim/templates/dcim/device_create.html +120 -120
  315. nautobot/dcim/templates/dcim/device_list.html +75 -12
  316. nautobot/dcim/templates/dcim/devicebay.html +7 -7
  317. nautobot/dcim/templates/dcim/devicebay_populate.html +29 -23
  318. nautobot/dcim/templates/dcim/deviceredundancygroup_create.html +6 -6
  319. nautobot/dcim/templates/dcim/devicetype.html +1 -1
  320. nautobot/dcim/templates/dcim/devicetype_component_add.html +25 -19
  321. nautobot/dcim/templates/dcim/devicetype_list.html +4 -4
  322. nautobot/dcim/templates/dcim/devicetype_update.html +9 -9
  323. nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +3 -3
  324. nautobot/dcim/templates/dcim/frontport.html +21 -20
  325. nautobot/dcim/templates/dcim/inc/cable_form.html +7 -7
  326. nautobot/dcim/templates/dcim/inc/cable_termination.html +1 -1
  327. nautobot/dcim/templates/dcim/inc/cable_toggle_buttons.html +18 -9
  328. nautobot/dcim/templates/dcim/inc/detail_softwareversion_softwareimagefile_rows.html +1 -1
  329. nautobot/dcim/templates/dcim/inc/device_interface_filter.html +1 -1
  330. nautobot/dcim/templates/dcim/inc/devicetype_component_table.html +10 -10
  331. nautobot/dcim/templates/dcim/inc/edit_form_softwareversion_js.html +2 -2
  332. nautobot/dcim/templates/dcim/inc/homepage_connections.html +2 -2
  333. nautobot/dcim/templates/dcim/inc/moduletype_component_table.html +10 -10
  334. nautobot/dcim/templates/dcim/inc/rack_elevation.html +2 -2
  335. nautobot/dcim/templates/dcim/interface.html +42 -22
  336. nautobot/dcim/templates/dcim/interface_connection_list.html +2 -2
  337. nautobot/dcim/templates/dcim/interface_edit.html +26 -11
  338. nautobot/dcim/templates/dcim/interfaceredundancygroupassociation_create.html +3 -3
  339. nautobot/dcim/templates/dcim/inventoryitem.html +3 -3
  340. nautobot/dcim/templates/dcim/inventoryitem_add.html +21 -10
  341. nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
  342. nautobot/dcim/templates/dcim/inventoryitem_edit.html +6 -4
  343. nautobot/dcim/templates/dcim/location.html +1 -1
  344. nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +24 -18
  345. nautobot/dcim/templates/dcim/location_retrieve.html +1 -1
  346. nautobot/dcim/templates/dcim/location_update.html +9 -9
  347. nautobot/dcim/templates/dcim/locationtype.html +0 -1
  348. nautobot/dcim/templates/dcim/module/base.html +67 -27
  349. nautobot/dcim/templates/dcim/module_consoleports.html +13 -15
  350. nautobot/dcim/templates/dcim/module_consoleserverports.html +13 -15
  351. nautobot/dcim/templates/dcim/module_frontports.html +13 -15
  352. nautobot/dcim/templates/dcim/module_interfaces.html +14 -16
  353. nautobot/dcim/templates/dcim/module_list.html +59 -10
  354. nautobot/dcim/templates/dcim/module_modulebays.html +12 -14
  355. nautobot/dcim/templates/dcim/module_poweroutlets.html +13 -15
  356. nautobot/dcim/templates/dcim/module_powerports.html +13 -15
  357. nautobot/dcim/templates/dcim/module_rearports.html +13 -15
  358. nautobot/dcim/templates/dcim/module_retrieve.html +3 -3
  359. nautobot/dcim/templates/dcim/module_update.html +15 -9
  360. nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -93
  361. nautobot/dcim/templates/dcim/modulefamily_retrieve.html +7 -7
  362. nautobot/dcim/templates/dcim/moduletype_list.html +2 -2
  363. nautobot/dcim/templates/dcim/moduletype_retrieve.html +74 -35
  364. nautobot/dcim/templates/dcim/platform_create.html +9 -9
  365. nautobot/dcim/templates/dcim/power_port_connection_list.html +3 -3
  366. nautobot/dcim/templates/dcim/powerfeed.html +1 -1
  367. nautobot/dcim/templates/dcim/powerfeed_edit.html +15 -15
  368. nautobot/dcim/templates/dcim/poweroutlet.html +13 -13
  369. nautobot/dcim/templates/dcim/powerpanel.html +1 -1
  370. nautobot/dcim/templates/dcim/powerport.html +17 -16
  371. nautobot/dcim/templates/dcim/rack.html +1 -1
  372. nautobot/dcim/templates/dcim/rack_elevation.html +2 -2
  373. nautobot/dcim/templates/dcim/rack_elevation_list.html +21 -9
  374. nautobot/dcim/templates/dcim/rack_retrieve.html +75 -57
  375. nautobot/dcim/templates/dcim/rack_update.html +14 -14
  376. nautobot/dcim/templates/dcim/rackreservation.html +1 -1
  377. nautobot/dcim/templates/dcim/rackreservation_edit.html +6 -6
  378. nautobot/dcim/templates/dcim/rearport.html +19 -18
  379. nautobot/dcim/templates/dcim/trace/cable.html +1 -1
  380. nautobot/dcim/templates/dcim/trace/circuit.html +1 -1
  381. nautobot/dcim/templates/dcim/trace/device.html +1 -1
  382. nautobot/dcim/templates/dcim/trace/powerpanel.html +1 -1
  383. nautobot/dcim/templates/dcim/trace/termination.html +1 -1
  384. nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
  385. nautobot/dcim/templates/dcim/virtualchassis_add_member.html +25 -16
  386. nautobot/dcim/templates/dcim/virtualchassis_create.html +6 -6
  387. nautobot/dcim/templates/dcim/virtualchassis_edit.html +1 -1
  388. nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -1
  389. nautobot/dcim/templates/dcim/virtualchassis_update.html +36 -22
  390. nautobot/dcim/templates/dcim/virtualdevicecontext_update.html +9 -9
  391. nautobot/dcim/tests/integration/test_controller.py +6 -6
  392. nautobot/dcim/tests/integration/test_controller_managed_device_group.py +7 -7
  393. nautobot/dcim/tests/integration/test_create_device.py +9 -9
  394. nautobot/dcim/tests/integration/test_device_bulk_operations.py +7 -2
  395. nautobot/dcim/tests/integration/test_fileinputpicker.py +5 -7
  396. nautobot/dcim/tests/integration/test_location_bulk_operations.py +2 -0
  397. nautobot/dcim/tests/integration/test_module_bay_position.py +4 -1
  398. nautobot/dcim/tests/test_api.py +86 -6
  399. nautobot/dcim/tests/test_custom_validators.py +229 -0
  400. nautobot/dcim/tests/test_filters.py +159 -110
  401. nautobot/dcim/tests/test_graphql.py +32 -36
  402. nautobot/dcim/tests/test_jobs.py +1 -1
  403. nautobot/dcim/tests/test_models.py +229 -1
  404. nautobot/dcim/tests/test_views.py +31 -20
  405. nautobot/dcim/utils.py +3 -3
  406. nautobot/dcim/views.py +77 -41
  407. nautobot/extras/api/serializers.py +83 -19
  408. nautobot/extras/api/urls.py +7 -0
  409. nautobot/extras/api/views.py +243 -140
  410. nautobot/extras/choices.py +34 -13
  411. nautobot/extras/constants.py +1 -1
  412. nautobot/extras/context_managers.py +26 -26
  413. nautobot/extras/datasources/git.py +22 -0
  414. nautobot/extras/datasources/registry.py +3 -0
  415. nautobot/extras/exceptions.py +5 -0
  416. nautobot/extras/factory.py +11 -1
  417. nautobot/extras/{filters/mixins.py → filter_mixins.py} +4 -3
  418. nautobot/extras/{filters/__init__.py → filters.py} +203 -58
  419. nautobot/extras/forms/base.py +2 -1
  420. nautobot/extras/forms/forms.py +225 -20
  421. nautobot/extras/forms/mixins.py +0 -41
  422. nautobot/extras/homepage.py +21 -2
  423. nautobot/extras/jobs.py +2 -8
  424. nautobot/extras/jobs_ui.py +2 -2
  425. nautobot/extras/management/__init__.py +9 -0
  426. nautobot/extras/managers.py +31 -22
  427. nautobot/extras/migrations/0126_approval_workflow_pre_check.py +58 -0
  428. nautobot/extras/migrations/0127_approval_workflow_models.py +266 -0
  429. nautobot/extras/migrations/0128_remove_job_approval_required_and_more.py +29 -0
  430. nautobot/extras/migrations/0129_jobresult_debug_log_count_jobresult_error_log_count_and_more.py +37 -0
  431. nautobot/extras/migrations/0130_jobresult_generate_log_entry_counts.py +42 -0
  432. nautobot/extras/models/__init__.py +14 -3
  433. nautobot/extras/models/approvals.py +556 -0
  434. nautobot/extras/models/change_logging.py +1 -0
  435. nautobot/extras/models/contacts.py +2 -0
  436. nautobot/extras/models/customfields.py +57 -22
  437. nautobot/extras/models/datasources.py +21 -0
  438. nautobot/extras/models/groups.py +2 -0
  439. nautobot/extras/models/jobs.py +122 -39
  440. nautobot/extras/models/metadata.py +2 -3
  441. nautobot/extras/models/mixins.py +129 -1
  442. nautobot/extras/models/models.py +22 -14
  443. nautobot/extras/models/relationships.py +47 -10
  444. nautobot/extras/models/secrets.py +1 -0
  445. nautobot/extras/models/statuses.py +0 -15
  446. nautobot/extras/models/tags.py +1 -1
  447. nautobot/extras/navigation.py +42 -15
  448. nautobot/extras/plugins/__init__.py +33 -55
  449. nautobot/extras/plugins/marketplace_manifest.yml +1 -23
  450. nautobot/extras/plugins/tables.py +8 -6
  451. nautobot/extras/plugins/urls.py +2 -21
  452. nautobot/extras/plugins/utils.py +1 -33
  453. nautobot/extras/plugins/validators.py +10 -10
  454. nautobot/extras/plugins/views.py +1 -5
  455. nautobot/extras/querysets.py +17 -21
  456. nautobot/extras/signals.py +23 -8
  457. nautobot/extras/tables.py +420 -99
  458. nautobot/extras/templates/extras/approval_dashboard.html +15 -0
  459. nautobot/extras/templates/extras/approval_workflow/approve.html +11 -0
  460. nautobot/extras/templates/extras/approval_workflow/comment.html +9 -0
  461. nautobot/extras/templates/extras/approval_workflow/deny.html +10 -0
  462. nautobot/extras/templates/extras/approvalworkflowdefinition_update.html +77 -0
  463. nautobot/extras/templates/extras/approvalworkflowstage_retrieve.html +29 -0
  464. nautobot/extras/templates/extras/configcontext_update.html +12 -12
  465. nautobot/extras/templates/extras/configcontextschema.html +1 -1
  466. nautobot/extras/templates/extras/configcontextschema_retrieve.html +9 -9
  467. nautobot/extras/templates/extras/configcontextschema_update.html +6 -6
  468. nautobot/extras/templates/extras/configcontextschema_validation.html +2 -2
  469. nautobot/extras/templates/extras/customfield_update.html +12 -12
  470. nautobot/extras/templates/extras/dynamicgroup.html +1 -1
  471. nautobot/extras/templates/extras/dynamicgroup_edit.html +1 -1
  472. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +17 -17
  473. nautobot/extras/templates/extras/dynamicgroup_update.html +24 -24
  474. nautobot/extras/templates/extras/externalintegration_update.html +6 -6
  475. nautobot/extras/templates/extras/gitrepository.html +1 -1
  476. nautobot/extras/templates/extras/gitrepository_object_edit.html +1 -1
  477. nautobot/extras/templates/extras/gitrepository_result.html +1 -1
  478. nautobot/extras/templates/extras/gitrepository_retrieve.html +12 -12
  479. nautobot/extras/templates/extras/gitrepository_update.html +25 -7
  480. nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -1
  481. nautobot/extras/templates/extras/inc/approval_buttons_column.html +38 -0
  482. nautobot/extras/templates/extras/inc/bulk_edit_overridable_field.html +14 -13
  483. nautobot/extras/templates/extras/inc/configcontext_format.html +11 -4
  484. nautobot/extras/templates/extras/inc/graphqlquery_execute.html +7 -7
  485. nautobot/extras/templates/extras/inc/job_label.html +5 -5
  486. nautobot/extras/templates/extras/inc/job_table.html +23 -10
  487. nautobot/extras/templates/extras/inc/job_tiles.html +33 -21
  488. nautobot/extras/templates/extras/inc/jobresult.html +6 -6
  489. nautobot/extras/templates/extras/inc/json_format.html +11 -4
  490. nautobot/extras/templates/extras/inc/object_contact_header.html +6 -6
  491. nautobot/extras/templates/extras/inc/overridable_field.html +16 -15
  492. nautobot/extras/templates/extras/inc/panel_approvalworkflowstage.html +34 -0
  493. nautobot/extras/templates/extras/inc/panel_changelog.html +9 -9
  494. nautobot/extras/templates/extras/inc/panel_jobhistory.html +8 -6
  495. nautobot/extras/templates/extras/inc/tags_panel.html +3 -3
  496. nautobot/extras/templates/extras/job.html +154 -155
  497. nautobot/extras/templates/extras/job_approval_confirmation.html +4 -27
  498. nautobot/extras/templates/extras/job_bulk_edit.html +18 -1
  499. nautobot/extras/templates/extras/job_detail.html +1 -1
  500. nautobot/extras/templates/extras/job_edit.html +69 -64
  501. nautobot/extras/templates/extras/job_list.html +37 -60
  502. nautobot/extras/templates/extras/jobresult.html +1 -1
  503. nautobot/extras/templates/extras/jobresult_retrieve.html +17 -17
  504. nautobot/extras/templates/extras/marketplace.html +62 -71
  505. nautobot/extras/templates/extras/metadatatype_create.html +9 -9
  506. nautobot/extras/templates/extras/note.html +1 -1
  507. nautobot/extras/templates/extras/object_approvalworkflow.html +36 -0
  508. nautobot/extras/templates/extras/object_assign_contact_or_team.html +16 -7
  509. nautobot/extras/templates/extras/object_configcontext.html +20 -20
  510. nautobot/extras/templates/extras/object_new_contact.html +6 -6
  511. nautobot/extras/templates/extras/object_new_team.html +6 -6
  512. nautobot/extras/templates/extras/objectchange.html +1 -1
  513. nautobot/extras/templates/extras/objectchange_retrieve.html +37 -56
  514. nautobot/extras/templates/extras/plugin_detail.html +40 -41
  515. nautobot/extras/templates/extras/plugins_list.html +23 -38
  516. nautobot/extras/templates/extras/plugins_tiles.html +28 -28
  517. nautobot/extras/templates/extras/role_retrieve.html +112 -48
  518. nautobot/extras/templates/extras/scheduledjob.html +25 -28
  519. nautobot/extras/templates/extras/secret_create.html +11 -11
  520. nautobot/extras/templates/extras/secretsgroup_update.html +6 -6
  521. nautobot/extras/templates/extras/staticgroupassociation_retrieve.html +3 -3
  522. nautobot/extras/templates/extras/status.html +1 -1
  523. nautobot/extras/templates/extras/tag.html +1 -1
  524. nautobot/extras/templates/extras/tag_update.html +3 -3
  525. nautobot/extras/templates/extras/templatetags/log_level.html +1 -1
  526. nautobot/extras/templates/extras/templatetags/plugin_object_detail_tabs.html +2 -2
  527. nautobot/extras/templates/extras/webhook.html +12 -12
  528. nautobot/extras/templatetags/approvals.py +19 -0
  529. nautobot/extras/templatetags/custom_links.py +12 -12
  530. nautobot/extras/templatetags/job_buttons.py +14 -12
  531. nautobot/extras/test_jobs/invalid_import.py +9 -0
  532. nautobot/extras/test_jobs/log_counts_by_level.py +23 -0
  533. nautobot/extras/test_jobs/missing_import.py +11 -0
  534. nautobot/extras/tests/integration/test_computedfields.py +5 -8
  535. nautobot/extras/tests/integration/test_configcontextschema.py +43 -48
  536. nautobot/extras/tests/integration/test_customfields.py +33 -33
  537. nautobot/extras/tests/integration/test_dynamicgroups.py +5 -10
  538. nautobot/extras/tests/integration/test_jobs.py +2 -4
  539. nautobot/extras/tests/integration/test_notes.py +3 -9
  540. nautobot/extras/tests/integration/test_plugin_banner.py +3 -0
  541. nautobot/extras/tests/integration/test_plugins.py +35 -27
  542. nautobot/extras/tests/integration/test_relationships.py +7 -11
  543. nautobot/extras/tests/integration/test_tagfilter.py +3 -11
  544. nautobot/extras/tests/test_api.py +786 -242
  545. nautobot/extras/tests/test_approvals.py +715 -0
  546. nautobot/extras/tests/test_changelog.py +18 -14
  547. nautobot/extras/tests/test_customfields.py +14 -13
  548. nautobot/extras/tests/test_datasources.py +1 -1
  549. nautobot/extras/tests/test_dynamicgroups.py +9 -4
  550. nautobot/extras/tests/test_filters.py +443 -13
  551. nautobot/extras/tests/test_forms.py +18 -57
  552. nautobot/extras/tests/test_jobs.py +25 -4
  553. nautobot/extras/tests/test_migrations.py +81 -1
  554. nautobot/extras/tests/test_models.py +378 -47
  555. nautobot/extras/tests/test_plugins.py +47 -13
  556. nautobot/extras/tests/test_relationships.py +7 -2
  557. nautobot/extras/tests/test_utils.py +2 -0
  558. nautobot/extras/tests/test_views.py +780 -493
  559. nautobot/extras/urls.py +36 -12
  560. nautobot/extras/utils.py +58 -12
  561. nautobot/extras/views.py +668 -209
  562. nautobot/ipam/factory.py +7 -0
  563. nautobot/ipam/filter_mixins.py +38 -0
  564. nautobot/ipam/filters.py +35 -71
  565. nautobot/ipam/formfields.py +1 -1
  566. nautobot/ipam/forms.py +6 -3
  567. nautobot/ipam/migrations/0030_ipam__namespaces.py +13 -0
  568. nautobot/ipam/migrations/0031_ipam___data_migrations.py +4 -1
  569. nautobot/ipam/migrations/0054_namespace_tenant.py +25 -0
  570. nautobot/ipam/models.py +29 -2
  571. nautobot/ipam/navigation.py +3 -1
  572. nautobot/ipam/querysets.py +1 -2
  573. nautobot/ipam/tables.py +26 -17
  574. nautobot/ipam/templates/ipam/inc/ipadress_edit_header.html +6 -6
  575. nautobot/ipam/templates/ipam/inc/service.html +8 -8
  576. nautobot/ipam/templates/ipam/inc/toggle_available.html +10 -10
  577. nautobot/ipam/templates/ipam/inc/vlangroup_header.html +3 -2
  578. nautobot/ipam/templates/ipam/ipaddress.html +27 -13
  579. nautobot/ipam/templates/ipam/ipaddress_assign.html +31 -24
  580. nautobot/ipam/templates/ipam/ipaddress_bulk_add.html +3 -3
  581. nautobot/ipam/templates/ipam/ipaddress_edit.html +9 -9
  582. nautobot/ipam/templates/ipam/ipaddress_interfaces.html +7 -9
  583. nautobot/ipam/templates/ipam/ipaddress_merge.html +195 -186
  584. nautobot/ipam/templates/ipam/ipaddress_vm_interfaces.html +7 -9
  585. nautobot/ipam/templates/ipam/ipaddresstointerface_retrieve.html +7 -5
  586. nautobot/ipam/templates/ipam/namespace_ip_addresses.html +1 -1
  587. nautobot/ipam/templates/ipam/namespace_prefixes.html +1 -1
  588. nautobot/ipam/templates/ipam/namespace_update.html +15 -0
  589. nautobot/ipam/templates/ipam/namespace_vrfs.html +1 -1
  590. nautobot/ipam/templates/ipam/prefix_create.html +9 -9
  591. nautobot/ipam/templates/ipam/prefix_list.html +15 -14
  592. nautobot/ipam/templates/ipam/prefix_retrieve.html +0 -1
  593. nautobot/ipam/templates/ipam/vlan.html +1 -1
  594. nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
  595. nautobot/ipam/templates/ipam/vlan_update.html +6 -6
  596. nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
  597. nautobot/ipam/templates/ipam/vrf_edit.html +15 -15
  598. nautobot/ipam/tests/integration/test_prefixes.py +5 -13
  599. nautobot/ipam/tests/migration/test_migrations.py +89 -0
  600. nautobot/ipam/tests/test_api.py +20 -7
  601. nautobot/ipam/tests/test_filters.py +10 -0
  602. nautobot/ipam/tests/test_forms.py +1 -1
  603. nautobot/ipam/tests/test_models.py +1 -1
  604. nautobot/ipam/tests/test_tables.py +1 -2
  605. nautobot/ipam/tests/test_utils.py +1 -1
  606. nautobot/ipam/tests/test_views.py +24 -21
  607. nautobot/ipam/ui.py +0 -17
  608. nautobot/ipam/utils/migrations.py +16 -2
  609. nautobot/ipam/utils/testing.py +9 -3
  610. nautobot/ipam/views.py +49 -7
  611. nautobot/project-static/dist/css/graphql-libraries.css +655 -0
  612. nautobot/project-static/dist/css/graphql-libraries.css.map +1 -0
  613. nautobot/project-static/dist/css/materialdesignicons.css +3 -0
  614. nautobot/project-static/dist/css/materialdesignicons.css.map +1 -0
  615. nautobot/project-static/dist/css/nautobot.css +13 -0
  616. nautobot/project-static/dist/css/nautobot.css.map +1 -0
  617. nautobot/project-static/dist/js/graphql-libraries.js +3 -0
  618. nautobot/project-static/dist/js/graphql-libraries.js.LICENSE.txt +62 -0
  619. nautobot/project-static/dist/js/graphql-libraries.js.map +1 -0
  620. nautobot/project-static/dist/js/libraries.js +3 -0
  621. nautobot/project-static/dist/js/libraries.js.LICENSE.txt +65 -0
  622. nautobot/project-static/dist/js/libraries.js.map +1 -0
  623. nautobot/project-static/dist/js/materialdesignicons.js +0 -0
  624. nautobot/project-static/dist/js/nautobot-graphiql.js +2 -0
  625. nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -0
  626. nautobot/project-static/dist/js/nautobot.js +2 -0
  627. nautobot/project-static/dist/js/nautobot.js.map +1 -0
  628. nautobot/project-static/fonts/Montserrat-v30-Bold.woff2 +0 -0
  629. nautobot/project-static/fonts/Montserrat-v30-Light.woff2 +0 -0
  630. nautobot/project-static/fonts/Montserrat-v30-Regular.woff2 +0 -0
  631. nautobot/project-static/fonts/Roboto-v48-Bold.woff2 +0 -0
  632. nautobot/project-static/fonts/Roboto-v48-Light.woff2 +0 -0
  633. nautobot/project-static/fonts/Roboto-v48-Regular.woff2 +0 -0
  634. nautobot/project-static/img/jinja_logo.svg +21 -92
  635. nautobot/project-static/js/cabletrace.js +1 -1
  636. nautobot/project-static/js/editor.js +4 -4
  637. nautobot/project-static/js/forms.js +67 -717
  638. nautobot/project-static/js/job_result.js +2 -2
  639. nautobot/project-static/nautobot-icons/360-degrees.svg +3 -0
  640. nautobot/project-static/nautobot-icons/arrow-decision.svg +3 -0
  641. nautobot/project-static/nautobot-icons/arrows-expand-rec.svg +3 -0
  642. nautobot/project-static/nautobot-icons/arrows-move-2-rec.svg +3 -0
  643. nautobot/project-static/nautobot-icons/arrows-move-rec.svg +3 -0
  644. nautobot/project-static/nautobot-icons/atom.svg +3 -0
  645. nautobot/project-static/nautobot-icons/battery-3.svg +3 -0
  646. nautobot/project-static/nautobot-icons/branch.svg +3 -0
  647. nautobot/project-static/nautobot-icons/briefcase-2.svg +3 -0
  648. nautobot/project-static/nautobot-icons/cable-data-2.svg +3 -0
  649. nautobot/project-static/nautobot-icons/cable-data.svg +3 -0
  650. nautobot/project-static/nautobot-icons/cast.svg +3 -0
  651. nautobot/project-static/nautobot-icons/check-circle.svg +3 -0
  652. nautobot/project-static/nautobot-icons/checkbox-circle.svg +3 -0
  653. nautobot/project-static/nautobot-icons/checkbox-rec.svg +3 -0
  654. nautobot/project-static/nautobot-icons/cloud-check.svg +3 -0
  655. nautobot/project-static/nautobot-icons/cloud-lightning.svg +3 -0
  656. nautobot/project-static/nautobot-icons/cloud-upload.svg +3 -0
  657. nautobot/project-static/nautobot-icons/cloud.svg +3 -0
  658. nautobot/project-static/nautobot-icons/compass.svg +3 -0
  659. nautobot/project-static/nautobot-icons/control-panel.svg +3 -0
  660. nautobot/project-static/nautobot-icons/credit-card.svg +3 -0
  661. nautobot/project-static/nautobot-icons/device-lifecycle.svg +3 -0
  662. nautobot/project-static/nautobot-icons/direction.svg +3 -0
  663. nautobot/project-static/nautobot-icons/elements.svg +3 -0
  664. nautobot/project-static/nautobot-icons/extensibility.svg +3 -0
  665. nautobot/project-static/nautobot-icons/globe-2.svg +3 -0
  666. nautobot/project-static/nautobot-icons/globe.svg +3 -0
  667. nautobot/project-static/nautobot-icons/hammer.svg +3 -0
  668. nautobot/project-static/nautobot-icons/history.svg +3 -0
  669. nautobot/project-static/nautobot-icons/ip.svg +3 -0
  670. nautobot/project-static/nautobot-icons/laptop.svg +3 -0
  671. nautobot/project-static/nautobot-icons/lightning.svg +3 -0
  672. nautobot/project-static/nautobot-icons/list-unordered.svg +3 -0
  673. nautobot/project-static/nautobot-icons/map-view.svg +3 -0
  674. nautobot/project-static/nautobot-icons/organization.svg +3 -0
  675. nautobot/project-static/nautobot-icons/pin-2.svg +3 -0
  676. nautobot/project-static/nautobot-icons/pin-3.svg +3 -0
  677. nautobot/project-static/nautobot-icons/plug.svg +3 -0
  678. nautobot/project-static/nautobot-icons/refresh-cw.svg +3 -0
  679. nautobot/project-static/nautobot-icons/rocket-2.svg +3 -0
  680. nautobot/project-static/nautobot-icons/rotate-cw.svg +3 -0
  681. nautobot/project-static/nautobot-icons/route.svg +3 -0
  682. nautobot/project-static/nautobot-icons/secrets.svg +3 -0
  683. nautobot/project-static/nautobot-icons/security.svg +3 -0
  684. nautobot/project-static/nautobot-icons/server-2.svg +3 -0
  685. nautobot/project-static/nautobot-icons/server.svg +3 -0
  686. nautobot/project-static/nautobot-icons/share.svg +3 -0
  687. nautobot/project-static/nautobot-icons/shield-check.svg +3 -0
  688. nautobot/project-static/nautobot-icons/sitemap-outline.svg +3 -0
  689. nautobot/project-static/nautobot-icons/sliders-vert-2.svg +3 -0
  690. nautobot/project-static/nautobot-icons/sliders-vert.svg +3 -0
  691. nautobot/project-static/nautobot-icons/star-filled.svg +3 -0
  692. nautobot/project-static/nautobot-icons/star.svg +3 -0
  693. nautobot/project-static/nautobot-icons/transform.svg +3 -0
  694. nautobot/project-static/nautobot-icons/wifi.svg +3 -0
  695. nautobot/tenancy/api/serializers.py +1 -0
  696. nautobot/tenancy/api/views.py +2 -1
  697. nautobot/tenancy/{filters/__init__.py → filters.py} +2 -10
  698. nautobot/tenancy/navigation.py +3 -1
  699. nautobot/tenancy/templates/tenancy/tenant_create.html +6 -6
  700. nautobot/tenancy/tests/test_filters.py +0 -2
  701. nautobot/tenancy/views.py +2 -1
  702. nautobot/ui/.gitignore +137 -0
  703. nautobot/ui/.node-version +1 -0
  704. nautobot/ui/.prettierignore +3 -0
  705. nautobot/ui/eslint.config.js +33 -0
  706. nautobot/ui/package-lock.json +6594 -0
  707. nautobot/ui/package.json +67 -0
  708. nautobot/ui/prettier.config.js +9 -0
  709. nautobot/ui/src/js/collapse.js +69 -0
  710. nautobot/ui/src/js/cookie.js +31 -0
  711. nautobot/ui/src/js/draggable.js +101 -0
  712. nautobot/ui/src/js/drawer.js +106 -0
  713. nautobot/ui/src/js/form.js +23 -0
  714. nautobot/ui/src/js/history.js +51 -0
  715. nautobot/ui/src/js/nautobot-graphiql.js +19 -0
  716. nautobot/ui/src/js/nautobot.js +128 -0
  717. nautobot/ui/src/js/search.js +274 -0
  718. nautobot/ui/src/js/select2.js +318 -0
  719. nautobot/ui/src/js/sidenav.js +87 -0
  720. nautobot/ui/src/js/tabs.js +139 -0
  721. nautobot/ui/src/js/theme.js +104 -0
  722. nautobot/ui/src/js/utils.js +54 -0
  723. nautobot/ui/src/scss/colors.scss +58 -0
  724. nautobot/ui/src/scss/nautobot.scss +2471 -0
  725. nautobot/ui/webpack.config.js +148 -0
  726. nautobot/users/apps.py +3 -0
  727. nautobot/users/filters.py +7 -11
  728. nautobot/users/forms.py +10 -0
  729. nautobot/users/models.py +8 -0
  730. nautobot/users/templates/users/advanced_settings_edit.html +31 -21
  731. nautobot/users/templates/users/api_tokens.html +61 -51
  732. nautobot/users/templates/users/base.html +23 -31
  733. nautobot/users/templates/users/change_password.html +29 -19
  734. nautobot/users/templates/users/preferences.html +55 -45
  735. nautobot/users/templates/users/profile.html +45 -14
  736. nautobot/users/tests/test_api.py +4 -0
  737. nautobot/users/urls.py +2 -0
  738. nautobot/users/views.py +70 -2
  739. nautobot/virtualization/api/views.py +1 -1
  740. nautobot/virtualization/filters.py +18 -32
  741. nautobot/virtualization/forms.py +22 -59
  742. nautobot/virtualization/models.py +1 -19
  743. nautobot/virtualization/navigation.py +3 -1
  744. nautobot/virtualization/tables.py +10 -6
  745. nautobot/virtualization/templates/virtualization/cluster.html +13 -13
  746. nautobot/virtualization/templates/virtualization/cluster_edit.html +6 -6
  747. nautobot/virtualization/templates/virtualization/inc/virtualmachine_vminterface_filter.html +1 -1
  748. nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
  749. nautobot/virtualization/templates/virtualization/virtualmachine_component_add.html +24 -16
  750. nautobot/virtualization/templates/virtualization/virtualmachine_edit.html +1 -1
  751. nautobot/virtualization/templates/virtualization/virtualmachine_list.html +4 -4
  752. nautobot/virtualization/templates/virtualization/virtualmachine_update.html +27 -25
  753. nautobot/virtualization/templates/virtualization/vminterface.html +5 -5
  754. nautobot/virtualization/templates/virtualization/vminterface_edit.html +27 -11
  755. nautobot/virtualization/tests/test_api.py +3 -0
  756. nautobot/virtualization/tests/test_models.py +20 -5
  757. nautobot/virtualization/tests/test_views.py +3 -5
  758. nautobot/virtualization/urls.py +0 -11
  759. nautobot/virtualization/views.py +5 -122
  760. nautobot/vpn/__init__.py +0 -0
  761. nautobot/vpn/api/serializers.py +113 -0
  762. nautobot/vpn/api/urls.py +19 -0
  763. nautobot/vpn/api/views.py +70 -0
  764. nautobot/vpn/apps.py +8 -0
  765. nautobot/vpn/choices.py +171 -0
  766. nautobot/vpn/factory.py +209 -0
  767. nautobot/vpn/filters.py +233 -0
  768. nautobot/vpn/forms.py +486 -0
  769. nautobot/vpn/homepage.py +19 -0
  770. nautobot/vpn/migrations/0001_initial.py +541 -0
  771. nautobot/vpn/migrations/0002_populate_defaults.py +199 -0
  772. nautobot/vpn/migrations/__init__.py +0 -0
  773. nautobot/vpn/models.py +527 -0
  774. nautobot/vpn/navigation.py +98 -0
  775. nautobot/vpn/tables.py +380 -0
  776. nautobot/vpn/templates/vpn/vpnprofile.html +2 -0
  777. nautobot/vpn/templates/vpn/vpnprofile_create.html +150 -0
  778. nautobot/vpn/tests/__init__.py +0 -0
  779. nautobot/vpn/tests/test_api.py +341 -0
  780. nautobot/vpn/tests/test_filters.py +139 -0
  781. nautobot/vpn/tests/test_forms.py +294 -0
  782. nautobot/vpn/tests/test_models.py +97 -0
  783. nautobot/vpn/tests/test_views.py +281 -0
  784. nautobot/vpn/urls.py +16 -0
  785. nautobot/vpn/views.py +437 -0
  786. nautobot/wireless/filters.py +0 -8
  787. nautobot/wireless/navigation.py +3 -1
  788. nautobot/wireless/templates/wireless/wirelessnetwork_create.html +6 -6
  789. nautobot/wireless/tests/integration/test_radio_profile.py +3 -7
  790. nautobot/wireless/tests/test_api.py +1 -1
  791. {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/METADATA +5 -4
  792. {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/RECORD +802 -707
  793. {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/entry_points.txt +1 -0
  794. nautobot/core/management/commands/check_job_approval_status.py +0 -47
  795. nautobot/core/templates/search_form.html +0 -9
  796. nautobot/core/templates/utilities/templatetags/filter_form_modal.html +0 -87
  797. nautobot/dcim/filters/mixins.py +0 -354
  798. nautobot/extras/templates/extras/job_approval_request.html +0 -134
  799. nautobot/extras/templates/extras/scheduled_jobs_approval_queue_list.html +0 -28
  800. nautobot/ipam/mixins.py +0 -32
  801. nautobot/ipam/templates/ipam/inc/prefix_header_extra_content_table.html +0 -4
  802. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css +0 -587
  803. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css.map +0 -1
  804. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css +0 -6
  805. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css.map +0 -1
  806. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.css +0 -6865
  807. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.css.map +0 -1
  808. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css +0 -6
  809. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css.map +0 -1
  810. nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.eot +0 -0
  811. nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.svg +0 -288
  812. nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.ttf +0 -0
  813. nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.woff +0 -0
  814. nautobot/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.woff2 +0 -0
  815. nautobot/project-static/bootstrap-3.4.1-dist/js/bootstrap.js +0 -2580
  816. nautobot/project-static/bootstrap-3.4.1-dist/js/bootstrap.min.js +0 -6
  817. nautobot/project-static/bootstrap-3.4.1-dist/js/npm.js +0 -13
  818. nautobot/project-static/clipboard.js-2.0.9/clipboard.min.js +0 -7
  819. nautobot/project-static/css/base.css +0 -1040
  820. nautobot/project-static/css/dark.css +0 -282
  821. nautobot/project-static/flatpickr-4.6.9/flatpickr.min.js +0 -2
  822. nautobot/project-static/flatpickr-4.6.9/themes/light.min.css +0 -1
  823. nautobot/project-static/graphiql-1.5.16/graphiql.min.css +0 -12
  824. nautobot/project-static/graphiql-1.5.16/graphiql.min.js +0 -11
  825. nautobot/project-static/highlight.js-11.9.0/github-dark.min.css +0 -10
  826. nautobot/project-static/highlight.js-11.9.0/github.min.css +0 -10
  827. nautobot/project-static/highlight.js-11.9.0/highlight.min.js +0 -378
  828. nautobot/project-static/jquery/jquery-3.7.1.min.js +0 -2
  829. nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_444444_256x240.png +0 -0
  830. nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_555555_256x240.png +0 -0
  831. nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_777620_256x240.png +0 -0
  832. nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_777777_256x240.png +0 -0
  833. nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_cc0000_256x240.png +0 -0
  834. nautobot/project-static/jquery-ui-1.13.2/images/ui-icons_ffffff_256x240.png +0 -0
  835. nautobot/project-static/jquery-ui-1.13.2/jquery-ui.min.css +0 -7
  836. nautobot/project-static/jquery-ui-1.13.2/jquery-ui.min.js +0 -6
  837. nautobot/project-static/jquery-ui-1.13.2/jquery-ui.structure.min.css +0 -5
  838. nautobot/project-static/jquery-ui-1.13.2/jquery-ui.theme.min.css +0 -5
  839. nautobot/project-static/js/homepage_layout.js +0 -182
  840. nautobot/project-static/js/nav_menu.js +0 -250
  841. nautobot/project-static/js/theme.js +0 -133
  842. nautobot/project-static/materialdesignicons-7.4.47/LICENSE +0 -20
  843. nautobot/project-static/materialdesignicons-7.4.47/css/materialdesignicons.min.css +0 -3
  844. nautobot/project-static/react-16.14.0/react.production.min.js +0 -32
  845. nautobot/project-static/react-dom-16.14.0/react-dom.production.min.js +0 -239
  846. nautobot/project-static/select2-4.0.13/i18n/af.js +0 -3
  847. nautobot/project-static/select2-4.0.13/i18n/ar.js +0 -3
  848. nautobot/project-static/select2-4.0.13/i18n/az.js +0 -3
  849. nautobot/project-static/select2-4.0.13/i18n/bg.js +0 -3
  850. nautobot/project-static/select2-4.0.13/i18n/bn.js +0 -3
  851. nautobot/project-static/select2-4.0.13/i18n/bs.js +0 -3
  852. nautobot/project-static/select2-4.0.13/i18n/ca.js +0 -3
  853. nautobot/project-static/select2-4.0.13/i18n/cs.js +0 -3
  854. nautobot/project-static/select2-4.0.13/i18n/da.js +0 -3
  855. nautobot/project-static/select2-4.0.13/i18n/de.js +0 -3
  856. nautobot/project-static/select2-4.0.13/i18n/dsb.js +0 -3
  857. nautobot/project-static/select2-4.0.13/i18n/el.js +0 -3
  858. nautobot/project-static/select2-4.0.13/i18n/en.js +0 -3
  859. nautobot/project-static/select2-4.0.13/i18n/es.js +0 -3
  860. nautobot/project-static/select2-4.0.13/i18n/et.js +0 -3
  861. nautobot/project-static/select2-4.0.13/i18n/eu.js +0 -3
  862. nautobot/project-static/select2-4.0.13/i18n/fa.js +0 -3
  863. nautobot/project-static/select2-4.0.13/i18n/fi.js +0 -3
  864. nautobot/project-static/select2-4.0.13/i18n/fr.js +0 -3
  865. nautobot/project-static/select2-4.0.13/i18n/gl.js +0 -3
  866. nautobot/project-static/select2-4.0.13/i18n/he.js +0 -3
  867. nautobot/project-static/select2-4.0.13/i18n/hi.js +0 -3
  868. nautobot/project-static/select2-4.0.13/i18n/hr.js +0 -3
  869. nautobot/project-static/select2-4.0.13/i18n/hsb.js +0 -3
  870. nautobot/project-static/select2-4.0.13/i18n/hu.js +0 -3
  871. nautobot/project-static/select2-4.0.13/i18n/hy.js +0 -3
  872. nautobot/project-static/select2-4.0.13/i18n/id.js +0 -3
  873. nautobot/project-static/select2-4.0.13/i18n/is.js +0 -3
  874. nautobot/project-static/select2-4.0.13/i18n/it.js +0 -3
  875. nautobot/project-static/select2-4.0.13/i18n/ja.js +0 -3
  876. nautobot/project-static/select2-4.0.13/i18n/ka.js +0 -3
  877. nautobot/project-static/select2-4.0.13/i18n/km.js +0 -3
  878. nautobot/project-static/select2-4.0.13/i18n/ko.js +0 -3
  879. nautobot/project-static/select2-4.0.13/i18n/lt.js +0 -3
  880. nautobot/project-static/select2-4.0.13/i18n/lv.js +0 -3
  881. nautobot/project-static/select2-4.0.13/i18n/mk.js +0 -3
  882. nautobot/project-static/select2-4.0.13/i18n/ms.js +0 -3
  883. nautobot/project-static/select2-4.0.13/i18n/nb.js +0 -3
  884. nautobot/project-static/select2-4.0.13/i18n/ne.js +0 -3
  885. nautobot/project-static/select2-4.0.13/i18n/nl.js +0 -3
  886. nautobot/project-static/select2-4.0.13/i18n/pl.js +0 -3
  887. nautobot/project-static/select2-4.0.13/i18n/ps.js +0 -3
  888. nautobot/project-static/select2-4.0.13/i18n/pt-BR.js +0 -3
  889. nautobot/project-static/select2-4.0.13/i18n/pt.js +0 -3
  890. nautobot/project-static/select2-4.0.13/i18n/ro.js +0 -3
  891. nautobot/project-static/select2-4.0.13/i18n/ru.js +0 -3
  892. nautobot/project-static/select2-4.0.13/i18n/sk.js +0 -3
  893. nautobot/project-static/select2-4.0.13/i18n/sl.js +0 -3
  894. nautobot/project-static/select2-4.0.13/i18n/sq.js +0 -3
  895. nautobot/project-static/select2-4.0.13/i18n/sr-Cyrl.js +0 -3
  896. nautobot/project-static/select2-4.0.13/i18n/sr.js +0 -3
  897. nautobot/project-static/select2-4.0.13/i18n/sv.js +0 -3
  898. nautobot/project-static/select2-4.0.13/i18n/th.js +0 -3
  899. nautobot/project-static/select2-4.0.13/i18n/tk.js +0 -3
  900. nautobot/project-static/select2-4.0.13/i18n/tr.js +0 -3
  901. nautobot/project-static/select2-4.0.13/i18n/uk.js +0 -3
  902. nautobot/project-static/select2-4.0.13/i18n/vi.js +0 -3
  903. nautobot/project-static/select2-4.0.13/i18n/zh-CN.js +0 -3
  904. nautobot/project-static/select2-4.0.13/i18n/zh-TW.js +0 -3
  905. nautobot/project-static/select2-4.0.13/select2.min.css +0 -1
  906. nautobot/project-static/select2-4.0.13/select2.min.js +0 -2
  907. nautobot/project-static/select2-bootstrap-0.1.0-beta.10/select2-bootstrap.min.css +0 -7
  908. nautobot/project-static/subscriptions-transport-ws-0.9.18/client.min.js +0 -8
  909. nautobot/project-static/whatwg-fetch-3.6.2/fetch.umd.min.js +0 -8
  910. nautobot/virtualization/templates/virtualization/cluster_add_devices.html +0 -37
  911. /nautobot/extras/{filters/customfields.py → filter_mixins_customfields.py} +0 -0
  912. /nautobot/project-static/{materialdesignicons-7.4.47/fonts/materialdesignicons-webfont.ttf → dist/1fcc36272ea3e53d0031.ttf} +0 -0
  913. /nautobot/project-static/{materialdesignicons-7.4.47/fonts/materialdesignicons-webfont.eot → dist/2146c3c82b553977abc7.eot} +0 -0
  914. /nautobot/project-static/{materialdesignicons-7.4.47/fonts/materialdesignicons-webfont.woff → dist/e55a20c80650829ec5fd.woff} +0 -0
  915. /nautobot/project-static/{materialdesignicons-7.4.47/fonts/materialdesignicons-webfont.woff2 → dist/ec024da790d2972da002.woff2} +0 -0
  916. /nautobot/tenancy/{filters/mixins.py → filter_mixins.py} +0 -0
  917. {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/LICENSE.txt +0 -0
  918. {nautobot-2.4.21.dist-info → nautobot-3.0.0a3.dist-info}/NOTICE +0 -0
  919. {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 extract_form_failures, extract_page_body, ModelViewTestCase, TestCase, ViewTestCases
23
- from nautobot.core.testing.context import load_event_broker_override_settings
24
- from nautobot.core.testing.utils import disable_warnings, get_deletable_objects, post_data
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">Update & Dry Run</button>',
1343
- '<button type="submit" name="_update" class="btn btn-primary">Update & Sync</button>',
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">Create & Dry Run</button>',
1347
- '<button type="submit" name="_create" class="btn btn-primary">Create & Sync</button>',
1348
- '<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>',
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(response, "<strong>Global Location Default View</strong>", html=True)
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="[&quot;top&quot;]"></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(response, "<strong>User Location Default View</strong>", html=True)
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="[&quot;top&quot;]"></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(response, "<strong>User Location Default View</strong>", html=True)
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="[&quot;top&quot;]"></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
- "<strong>Global Location Default View</strong>",
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="[&quot;top&quot;]"></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
- "<strong>User Location Default View</strong>", extract_page_body(response.content.decode(response.charset))
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="[&quot;top&quot;]"></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(response, f"<strong>{sv_name}</strong>", html=True)
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(response, f"<strong>{sv_name}</strong>", html=True)
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(response, f"<strong>{sv_name}</strong>", html=True)
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="[&quot;top&quot;]"></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(response, f"<strong>{sv_name}</strong>", html=True)
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="[&quot;top&quot;]"></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 ApprovalQueueTestCase(
2336
- # It would be nice to use ViewTestCases.GetObjectViewTestCase as well,
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
- def _get_url(self, action, instance=None):
2345
- if action == "list":
2346
- return reverse("extras:scheduledjob_approval_queue_list")
2347
- if action == "view" and instance is not None:
2348
- return reverse("extras:scheduledjob_approval_request_view", kwargs={"pk": instance.pk})
2349
- raise ValueError("This override is only valid for list and view test cases")
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
- def setUp(self):
2355
- super().setUp()
2356
- self.job_model = Job.objects.get_for_class_path("dry_run.TestDryRun")
2357
- self.job_model_2 = Job.objects.get_for_class_path("fail.TestFailJob")
2358
-
2359
- ScheduledJob.objects.create(
2360
- name="test1",
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
- def test_run_now_with_args(self, _):
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
- def test_run_later(self, _):
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 test_run_job_with_sensitive_variables_and_requires_approval(self, _):
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 warning message shows in get
3345
- response = self.client.get(run_url)
3478
+ # Assert error message shows after post
3479
+ response = self.client.post(run_url, data)
3346
3480
  self.assertBodyContains(
3347
3481
  response,
3348
- "This job is flagged as possibly having sensitive variables but is also flagged as requiring approval.",
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
- # Assert run button is disabled
3352
- self.assertBodyContains(
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 is also flagged as requiring approval."
3367
- "One of these two flags must be removed before this job can be scheduled or run.",
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 test_run_job_with_approval_required_creates_scheduled_job_internal_future(self, _):
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
- self.test_pass.approval_required = True
3376
- self.test_pass.save()
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": "immediately",
3632
+ "_schedule_type": "future",
3633
+ "_schedule_name": "test",
3634
+ "_schedule_start_time": str(timezone.now() + timedelta(minutes=1)),
3635
+ "dryrun": True,
3379
3636
  }
3380
- for run_url in self.run_urls:
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:scheduledjob_approval_queue_list"),
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.name} - Change Log")
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.button_class,
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.button_class,
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
- tag = Tag.objects.filter(name=self.form_data["name"])
4164
- self.assertTrue(tag.exists())
4165
- self.assertEqual(tag[0].content_types.first(), location_content_type)
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
- tag = Tag.objects.filter(name=self.form_data["name"])
4183
- self.assertFalse(tag.exists())
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="pull-right noprint">',
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: