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,14 +1,16 @@
1
1
  from datetime import datetime, timedelta
2
2
  import tempfile
3
3
  from unittest import mock, skip
4
+ from urllib.parse import urlencode
4
5
  import uuid
5
6
  from zoneinfo import ZoneInfo
6
7
 
7
8
  from django.conf import settings
8
9
  from django.contrib.auth import get_user_model
10
+ from django.contrib.auth.models import Group
9
11
  from django.contrib.contenttypes.models import ContentType
10
12
  from django.core.files.uploadedfile import SimpleUploadedFile
11
- from django.test import override_settings
13
+ from django.test import override_settings, tag
12
14
  from django.urls import reverse
13
15
  from django.utils.timezone import make_aware, now
14
16
  from rest_framework import status
@@ -33,6 +35,7 @@ from nautobot.dcim.models import (
33
35
  from nautobot.dcim.tests import test_views
34
36
  from nautobot.extras.api.serializers import ConfigContextSerializer, JobResultSerializer
35
37
  from nautobot.extras.choices import (
38
+ ApprovalWorkflowStateChoices,
36
39
  DynamicGroupOperatorChoices,
37
40
  DynamicGroupTypeChoices,
38
41
  JobExecutionType,
@@ -48,6 +51,11 @@ from nautobot.extras.choices import (
48
51
  )
49
52
  from nautobot.extras.jobs import get_job
50
53
  from nautobot.extras.models import (
54
+ ApprovalWorkflow,
55
+ ApprovalWorkflowDefinition,
56
+ ApprovalWorkflowStage,
57
+ ApprovalWorkflowStageDefinition,
58
+ ApprovalWorkflowStageResponse,
51
59
  ComputedField,
52
60
  ConfigContext,
53
61
  ConfigContextSchema,
@@ -107,6 +115,578 @@ class AppTest(APITestCase):
107
115
  self.assertEqual(response.status_code, 200)
108
116
 
109
117
 
118
+ class ApprovalWorkflowStageTest(
119
+ APIViewTestCases.GetObjectViewTestCase,
120
+ APIViewTestCases.ListObjectsViewTestCase,
121
+ ):
122
+ model = ApprovalWorkflowStage
123
+
124
+ @classmethod
125
+ def setUpTestData(cls):
126
+ cls.user = User.objects.create(username="user1", is_active=True)
127
+ cls.approver_group_1 = Group.objects.create(name="Approver Group 1")
128
+ cls.job_model = Job.objects.get_for_class_path("pass_job.TestPassJob")
129
+ cls.job_model.enabled = True
130
+ cls.job_model.save()
131
+
132
+ cls.scheduled_jobs = [
133
+ ScheduledJob.objects.create(
134
+ name=f"TessPassJob Scheduled Job {i}",
135
+ task="pass_job.TestPassJob",
136
+ job_model=cls.job_model,
137
+ interval=JobExecutionType.TYPE_IMMEDIATELY,
138
+ user=cls.user,
139
+ start_time=now(),
140
+ )
141
+ for i in range(4)
142
+ ]
143
+ cls.scheduledjob_ct = ContentType.objects.get_for_model(ScheduledJob)
144
+
145
+ cls.approval_workflow_definitions = [
146
+ ApprovalWorkflowDefinition.objects.create(
147
+ name=f"Test Approval Workflow {i}", model_content_type=cls.scheduledjob_ct, weight=i
148
+ )
149
+ for i in range(4)
150
+ ]
151
+ cls.approval_workflows = [
152
+ ApprovalWorkflow.objects.create(
153
+ approval_workflow_definition=cls.approval_workflow_definitions[i],
154
+ object_under_review_content_type=cls.scheduledjob_ct,
155
+ object_under_review_object_id=cls.scheduled_jobs[i].pk,
156
+ current_state=ApprovalWorkflowStateChoices.PENDING,
157
+ )
158
+ for i in range(4)
159
+ ]
160
+ cls.approval_workflow_stage_definitions = [
161
+ ApprovalWorkflowStageDefinition.objects.create(
162
+ approval_workflow_definition=cls.approval_workflow_definitions[i],
163
+ sequence=i * 100,
164
+ name=f"Test Approval Workflow Stage {i} Definition",
165
+ min_approvers=1,
166
+ denial_message="Stage Denial Message",
167
+ approver_group=cls.approver_group_1,
168
+ )
169
+ for i in range(3)
170
+ ]
171
+ cls.approval_workflow_stages = [
172
+ ApprovalWorkflowStage.objects.create(
173
+ approval_workflow=cls.approval_workflows[i],
174
+ approval_workflow_stage_definition=cls.approval_workflow_stage_definitions[i],
175
+ state=ApprovalWorkflowStateChoices.PENDING,
176
+ )
177
+ for i in range(3)
178
+ ]
179
+
180
+ cls.approval_workflow_content_type_cases = [
181
+ {
182
+ "content_type": "ScheduledJob",
183
+ "object": cls.scheduled_jobs[0],
184
+ "workflow": cls.approval_workflows[0],
185
+ "stage": cls.approval_workflow_stages[0],
186
+ }
187
+ ]
188
+
189
+ def _test_workflow_stage_action_anonymous(self, action):
190
+ for case in self.approval_workflow_content_type_cases:
191
+ with self.subTest(case=case["content_type"], action=action):
192
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
193
+ response = self.client.post(url)
194
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
195
+ self.assertIn(response.data["detail"], "'Authentication credentials were not provided.'")
196
+
197
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
198
+ def test_approve_approval_workflow_stage_anonymous(self):
199
+ self._test_workflow_stage_action_anonymous("approve")
200
+
201
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
202
+ def test_deny_approval_workflow_stage_anonymous(self):
203
+ self._test_workflow_stage_action_anonymous("deny")
204
+
205
+ def _test_approval_workflow_stage_action_without_permission(self, action):
206
+ for case in self.approval_workflow_content_type_cases:
207
+ with self.subTest(case=case["content_type"], action=action):
208
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
209
+ with disable_warnings("django.request"):
210
+ response = self.client.post(url, **self.header)
211
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
212
+ self.assertIn(response.data["detail"], "You do not have permission to perform this action.")
213
+
214
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
215
+ def test_approve_approval_workflow_stage_without_permission(self):
216
+ self._test_approval_workflow_stage_action_without_permission("approve")
217
+
218
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
219
+ def test_deny_approval_workflow_stage_without_permission(self):
220
+ self._test_approval_workflow_stage_action_without_permission("deny")
221
+
222
+ def _test_approval_workflow_stage_action_without_approvalworkflow_permission(self, action):
223
+ for case in self.approval_workflow_content_type_cases:
224
+ content_type = case["content_type"]
225
+ with self.subTest(case=content_type, action=action):
226
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
227
+ self.add_permissions(f"extras.change_{content_type.lower()}")
228
+ response = self.client.post(url, **self.header)
229
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
230
+ self.assertIn(response.data["detail"], "You do not have permission to perform this action.")
231
+
232
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
233
+ def test_approve_approval_workflow_stage_without_approvalworkflow_permission(self):
234
+ self._test_approval_workflow_stage_action_without_approvalworkflow_permission("approve")
235
+
236
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
237
+ def test_deny_approval_workflow_stage_without_approvalworkflow_permission(self):
238
+ self._test_approval_workflow_stage_action_without_approvalworkflow_permission("deny")
239
+
240
+ def _test_approval_workflow_stage_action_without_change_content_type_permission(self, action):
241
+ for case in self.approval_workflow_content_type_cases:
242
+ with self.subTest(case=case["content_type"], action=action):
243
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
244
+ self.add_permissions("extras.change_approvalworkflowstage")
245
+ self.user.groups.add(self.approver_group_1)
246
+ response = self.client.post(url, **self.header)
247
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
248
+ self.assertIn(response.data["detail"], "You do not have 'change' permission on extras.scheduledjob.")
249
+
250
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
251
+ def test_approve_approval_workflow_stage_without_change_content_type_permission(self):
252
+ self._test_approval_workflow_stage_action_without_change_content_type_permission("approve")
253
+
254
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
255
+ def test_deny_approval_workflow_stage_without_change_content_type_permission(self):
256
+ self._test_approval_workflow_stage_action_without_change_content_type_permission("deny")
257
+
258
+ def _test_approval_workflow_stage_action_without_approver_group_membership(self, action):
259
+ for case in self.approval_workflow_content_type_cases:
260
+ content_type = case["content_type"]
261
+ with self.subTest(case=content_type, action=action):
262
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
263
+ self.add_permissions("extras.change_approvalworkflowstage", f"extras.change_{content_type.lower()}")
264
+ response = self.client.post(url, **self.header)
265
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
266
+ self.assertIn(response.data["detail"], "You do not have permission to approve this stage.")
267
+
268
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
269
+ def test_approve_approval_workflow_stage_without_approver_group_membership(self):
270
+ self._test_approval_workflow_stage_action_without_approver_group_membership("approve")
271
+
272
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
273
+ def test_deny_approval_workflow_stage_without_approver_group_membership(self):
274
+ self._test_approval_workflow_stage_action_without_approver_group_membership("deny")
275
+
276
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
277
+ def _test_approval_workflow_stage_with_dismatch_constraints(self, action):
278
+ for case in self.approval_workflow_content_type_cases:
279
+ content_type = case["content_type"]
280
+ with self.subTest(case=content_type, action=action):
281
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
282
+ self.add_permissions(
283
+ "extras.change_approvalworkflowstage",
284
+ f"extras.change_{content_type.lower()}",
285
+ constraints={"pk": self.approval_workflow_stages[1].pk},
286
+ )
287
+ self.user.groups.add(self.approver_group_1)
288
+ response = self.client.post(url, **self.header)
289
+ self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
290
+
291
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
292
+ def test_approve_approval_workflow_stage_with_dismatch_contraints(self):
293
+ self._test_approval_workflow_stage_with_dismatch_constraints("approve")
294
+
295
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
296
+ def test_deny_approval_workflow_stage_with_dismatch_contraints(self):
297
+ self._test_approval_workflow_stage_with_dismatch_constraints("deny")
298
+
299
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
300
+ def _test_approval_workflow_stage_with_match_constraints(self, action):
301
+ for case in self.approval_workflow_content_type_cases:
302
+ content_type = case["content_type"]
303
+ with self.subTest(case=content_type, action=action):
304
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
305
+ self.add_permissions(
306
+ "extras.change_approvalworkflowstage",
307
+ f"extras.change_{content_type.lower()}",
308
+ constraints={"pk": case["stage"].pk},
309
+ )
310
+ self.user.groups.add(self.approver_group_1)
311
+ response = self.client.post(url, **self.header)
312
+ self.assertHttpStatus(response, status.HTTP_200_OK)
313
+
314
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
315
+ def test_approve_approval_workflow_stage_with_match_contraints(self):
316
+ self._test_approval_workflow_stage_with_match_constraints("approve")
317
+
318
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
319
+ def test_deny_approval_workflow_stage_with_match_contraints(self):
320
+ self._test_approval_workflow_stage_with_match_constraints("deny")
321
+
322
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
323
+ def _test_approval_workflow_stage_with_dismatch_content_type_constraints(self, action):
324
+ for case in self.approval_workflow_content_type_cases:
325
+ content_type = case["content_type"]
326
+ with self.subTest(case=content_type, action=action):
327
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
328
+ self.add_permissions(
329
+ "extras.change_approvalworkflowstage",
330
+ f"extras.change_{content_type.lower()}",
331
+ constraints={"approval_workflow__object_under_review_object_id": self.scheduled_jobs[1].pk},
332
+ )
333
+ self.user.groups.add(self.approver_group_1)
334
+ response = self.client.post(url, **self.header)
335
+ self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
336
+
337
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
338
+ def test_approve_approval_workflow_stage_with_dismatch_content_type_contraints(self):
339
+ self._test_approval_workflow_stage_with_dismatch_content_type_constraints("approve")
340
+
341
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
342
+ def test_deny_approval_workflow_stage_with_dismatch_content_type_contraints(self):
343
+ self._test_approval_workflow_stage_with_dismatch_content_type_constraints("deny")
344
+
345
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
346
+ def _test_approval_workflow_stage_with_match_content_type_constraints(self, action):
347
+ for case in self.approval_workflow_content_type_cases:
348
+ content_type = case["content_type"]
349
+ with self.subTest(case=content_type, action=action):
350
+ url = reverse(f"extras-api:approvalworkflowstage-{action}", kwargs={"pk": case["stage"].pk})
351
+ self.add_permissions(
352
+ "extras.change_approvalworkflowstage",
353
+ f"extras.change_{content_type.lower()}",
354
+ constraints={"approval_workflow__object_under_review_object_id": self.scheduled_jobs[0].pk},
355
+ )
356
+ self.user.groups.add(self.approver_group_1)
357
+ response = self.client.post(url, **self.header)
358
+ self.assertHttpStatus(response, status.HTTP_200_OK)
359
+
360
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
361
+ def test_approve_approval_workflow_stage_with_match_content_type_contraints(self):
362
+ self._test_approval_workflow_stage_with_match_content_type_constraints("approve")
363
+
364
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
365
+ def test_deny_approval_workflow_stage_with_match_content_type_contraints(self):
366
+ self._test_approval_workflow_stage_with_match_content_type_constraints("deny")
367
+
368
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
369
+ def test_approve_approval_workflow_stage_in_approval_workflow_with_one_stage(self):
370
+ for case in self.approval_workflow_content_type_cases:
371
+ content_type = case["content_type"]
372
+ with self.subTest(case=content_type):
373
+ approval_workflow = case["workflow"]
374
+ approval_workflow_stage = case["stage"]
375
+ url = reverse("extras-api:approvalworkflowstage-approve", kwargs={"pk": case["stage"].pk})
376
+ self.add_permissions("extras.change_approvalworkflowstage", f"extras.change_{content_type.lower()}")
377
+ self.user.groups.add(self.approver_group_1)
378
+
379
+ response = self.client.post(url, **self.header)
380
+ self.assertHttpStatus(response, status.HTTP_200_OK)
381
+
382
+ approval_workflow.refresh_from_db()
383
+ approval_workflow_stage.refresh_from_db()
384
+ self.assertEqual(approval_workflow_stage.approval_workflow_stage_responses.count(), 1)
385
+ self.assertEqual(approval_workflow_stage.approval_workflow_stage_responses.first().comments, "")
386
+ self.assertEqual(approval_workflow_stage.state, ApprovalWorkflowStateChoices.APPROVED)
387
+ self.assertEqual(approval_workflow.current_state, ApprovalWorkflowStateChoices.APPROVED)
388
+
389
+ scheduled_job = case["object"]
390
+ scheduled_job.refresh_from_db()
391
+ self.assertEqual(scheduled_job.decision_date, approval_workflow.decision_date)
392
+
393
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
394
+ def test_approve_approval_workflow_stage_in_approval_workflow_with_more_than_one_stage(self):
395
+ for case in self.approval_workflow_content_type_cases:
396
+ content_type = case["content_type"]
397
+ with self.subTest(case=content_type):
398
+ approval_workflow = case["workflow"]
399
+ approval_workflow_stage = case["stage"]
400
+ url = reverse("extras-api:approvalworkflowstage-approve", kwargs={"pk": case["stage"].pk})
401
+ self.add_permissions("extras.change_approvalworkflowstage", f"extras.change_{content_type.lower()}")
402
+ self.user.groups.add(self.approver_group_1)
403
+
404
+ approval_workflow_stage_definition_2 = ApprovalWorkflowStageDefinition.objects.create(
405
+ approval_workflow_definition=approval_workflow.approval_workflow_definition,
406
+ sequence=200,
407
+ name="Approval Workflow Stage Definition 2",
408
+ min_approvers=1,
409
+ denial_message="Stage 2 Denial Message",
410
+ approver_group=self.approver_group_1,
411
+ )
412
+ approval_workflow_stage_2 = ApprovalWorkflowStage.objects.create(
413
+ approval_workflow=approval_workflow,
414
+ approval_workflow_stage_definition=approval_workflow_stage_definition_2,
415
+ state=ApprovalWorkflowStateChoices.PENDING,
416
+ )
417
+ response = self.client.post(url, **self.header)
418
+ self.assertHttpStatus(response, status.HTTP_200_OK)
419
+
420
+ approval_workflow.refresh_from_db()
421
+ approval_workflow_stage.refresh_from_db()
422
+ self.assertEqual(approval_workflow_stage.approval_workflow_stage_responses.count(), 1)
423
+ self.assertEqual(approval_workflow_stage.state, ApprovalWorkflowStateChoices.APPROVED)
424
+ self.assertEqual(approval_workflow_stage_2.approval_workflow_stage_responses.count(), 0)
425
+ self.assertEqual(approval_workflow_stage_2.state, ApprovalWorkflowStateChoices.PENDING)
426
+
427
+ # approval workflow should still be in pending state because user already approve 1 stage but are 2
428
+ self.assertEqual(approval_workflow.current_state, ApprovalWorkflowStateChoices.PENDING)
429
+
430
+ scheduled_job = case["object"]
431
+ scheduled_job.refresh_from_db()
432
+ self.assertIsNone(scheduled_job.decision_date)
433
+
434
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
435
+ def test_approve_with_comment_approval_workflow_stage_in_approval_workflow_with_one_stage(self):
436
+ for case in self.approval_workflow_content_type_cases:
437
+ content_type = case["content_type"]
438
+ with self.subTest(case=content_type):
439
+ approval_workflow = case["workflow"]
440
+ approval_workflow_stage = case["stage"]
441
+ url = reverse("extras-api:approvalworkflowstage-approve", kwargs={"pk": case["stage"].pk})
442
+ self.add_permissions("extras.change_approvalworkflowstage", f"extras.change_{content_type.lower()}")
443
+ self.user.groups.add(self.approver_group_1)
444
+
445
+ data = {"comments": "LGTM"}
446
+ response = self.client.post(url, data=data, format="json", **self.header)
447
+ self.assertHttpStatus(response, status.HTTP_200_OK)
448
+
449
+ approval_workflow.refresh_from_db()
450
+ approval_workflow_stage.refresh_from_db()
451
+ self.assertEqual(approval_workflow_stage.approval_workflow_stage_responses.count(), 1)
452
+ self.assertEqual(
453
+ approval_workflow_stage.approval_workflow_stage_responses.first().comments, data["comments"]
454
+ )
455
+ self.assertEqual(approval_workflow_stage.state, ApprovalWorkflowStateChoices.APPROVED)
456
+ self.assertEqual(approval_workflow.current_state, ApprovalWorkflowStateChoices.APPROVED)
457
+
458
+ scheduled_job = case["object"]
459
+ scheduled_job.refresh_from_db()
460
+ self.assertEqual(scheduled_job.decision_date, approval_workflow.decision_date)
461
+
462
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
463
+ def test_deny_approval_workflow_stage_in_approval_workflow_with_one_stage(self):
464
+ for case in self.approval_workflow_content_type_cases:
465
+ content_type = case["content_type"]
466
+ with self.subTest(case=content_type):
467
+ approval_workflow = case["workflow"]
468
+ approval_workflow_stage = case["stage"]
469
+ url = reverse("extras-api:approvalworkflowstage-deny", kwargs={"pk": case["stage"].pk})
470
+ self.add_permissions("extras.change_approvalworkflowstage", f"extras.change_{content_type.lower()}")
471
+ self.user.groups.add(self.approver_group_1)
472
+ response = self.client.post(url, **self.header)
473
+ self.assertHttpStatus(response, status.HTTP_200_OK)
474
+
475
+ approval_workflow.refresh_from_db()
476
+ approval_workflow_stage.refresh_from_db()
477
+ self.assertEqual(approval_workflow_stage.approval_workflow_stage_responses.count(), 1)
478
+ self.assertEqual(approval_workflow_stage.approval_workflow_stage_responses.first().comments, "")
479
+ self.assertEqual(approval_workflow_stage.state, ApprovalWorkflowStateChoices.DENIED)
480
+ self.assertEqual(approval_workflow.current_state, ApprovalWorkflowStateChoices.DENIED)
481
+
482
+ scheduled_job = case["object"]
483
+ scheduled_job.refresh_from_db()
484
+ self.assertEqual(scheduled_job.decision_date, approval_workflow.decision_date)
485
+
486
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
487
+ def test_deny_approval_workflow_stage_in_approval_workflow_with_more_than_one_stage(self):
488
+ for case in self.approval_workflow_content_type_cases:
489
+ content_type = case["content_type"]
490
+ with self.subTest(case=content_type):
491
+ approval_workflow = case["workflow"]
492
+ approval_workflow_stage = case["stage"]
493
+ url = reverse("extras-api:approvalworkflowstage-deny", kwargs={"pk": case["stage"].pk})
494
+ self.add_permissions("extras.change_approvalworkflowstage", f"extras.change_{content_type.lower()}")
495
+ self.user.groups.add(self.approver_group_1)
496
+
497
+ approval_workflow_stage_definition_2 = ApprovalWorkflowStageDefinition.objects.create(
498
+ approval_workflow_definition=approval_workflow.approval_workflow_definition,
499
+ sequence=200,
500
+ name="Approval Workflow Stage Definition 2",
501
+ min_approvers=1,
502
+ denial_message="Stage 2 Denial Message",
503
+ approver_group=self.approver_group_1,
504
+ )
505
+ approval_workflow_stage_2 = ApprovalWorkflowStage.objects.create(
506
+ approval_workflow=approval_workflow,
507
+ approval_workflow_stage_definition=approval_workflow_stage_definition_2,
508
+ state=ApprovalWorkflowStateChoices.PENDING,
509
+ )
510
+ response = self.client.post(url, **self.header)
511
+ self.assertHttpStatus(response, status.HTTP_200_OK)
512
+
513
+ approval_workflow.refresh_from_db()
514
+ approval_workflow_stage.refresh_from_db()
515
+ self.assertEqual(approval_workflow_stage.approval_workflow_stage_responses.count(), 1)
516
+ self.assertEqual(approval_workflow_stage.state, ApprovalWorkflowStateChoices.DENIED)
517
+ self.assertEqual(approval_workflow_stage_2.approval_workflow_stage_responses.count(), 0)
518
+ self.assertEqual(approval_workflow_stage_2.state, ApprovalWorkflowStateChoices.PENDING)
519
+
520
+ self.assertEqual(approval_workflow.current_state, ApprovalWorkflowStateChoices.DENIED)
521
+
522
+ scheduled_job = case["object"]
523
+ scheduled_job.refresh_from_db()
524
+ self.assertEqual(scheduled_job.decision_date, approval_workflow.decision_date)
525
+
526
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
527
+ def test_deny_with_approval_workflow_stage_in_approval_workflow_with_one_stage(self):
528
+ for case in self.approval_workflow_content_type_cases:
529
+ content_type = case["content_type"]
530
+ with self.subTest(case=content_type):
531
+ approval_workflow = case["workflow"]
532
+ approval_workflow_stage = case["stage"]
533
+ url = reverse("extras-api:approvalworkflowstage-deny", kwargs={"pk": case["stage"].pk})
534
+ self.add_permissions("extras.change_approvalworkflowstage", f"extras.change_{content_type.lower()}")
535
+ self.user.groups.add(self.approver_group_1)
536
+ data = {"comments": "Denied comment"}
537
+ response = self.client.post(url, data=data, format="json", **self.header)
538
+ self.assertHttpStatus(response, status.HTTP_200_OK)
539
+
540
+ approval_workflow.refresh_from_db()
541
+ approval_workflow_stage.refresh_from_db()
542
+ self.assertEqual(approval_workflow_stage.approval_workflow_stage_responses.count(), 1)
543
+ self.assertEqual(
544
+ approval_workflow_stage.approval_workflow_stage_responses.first().comments, data["comments"]
545
+ )
546
+ self.assertEqual(approval_workflow_stage.state, ApprovalWorkflowStateChoices.DENIED)
547
+ self.assertEqual(approval_workflow.current_state, ApprovalWorkflowStateChoices.DENIED)
548
+
549
+ scheduled_job = case["object"]
550
+ scheduled_job.refresh_from_db()
551
+ self.assertEqual(scheduled_job.decision_date, approval_workflow.decision_date)
552
+
553
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
554
+ def test_approval_workflow_stage_comment_without_permission(self):
555
+ for case in self.approval_workflow_content_type_cases:
556
+ with self.subTest(case=case["content_type"]):
557
+ stage = case["stage"]
558
+ url = reverse("extras-api:approvalworkflowstage-comment", kwargs={"pk": stage.pk})
559
+ data = {"comments": "Test comment without permission."}
560
+ response = self.client.post(url, data=data, format="json", **self.header)
561
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
562
+ self.assertEqual(stage.approval_workflow_stage_responses.count(), 0)
563
+
564
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
565
+ def test_approval_workflow_stage_add_comment_missing_or_empty(self):
566
+ for case in self.approval_workflow_content_type_cases:
567
+ with self.subTest(case=case["content_type"]):
568
+ stage = case["stage"]
569
+ url = reverse("extras-api:approvalworkflowstage-comment", kwargs={"pk": stage.pk})
570
+ self.add_permissions("extras.change_approvalworkflowstage")
571
+
572
+ # Try both missing and empty comment values
573
+ for payload in [{}, {"comments": ""}]:
574
+ response = self.client.post(url, data=payload, format="json", **self.header)
575
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
576
+ self.assertIn("detail", response.data)
577
+ self.assertEqual(response.data["detail"], "Comment cannot be empty.")
578
+ self.assertEqual(stage.approval_workflow_stage_responses.count(), 0)
579
+
580
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
581
+ def test_approval_workflow_stage_add_comment_to_stage(self):
582
+ for case in self.approval_workflow_content_type_cases:
583
+ content_type = case["content_type"]
584
+ with self.subTest(case=content_type):
585
+ stage = case["stage"]
586
+ url = reverse("extras-api:approvalworkflowstage-comment", kwargs={"pk": stage.pk})
587
+ self.add_permissions("extras.change_approvalworkflowstage")
588
+ # remove user from approver group
589
+ self.user.groups.remove(self.approver_group_1)
590
+
591
+ data = {"comments": "This is a test comment."}
592
+ response = self.client.post(url, data=data, format="json", **self.header)
593
+ self.assertHttpStatus(response, status.HTTP_200_OK)
594
+
595
+ stage.refresh_from_db()
596
+ self.assertEqual(stage.approval_workflow_stage_responses.count(), 1)
597
+
598
+ response_obj = stage.approval_workflow_stage_responses.first()
599
+ self.assertEqual(response_obj.comments, "This is a test comment.")
600
+ self.assertEqual(response_obj.user, self.user)
601
+ self.assertEqual(response_obj.state, stage.state)
602
+
603
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
604
+ def test_approval_workflow_stage_add_more_comment_to_stage(self):
605
+ for case in self.approval_workflow_content_type_cases:
606
+ content_type = case["content_type"]
607
+ with self.subTest(case=content_type):
608
+ stage = case["stage"]
609
+ url = reverse("extras-api:approvalworkflowstage-comment", kwargs={"pk": stage.pk})
610
+ self.add_permissions("extras.change_approvalworkflowstage")
611
+ # Ensure user is not in approver group (just commenting)
612
+ self.user.groups.remove(self.approver_group_1)
613
+ ApprovalWorkflowStageResponse.objects.create(
614
+ approval_workflow_stage=stage, user=self.user, state=stage.state, comments="First comment"
615
+ )
616
+ data = {"comments": "This is a test comment."}
617
+ response = self.client.post(url, data=data, format="json", **self.header)
618
+ self.assertHttpStatus(response, status.HTTP_200_OK)
619
+
620
+ stage.refresh_from_db()
621
+ self.assertEqual(stage.approval_workflow_stage_responses.count(), 2)
622
+
623
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
624
+ def test_approval_workflow_stage_pending_my_approvals(self):
625
+ base_url = reverse("extras-api:approvalworkflowstage-list")
626
+ query_params = urlencode({"pending_my_approvals": "true"})
627
+ url = f"{base_url}?{query_params}"
628
+ self.add_permissions(
629
+ "extras.view_approvalworkflowstage",
630
+ )
631
+ self.user.groups.add(self.approver_group_1)
632
+
633
+ # Set all stages as approved except for the first one.
634
+ for stage in ApprovalWorkflowStage.objects.all():
635
+ if stage.approval_workflow != self.approval_workflows[0]:
636
+ ApprovalWorkflowStageResponse.objects.create(
637
+ approval_workflow_stage=stage, user=self.user, state=ApprovalWorkflowStateChoices.APPROVED
638
+ )
639
+ stage.state = ApprovalWorkflowStateChoices.APPROVED
640
+ stage.save()
641
+
642
+ self.assertEqual(ApprovalWorkflowStage.objects.filter(state=ApprovalWorkflowStateChoices.APPROVED).count(), 2)
643
+
644
+ # Create 1 pending stage, but in different approver group
645
+ approver_group_2 = Group.objects.create(name="Approver Group 2")
646
+ approval_workflow_stage_definition_approver_group_2 = ApprovalWorkflowStageDefinition.objects.create(
647
+ approval_workflow_definition=self.approval_workflow_definitions[3],
648
+ sequence=100,
649
+ name="Test Approval Workflow Stage 1 Definition",
650
+ min_approvers=1,
651
+ denial_message="Stage Denial Message",
652
+ approver_group=approver_group_2,
653
+ )
654
+ ApprovalWorkflowStage.objects.create(
655
+ approval_workflow=self.approval_workflows[3],
656
+ approval_workflow_stage_definition=approval_workflow_stage_definition_approver_group_2,
657
+ state=ApprovalWorkflowStateChoices.PENDING,
658
+ )
659
+
660
+ # user is approver in 2 approval workflows, but second one is approved
661
+ self.assertTrue(self.approval_workflow_stage_definitions[1].approver_group in self.user.groups.all())
662
+ # user is not an approver
663
+ self.assertTrue(
664
+ approval_workflow_stage_definition_approver_group_2.approver_group not in self.user.groups.all()
665
+ )
666
+
667
+ test_cases = [
668
+ ("true", 1), # Should list pending approvals for specific user in this case 1
669
+ ("false", 2), # Should list done approvals for specific user
670
+ (None, 4), # Should return all visible stages (no filter applied)
671
+ ]
672
+
673
+ for param_value, expected_count in test_cases:
674
+ with self.subTest(pending_my_approvals=param_value):
675
+ if param_value is not None:
676
+ query_params = urlencode({"pending_my_approvals": param_value})
677
+ url = f"{base_url}?{query_params}"
678
+ else:
679
+ url = base_url # no filter param
680
+
681
+ response = self.client.get(url, **self.header)
682
+ self.assertHttpStatus(response, status.HTTP_200_OK)
683
+ self.assertEqual(len(response.data["results"]), expected_count)
684
+
685
+ if param_value == "true":
686
+ # Confirm correct object is returned
687
+ self.assertEqual(response.data["results"][0]["id"], str(self.approval_workflow_stages[0].id))
688
+
689
+
110
690
  #
111
691
  # Computed Fields
112
692
  #
@@ -406,6 +986,9 @@ class ContactTest(APIViewTestCases.APIViewTestCase):
406
986
  bulk_update_data = {
407
987
  "address": "Carnegie Hall, New York, NY",
408
988
  }
989
+ validation_excluded_fields = [
990
+ "teams", # M2M field, excluded by default
991
+ ]
409
992
 
410
993
  @classmethod
411
994
  def setUpTestData(cls):
@@ -1213,6 +1796,7 @@ class GitRepositoryTest(APIViewTestCases.APIViewTestCase):
1213
1796
  self.assertEqual(response.data["message"], f"Repository {self.repos[0].name} sync job added to queue.")
1214
1797
  self.assertIsInstance(response.data["job_result"], dict)
1215
1798
 
1799
+ @tag("example_app")
1216
1800
  def test_create_with_app_provided_contents(self):
1217
1801
  """Test that `provided_contents` published by an App works."""
1218
1802
  self.add_permissions("extras.add_gitrepository")
@@ -1278,12 +1862,12 @@ class GraphQLQueryTest(APIViewTestCases.APIViewTestCase):
1278
1862
  url = reverse("extras-api:graphqlquery-run", kwargs={"pk": self.graphqlqueries[0].pk})
1279
1863
  response = self.client.post(url, **self.header)
1280
1864
  self.assertHttpStatus(response, status.HTTP_200_OK)
1281
- self.assertEqual({"data": {"locations": []}}, response.data)
1865
+ self.assertEqual({"data": {"locations": []}, "errors": None}, response.data)
1282
1866
 
1283
1867
  url = reverse("extras-api:graphqlquery-run", kwargs={"pk": self.graphqlqueries[2].pk})
1284
1868
  response = self.client.post(url, **self.header)
1285
1869
  self.assertHttpStatus(response, status.HTTP_200_OK)
1286
- self.assertEqual({"data": {"devices": []}}, response.data)
1870
+ self.assertEqual({"data": {"devices": []}, "errors": None}, response.data)
1287
1871
 
1288
1872
 
1289
1873
  # TODO(Glenn): Standardize to APIViewTestCase (needs create & update tests)
@@ -1358,6 +1942,14 @@ class JobTest(
1358
1942
  self.job_model.enabled = True
1359
1943
  self.job_model.validated_save()
1360
1944
 
1945
+ device_role = Role.objects.get_for_model(Device).first()
1946
+ self.job_proper_data = {
1947
+ "var1": "FooBar",
1948
+ "var2": 123,
1949
+ "var3": False,
1950
+ "var4": device_role.pk,
1951
+ }
1952
+
1361
1953
  @classmethod
1362
1954
  def setUpTestData(cls):
1363
1955
  cls.update_data = {
@@ -1369,8 +1961,6 @@ class JobTest(
1369
1961
  "description_override": True,
1370
1962
  "description": "This is an overridden description.",
1371
1963
  "enabled": True,
1372
- "approval_required_override": True,
1373
- "approval_required": True,
1374
1964
  "dryrun_default_override": True,
1375
1965
  "dryrun_default": True,
1376
1966
  "hidden_override": True,
@@ -1384,8 +1974,6 @@ class JobTest(
1384
1974
  }
1385
1975
  cls.bulk_update_data = {
1386
1976
  "enabled": True,
1387
- "approval_required_override": True,
1388
- "approval_required": True,
1389
1977
  "has_sensitive_variables": False,
1390
1978
  "has_sensitive_variables_override": True,
1391
1979
  }
@@ -1460,50 +2048,6 @@ class JobTest(
1460
2048
  {"name": "var4", "type": "ObjectVar", "required": True, "model": "extras.role"},
1461
2049
  )
1462
2050
 
1463
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1464
- def test_update_job_with_sensitive_variables_set_approval_required_to_true(self):
1465
- job_model = Job.objects.get_for_class_path("api_test_job.APITestJob")
1466
- job_model.has_sensitive_variables = True
1467
- job_model.has_sensitive_variables_override = True
1468
- job_model.validated_save()
1469
-
1470
- url = self._get_detail_url(job_model)
1471
- data = {
1472
- "approval_required_override": True,
1473
- "approval_required": True,
1474
- }
1475
-
1476
- self.add_permissions("extras.change_job")
1477
-
1478
- response = self.client.patch(url, data, format="json", **self.header)
1479
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1480
- self.assertEqual(
1481
- response.data["approval_required"][0],
1482
- "A job with sensitive variables cannot also be marked as requiring approval",
1483
- )
1484
-
1485
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1486
- def test_update_approval_required_job_set_has_sensitive_variables_to_true(self):
1487
- job_model = Job.objects.get_for_class_path("api_test_job.APITestJob")
1488
- job_model.approval_required = True
1489
- job_model.approval_required_override = True
1490
- job_model.validated_save()
1491
-
1492
- url = self._get_detail_url(job_model)
1493
- data = {
1494
- "has_sensitive_variables": True,
1495
- "has_sensitive_variables_override": True,
1496
- }
1497
-
1498
- self.add_permissions("extras.change_job")
1499
-
1500
- response = self.client.patch(url, data, format="json", **self.header)
1501
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1502
- self.assertEqual(
1503
- response.data["has_sensitive_variables"][0],
1504
- "A job with sensitive variables cannot also be marked as requiring approval",
1505
- )
1506
-
1507
2051
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1508
2052
  def test_run_job_anonymous_not_permitted(self):
1509
2053
  """The run_job endpoint should NOT allow anonymous users to submit jobs."""
@@ -1672,9 +2216,11 @@ class JobTest(
1672
2216
 
1673
2217
  Assert an immediate schedule that enforces it.
1674
2218
  """
1675
- # Set approval_required=True
1676
- self.job_model.approval_required = True
1677
- self.job_model.save()
2219
+ ApprovalWorkflowDefinition.objects.create(
2220
+ name="Test Approval Workflow Definition 1",
2221
+ model_content_type=ContentType.objects.get_for_model(ScheduledJob),
2222
+ weight=0,
2223
+ )
1678
2224
 
1679
2225
  # Do the stuff.
1680
2226
  mock_get_worker_count.return_value = 1
@@ -1703,7 +2249,7 @@ class JobTest(
1703
2249
  schedule = ScheduledJob.objects.last()
1704
2250
  self.assertIsNotNone(schedule)
1705
2251
  self.assertEqual(schedule.interval, JobExecutionType.TYPE_FUTURE)
1706
- self.assertEqual(schedule.approval_required, self.job_model.approval_required)
2252
+ self.assertTrue(schedule.approval_required)
1707
2253
  self.assertEqual(schedule.kwargs["var4"], str(device_role.pk))
1708
2254
 
1709
2255
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
@@ -1939,21 +2485,27 @@ class JobTest(
1939
2485
  "Unable to schedule job: Job may have sensitive input variables",
1940
2486
  )
1941
2487
 
2488
+ @tag("example_app")
1942
2489
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1943
2490
  @mock.patch("nautobot.extras.api.views.get_worker_count")
1944
- def test_run_a_job_with_sensitive_variables_and_requires_approval(self, mock_get_worker_count):
2491
+ def test_run_a_job_with_sensitive_variables_when_approval_workflow_defined(self, mock_get_worker_count):
2492
+ ApprovalWorkflowDefinition.objects.create(
2493
+ name="Test Approval Workflow Definition 1",
2494
+ model_content_type=ContentType.objects.get_for_model(ScheduledJob),
2495
+ weight=0,
2496
+ )
2497
+
1945
2498
  mock_get_worker_count.return_value = 1
1946
2499
  self.add_permissions("extras.run_job")
1947
2500
 
1948
2501
  job_model = Job.objects.get(job_class_name="ExampleJob")
1949
2502
  job_model.enabled = True
1950
2503
  job_model.has_sensitive_variables = True
1951
- job_model.approval_required = True
1952
2504
  job_model.save()
1953
2505
 
1954
2506
  url = reverse("extras-api:job-run", kwargs={"pk": job_model.pk})
1955
2507
  data = {
1956
- "data": {},
2508
+ "data": {"some_json_data": {"var1": "x"}},
1957
2509
  "schedule": {
1958
2510
  "interval": "immediately",
1959
2511
  "name": "test",
@@ -1965,8 +2517,8 @@ class JobTest(
1965
2517
  self.assertEqual(
1966
2518
  response.data[0],
1967
2519
  "Unable to run or schedule job: "
1968
- "This job is flagged as possibly having sensitive variables but is also flagged as requiring approval."
1969
- "One of these two flags must be removed before this job can be scheduled or run.",
2520
+ "This job is flagged as possibly having sensitive variables but also has an applicable approval workflow definition."
2521
+ "Modify or remove the approval workflow definition or modify the job to set `has_sensitive_variables` to False.",
1970
2522
  )
1971
2523
 
1972
2524
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
@@ -2148,6 +2700,169 @@ class JobTest(
2148
2700
  response = self.client.post(url, data, format="json", **self.header)
2149
2701
  self.assertHttpStatus(response, self.run_success_response_status)
2150
2702
 
2703
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2704
+ @mock.patch("nautobot.extras.api.views.get_worker_count")
2705
+ def test_run_job_with_approval_workflow_defined_triggers_approval_workflow(self, mock_get_worker_count):
2706
+ """Test that jobs with approval workflow definition for both immediate and scheduled execution triggers approval workflow."""
2707
+ ApprovalWorkflowDefinition.objects.create(
2708
+ name="Test Approval Workflow Definition 1",
2709
+ model_content_type=ContentType.objects.get_for_model(ScheduledJob),
2710
+ weight=0,
2711
+ )
2712
+
2713
+ mock_get_worker_count.return_value = 1
2714
+ self.add_permissions("extras.run_job")
2715
+ self.add_permissions("extras.view_scheduledjob")
2716
+
2717
+ url = self.get_run_url()
2718
+
2719
+ # Test scenarios
2720
+ test_cases = [
2721
+ {
2722
+ "name": "immediate_execution",
2723
+ "data": {
2724
+ "data": self.job_proper_data,
2725
+ # schedule is omitted - should create immediate schedule
2726
+ },
2727
+ "expected_interval": JobExecutionType.TYPE_FUTURE,
2728
+ "expected_name": None,
2729
+ },
2730
+ {
2731
+ "name": "scheduled_execution",
2732
+ "data": {
2733
+ "data": self.job_proper_data,
2734
+ "schedule": {
2735
+ "interval": JobExecutionType.TYPE_FUTURE,
2736
+ "name": "test_scheduled_job",
2737
+ "start_time": (now() + timedelta(minutes=1)).isoformat(),
2738
+ },
2739
+ },
2740
+ "expected_interval": JobExecutionType.TYPE_FUTURE,
2741
+ "expected_name": "test_scheduled_job",
2742
+ },
2743
+ ]
2744
+
2745
+ for test_case in test_cases:
2746
+ with self.subTest(execution_type=test_case["name"]):
2747
+ # Clear any existing scheduled jobs from previous subtest
2748
+ ScheduledJob.objects.all().delete()
2749
+ JobResult.objects.filter(name=self.job_model.name).delete()
2750
+
2751
+ response = self.client.post(url, test_case["data"], format="json", **self.header)
2752
+ self.assertHttpStatus(response, self.run_success_response_status)
2753
+
2754
+ # Assert that a JobResult was NOT created (job is pending approval)
2755
+ self.assertFalse(JobResult.objects.filter(name=self.job_model.name).exists())
2756
+
2757
+ # Assert that a ScheduledJob was created and has approval workflow
2758
+ scheduled_job = ScheduledJob.objects.last()
2759
+ self.assertIsNotNone(scheduled_job)
2760
+ self.assertEqual(scheduled_job.interval, test_case["expected_interval"])
2761
+
2762
+ if test_case["expected_name"]:
2763
+ self.assertEqual(scheduled_job.name, test_case["expected_name"])
2764
+
2765
+ self.assertTrue(scheduled_job.associated_approval_workflows.exists())
2766
+
2767
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2768
+ @mock.patch("nautobot.extras.api.views.get_worker_count")
2769
+ def test_run_immediate_job_with_no_approval_workflow_defined(self, mock_get_worker_count):
2770
+ """
2771
+ Run an immediate job with no approval workflow defined.
2772
+ Should succeed and execute immediately.
2773
+ """
2774
+ mock_get_worker_count.return_value = 1
2775
+ self.add_permissions("extras.run_job")
2776
+ self.add_permissions("extras.view_jobresult")
2777
+
2778
+ data = {
2779
+ "data": self.job_proper_data,
2780
+ # schedule is omitted - should create immediate schedule
2781
+ }
2782
+
2783
+ count_scheduled_job = ScheduledJob.objects.count()
2784
+
2785
+ url = self.get_run_url()
2786
+ response = self.client.post(url, data, format="json", **self.header)
2787
+ self.assertHttpStatus(response, self.run_success_response_status)
2788
+
2789
+ # Assert no ScheduledJob was created (job ran immediately)
2790
+ self.assertEqual(ScheduledJob.objects.count(), count_scheduled_job)
2791
+
2792
+ # Assert JobResult was created
2793
+ result = JobResult.objects.latest()
2794
+ self.assertEqual(result.name, self.job_model.name)
2795
+
2796
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2797
+ @mock.patch("nautobot.extras.api.views.get_worker_count")
2798
+ def test_scheduled_job_triggers_approval_workflow_if_defined(self, mock_get_worker_count):
2799
+ """
2800
+ Run a scheduled job with approval workflow defined.
2801
+ Should trigger approval workflow for scheduled jobs.
2802
+ """
2803
+ mock_get_worker_count.return_value = 1
2804
+ self.add_permissions("extras.run_job")
2805
+ self.add_permissions("extras.view_scheduledjob")
2806
+
2807
+ ApprovalWorkflowDefinition.objects.create(
2808
+ name="Approval Definition",
2809
+ model_content_type=ContentType.objects.get_for_model(ScheduledJob),
2810
+ weight=0,
2811
+ )
2812
+
2813
+ start_time = now() + timedelta(minutes=1)
2814
+ data = {
2815
+ "data": self.job_proper_data,
2816
+ "schedule": {
2817
+ "interval": JobExecutionType.TYPE_FUTURE,
2818
+ "name": "test_scheduled_job",
2819
+ "start_time": start_time.isoformat(),
2820
+ },
2821
+ }
2822
+
2823
+ url = self.get_run_url()
2824
+ response = self.client.post(url, data, format="json", **self.header)
2825
+ self.assertHttpStatus(response, self.run_success_response_status)
2826
+
2827
+ # Assert that a ScheduledJob was created and has approval workflow
2828
+ scheduled_job = ScheduledJob.objects.last()
2829
+ self.assertIsNotNone(scheduled_job)
2830
+ self.assertEqual(scheduled_job.interval, JobExecutionType.TYPE_FUTURE)
2831
+ self.assertEqual(scheduled_job.name, "test_scheduled_job")
2832
+ self.assertTrue(scheduled_job.associated_approval_workflows.exists())
2833
+
2834
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2835
+ @mock.patch("nautobot.extras.api.views.get_worker_count")
2836
+ def test_run_scheduled_job_with_no_approval_workflow_defined(self, mock_get_worker_count):
2837
+ """
2838
+ Run a scheduled job with no approval workflow defined.
2839
+ Should succeed and create scheduled job without approval workflow.
2840
+ """
2841
+ mock_get_worker_count.return_value = 1
2842
+ self.add_permissions("extras.run_job")
2843
+ self.add_permissions("extras.view_scheduledjob")
2844
+
2845
+ start_time = now() + timedelta(minutes=1)
2846
+ data = {
2847
+ "data": self.job_proper_data,
2848
+ "schedule": {
2849
+ "interval": JobExecutionType.TYPE_FUTURE,
2850
+ "name": "test_scheduled_job",
2851
+ "start_time": start_time.isoformat(),
2852
+ },
2853
+ }
2854
+
2855
+ url = self.get_run_url()
2856
+ response = self.client.post(url, data, format="json", **self.header)
2857
+ self.assertHttpStatus(response, self.run_success_response_status)
2858
+
2859
+ # Assert that a ScheduledJob was created without approval workflow
2860
+ scheduled_job = ScheduledJob.objects.last()
2861
+ self.assertIsNotNone(scheduled_job)
2862
+ self.assertEqual(scheduled_job.interval, JobExecutionType.TYPE_FUTURE)
2863
+ self.assertEqual(scheduled_job.name, "test_scheduled_job")
2864
+ self.assertFalse(scheduled_job.associated_approval_workflows.exists())
2865
+
2151
2866
  # TODO: Either improve test base or or write a more specific test for this model.
2152
2867
  @skip("Job has a `name` property but grouping is also used to sort Jobs")
2153
2868
  def test_list_objects_ascending_ordered(self):
@@ -2605,180 +3320,6 @@ class ScheduledJobTest(
2605
3320
  )
2606
3321
 
2607
3322
 
2608
- class JobApprovalTest(APITestCase):
2609
- @classmethod
2610
- def setUpTestData(cls):
2611
- cls.additional_user = User.objects.create(username="user1", is_active=True)
2612
- cls.job_model = Job.objects.get_for_class_path("pass_job.TestPassJob")
2613
- cls.job_model.enabled = True
2614
- cls.job_model.save()
2615
- cls.scheduled_job = ScheduledJob.objects.create(
2616
- name="test pass",
2617
- task="pass_job.TestPassJob",
2618
- job_model=cls.job_model,
2619
- interval=JobExecutionType.TYPE_IMMEDIATELY,
2620
- user=cls.additional_user,
2621
- approval_required=True,
2622
- start_time=now(),
2623
- )
2624
- cls.dryrun_job_model = Job.objects.get_for_class_path("dry_run.TestDryRun")
2625
- cls.dryrun_job_model.enabled = True
2626
- cls.dryrun_job_model.save()
2627
- cls.dryrun_scheduled_job = ScheduledJob.objects.create(
2628
- name="test dryrun",
2629
- task="dry_run.TestDryRun",
2630
- job_model=cls.dryrun_job_model,
2631
- kwargs={"value": 1},
2632
- interval=JobExecutionType.TYPE_IMMEDIATELY,
2633
- user=cls.additional_user,
2634
- approval_required=True,
2635
- start_time=now(),
2636
- )
2637
-
2638
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2639
- def test_approve_job_anonymous(self):
2640
- url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
2641
- response = self.client.post(url)
2642
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2643
-
2644
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2645
- def test_approve_job_without_permission(self):
2646
- url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
2647
- with disable_warnings("django.request"):
2648
- response = self.client.post(url, **self.header)
2649
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2650
-
2651
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2652
- def test_approve_job_without_approve_job_permission(self):
2653
- self.add_permissions("extras.view_scheduledjob", "extras.change_scheduledjob")
2654
- url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
2655
- response = self.client.post(url, **self.header)
2656
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2657
-
2658
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2659
- def test_approve_job_without_change_scheduledjob_permission(self):
2660
- self.add_permissions("extras.approve_job", "extras.view_scheduledjob")
2661
- url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
2662
- response = self.client.post(url, **self.header)
2663
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2664
-
2665
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2666
- def test_approve_job_same_user(self):
2667
- self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
2668
- scheduled_job = ScheduledJob.objects.create(
2669
- name="test",
2670
- task="pass_job.TestPassJob",
2671
- job_model=self.job_model,
2672
- interval=JobExecutionType.TYPE_IMMEDIATELY,
2673
- user=self.user,
2674
- approval_required=True,
2675
- start_time=now(),
2676
- )
2677
- url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": scheduled_job.pk})
2678
- response = self.client.post(url, **self.header)
2679
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2680
-
2681
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2682
- def test_approve_job(self):
2683
- self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
2684
- url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
2685
- response = self.client.post(url, **self.header)
2686
- self.assertHttpStatus(response, status.HTTP_200_OK)
2687
-
2688
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2689
- def test_approve_job_in_past(self):
2690
- self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
2691
- scheduled_job = ScheduledJob.objects.create(
2692
- name="test",
2693
- task="pass_job.TestPassJob",
2694
- job_model=self.job_model,
2695
- interval=JobExecutionType.TYPE_FUTURE,
2696
- one_off=True,
2697
- user=self.additional_user,
2698
- approval_required=True,
2699
- start_time=now(),
2700
- )
2701
- url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": scheduled_job.pk})
2702
- response = self.client.post(url, **self.header)
2703
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
2704
-
2705
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2706
- def test_approve_job_in_past_force(self):
2707
- self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
2708
- scheduled_job = ScheduledJob.objects.create(
2709
- name="test",
2710
- task="pass_job.TestPassJob",
2711
- job_model=self.job_model,
2712
- interval=JobExecutionType.TYPE_FUTURE,
2713
- one_off=True,
2714
- user=self.additional_user,
2715
- approval_required=True,
2716
- start_time=now(),
2717
- )
2718
- url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": scheduled_job.pk})
2719
- response = self.client.post(url + "?force=true", **self.header)
2720
- self.assertHttpStatus(response, status.HTTP_200_OK)
2721
-
2722
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2723
- def test_deny_job_without_permission(self):
2724
- url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
2725
- with disable_warnings("django.request"):
2726
- response = self.client.post(url, **self.header)
2727
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2728
-
2729
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2730
- def test_deny_job_without_approve_job_permission(self):
2731
- self.add_permissions("extras.view_scheduledjob", "extras.delete_scheduledjob")
2732
- url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
2733
- response = self.client.post(url, **self.header)
2734
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2735
-
2736
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2737
- def test_deny_job_without_delete_scheduledjob_permission(self):
2738
- self.add_permissions("extras.approve_job", "extras.view_scheduledjob")
2739
- url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
2740
- response = self.client.post(url, **self.header)
2741
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2742
-
2743
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2744
- def test_deny_job(self):
2745
- self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.delete_scheduledjob")
2746
- url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
2747
- response = self.client.post(url, **self.header)
2748
- self.assertHttpStatus(response, status.HTTP_200_OK)
2749
- self.assertIsNone(ScheduledJob.objects.filter(pk=self.scheduled_job.pk).first())
2750
-
2751
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
2752
- def test_dry_run_job_without_permission(self):
2753
- url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.dryrun_scheduled_job.pk})
2754
- with disable_warnings("django.request"):
2755
- response = self.client.post(url, **self.header)
2756
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2757
-
2758
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2759
- def test_dry_run_job_without_run_job_permission(self):
2760
- self.add_permissions("extras.view_scheduledjob")
2761
- url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.dryrun_scheduled_job.pk})
2762
- response = self.client.post(url, **self.header)
2763
- self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2764
-
2765
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2766
- def test_dry_run_job(self):
2767
- self.add_permissions("extras.run_job", "extras.view_scheduledjob")
2768
- url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.dryrun_scheduled_job.pk})
2769
- response = self.client.post(url, **self.header)
2770
- self.assertHttpStatus(response, status.HTTP_200_OK)
2771
- # The below fails because JobResult.task_kwargs doesn't get set until *after* the task begins executing.
2772
- # self.assertEqual(response.data["task_kwargs"], {"dryrun": True, "value": 1}, response.data)
2773
-
2774
- @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2775
- def test_dry_run_not_supported(self):
2776
- self.add_permissions("extras.run_job", "extras.view_scheduledjob")
2777
- url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.scheduled_job.pk})
2778
- response = self.client.post(url, **self.header)
2779
- self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
2780
-
2781
-
2782
3323
  class MetadataTypeTest(APIViewTestCases.APIViewTestCase):
2783
3324
  model = MetadataType
2784
3325
  choices_fields = ["data_type"]
@@ -4209,9 +4750,9 @@ class TagTest(APIViewTestCases.APIViewTestCase):
4209
4750
  data = {**self.create_data[0], "content_types": [Manufacturer._meta.label_lower]}
4210
4751
  response = self.client.post(self._get_list_url(), data, format="json", **self.header)
4211
4752
 
4212
- tag = Tag.objects.filter(name=data["name"])
4753
+ tags = Tag.objects.filter(name=data["name"])
4213
4754
  self.assertHttpStatus(response, 400)
4214
- self.assertFalse(tag.exists())
4755
+ self.assertFalse(tags.exists())
4215
4756
  self.assertIn(f"Invalid content type: {Manufacturer._meta.label_lower}", response.data["content_types"])
4216
4757
 
4217
4758
  def test_create_tags_without_content_types(self):
@@ -4248,9 +4789,9 @@ class TagTest(APIViewTestCases.APIViewTestCase):
4248
4789
  """Test updating a tag without changing its content-types."""
4249
4790
  self.add_permissions("extras.change_tag")
4250
4791
 
4251
- tag = Tag.objects.exclude(content_types=ContentType.objects.get_for_model(Location)).first()
4252
- tag_content_types = list(tag.content_types.all())
4253
- url = self._get_detail_url(tag)
4792
+ tag_instance = Tag.objects.exclude(content_types=ContentType.objects.get_for_model(Location)).first()
4793
+ tag_content_types = list(tag_instance.content_types.all())
4794
+ url = self._get_detail_url(tag_instance)
4254
4795
  data = {"color": ColorChoices.COLOR_LIME}
4255
4796
 
4256
4797
  response = self.client.patch(url, data, format="json", **self.header)
@@ -4260,9 +4801,9 @@ class TagTest(APIViewTestCases.APIViewTestCase):
4260
4801
  sorted(response.data["content_types"]), sorted([f"{ct.app_label}.{ct.model}" for ct in tag_content_types])
4261
4802
  )
4262
4803
 
4263
- tag.refresh_from_db()
4264
- self.assertEqual(tag.color, ColorChoices.COLOR_LIME)
4265
- self.assertEqual(list(tag.content_types.all()), tag_content_types)
4804
+ tag_instance.refresh_from_db()
4805
+ self.assertEqual(tag_instance.color, ColorChoices.COLOR_LIME)
4806
+ self.assertEqual(list(tag_instance.content_types.all()), tag_content_types)
4266
4807
 
4267
4808
 
4268
4809
  #
@@ -4275,6 +4816,9 @@ class TeamTest(APIViewTestCases.APIViewTestCase):
4275
4816
  bulk_update_data = {
4276
4817
  "address": "Carnegie Hall, New York, NY",
4277
4818
  }
4819
+ validation_excluded_fields = [
4820
+ "contacts", # M2M field, excluded by default
4821
+ ]
4278
4822
 
4279
4823
  @classmethod
4280
4824
  def setUpTestData(cls):