nautobot 2.0.0a3__py3-none-any.whl → 2.0.0b1__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.
Files changed (780) hide show
  1. nautobot/apps/api.py +6 -8
  2. nautobot/apps/forms.py +0 -2
  3. nautobot/apps/ui.py +0 -8
  4. nautobot/circuits/api/serializers.py +9 -117
  5. nautobot/circuits/api/urls.py +1 -1
  6. nautobot/circuits/api/views.py +0 -1
  7. nautobot/circuits/forms.py +0 -65
  8. nautobot/circuits/migrations/0014_related_name_changes.py +1 -1
  9. nautobot/circuits/migrations/0016_tagsfield.py +34 -0
  10. nautobot/circuits/migrations/0017_fixup_null_statuses.py +22 -0
  11. nautobot/circuits/migrations/0018_status_nonnullable.py +22 -0
  12. nautobot/circuits/models.py +3 -87
  13. nautobot/circuits/navigation.py +14 -69
  14. nautobot/circuits/signals.py +0 -2
  15. nautobot/circuits/tables.py +39 -1
  16. nautobot/circuits/tests/integration/test_relationships.py +9 -9
  17. nautobot/circuits/tests/test_api.py +4 -8
  18. nautobot/circuits/tests/test_filters.py +10 -4
  19. nautobot/circuits/tests/test_models.py +5 -1
  20. nautobot/circuits/tests/test_views.py +27 -5
  21. nautobot/circuits/views.py +18 -10
  22. nautobot/core/api/__init__.py +8 -2
  23. nautobot/core/api/fields.py +15 -6
  24. nautobot/core/api/filter_backends.py +3 -2
  25. nautobot/core/api/metadata.py +237 -30
  26. nautobot/core/api/mixins.py +94 -0
  27. nautobot/core/api/pagination.py +4 -0
  28. nautobot/core/api/parsers.py +154 -0
  29. nautobot/core/api/renderers.py +153 -2
  30. nautobot/core/api/schema.py +46 -2
  31. nautobot/core/api/serializers.py +377 -35
  32. nautobot/core/api/urls.py +11 -3
  33. nautobot/core/api/utils.py +174 -2
  34. nautobot/core/api/versioning.py +32 -10
  35. nautobot/core/api/views.py +266 -72
  36. nautobot/core/apps/__init__.py +138 -220
  37. nautobot/core/celery/__init__.py +112 -41
  38. nautobot/core/celery/backends.py +19 -12
  39. nautobot/core/celery/control.py +46 -0
  40. nautobot/core/celery/encoders.py +53 -0
  41. nautobot/core/celery/log.py +38 -0
  42. nautobot/core/celery/schedulers.py +23 -4
  43. nautobot/core/celery/task.py +1 -16
  44. nautobot/core/checks.py +0 -27
  45. nautobot/core/choices.py +0 -113
  46. nautobot/core/{cli.py → cli/__init__.py} +1 -1
  47. nautobot/core/cli/__main__.py +3 -0
  48. nautobot/core/constants.py +0 -24
  49. nautobot/core/context_processors.py +12 -0
  50. nautobot/core/filters.py +2 -2
  51. nautobot/core/forms/__init__.py +0 -4
  52. nautobot/core/forms/fields.py +38 -65
  53. nautobot/core/forms/forms.py +4 -1
  54. nautobot/core/forms/utils.py +0 -52
  55. nautobot/core/graphql/schema.py +4 -27
  56. nautobot/core/jobs/__init__.py +75 -0
  57. nautobot/core/management/commands/build_ui.py +255 -0
  58. nautobot/core/management/commands/generate_test_data.py +3 -2
  59. nautobot/core/management/commands/post_upgrade.py +24 -24
  60. nautobot/core/models/__init__.py +26 -1
  61. nautobot/core/models/fields.py +24 -5
  62. nautobot/core/models/generics.py +2 -42
  63. nautobot/core/models/managers.py +5 -0
  64. nautobot/core/models/name_color_content_types.py +0 -14
  65. nautobot/core/models/tree_queries.py +14 -4
  66. nautobot/core/models/utils.py +5 -6
  67. nautobot/core/models/validators.py +17 -8
  68. nautobot/core/releases.py +8 -10
  69. nautobot/core/settings.py +80 -42
  70. nautobot/core/tables.py +5 -5
  71. nautobot/core/tasks.py +4 -7
  72. nautobot/core/templates/base.html +1 -49
  73. nautobot/core/templates/base_django.html +49 -0
  74. nautobot/core/templates/base_react.html +55 -0
  75. nautobot/core/templates/buttons/export.html +6 -4
  76. nautobot/core/templates/generic/object_bulk_create.html +10 -21
  77. nautobot/core/templates/generic/object_list.html +3 -1
  78. nautobot/core/templates/generic/object_retrieve_plugin_full_width.html +3 -0
  79. nautobot/core/templates/inc/footer.html +1 -0
  80. nautobot/core/templates/inc/javascript.html +0 -14
  81. nautobot/core/templates/inc/nav_menu.html +28 -33
  82. nautobot/core/templates/inc/object_details_advanced_panel.html +13 -0
  83. nautobot/core/templates/inc/relationships_table_rows.html +2 -2
  84. nautobot/core/templates/nautobot_config.py.j2 +8 -20
  85. nautobot/core/templates/plugin_template/__init__.py-tpl +1 -2
  86. nautobot/core/templates/rest_framework/api.html +8 -0
  87. nautobot/core/templatetags/buttons.py +32 -28
  88. nautobot/core/testing/__init__.py +47 -44
  89. nautobot/core/testing/api.py +362 -47
  90. nautobot/core/testing/filters.py +1 -1
  91. nautobot/core/testing/migrations.py +2 -0
  92. nautobot/core/testing/mixins.py +22 -9
  93. nautobot/core/testing/schema.py +2 -1
  94. nautobot/core/testing/views.py +21 -46
  95. nautobot/core/tests/integration/test_filters.py +17 -8
  96. nautobot/core/tests/integration/test_navbar.py +11 -34
  97. nautobot/core/tests/integration/test_plugin_navbar.py +9 -103
  98. nautobot/core/tests/nautobot_config.py +2 -3
  99. nautobot/core/tests/test_api.py +290 -21
  100. nautobot/core/tests/test_checks.py +0 -7
  101. nautobot/core/tests/test_filters.py +107 -59
  102. nautobot/core/tests/test_forms.py +26 -92
  103. nautobot/core/tests/test_graphql.py +110 -77
  104. nautobot/core/tests/test_logging.py +4 -0
  105. nautobot/core/tests/test_managers.py +3 -1
  106. nautobot/core/tests/test_models.py +2 -0
  107. nautobot/core/tests/test_paginator.py +3 -1
  108. nautobot/core/tests/test_releases.py +12 -12
  109. nautobot/core/tests/test_templatetags_helpers.py +4 -4
  110. nautobot/core/tests/test_utils.py +32 -68
  111. nautobot/core/tests/test_views.py +12 -15
  112. nautobot/core/utils/data.py +17 -0
  113. nautobot/core/utils/deprecation.py +9 -6
  114. nautobot/core/utils/filtering.py +8 -3
  115. nautobot/core/utils/git.py +12 -4
  116. nautobot/core/utils/lookup.py +3 -1
  117. nautobot/core/utils/requests.py +1 -104
  118. nautobot/core/views/__init__.py +1 -0
  119. nautobot/core/views/generic.py +75 -110
  120. nautobot/core/views/mixins.py +52 -61
  121. nautobot/core/views/renderers.py +6 -7
  122. nautobot/core/views/utils.py +80 -0
  123. nautobot/dcim/api/serializers.py +160 -667
  124. nautobot/dcim/api/urls.py +1 -1
  125. nautobot/dcim/api/views.py +7 -44
  126. nautobot/dcim/choices.py +2 -0
  127. nautobot/dcim/filters/__init__.py +21 -0
  128. nautobot/dcim/form_mixins.py +1 -27
  129. nautobot/dcim/forms.py +19 -765
  130. nautobot/dcim/migrations/0024_alter_device_and_rack_role_add_new_role.py +2 -1
  131. nautobot/dcim/migrations/0025_device_and_rack_roles_data_migrations.py +19 -13
  132. nautobot/dcim/migrations/0027_remove_device_role_and_rack_role.py +1 -1
  133. nautobot/dcim/migrations/0028_rename_foreignkey_fields.py +1 -1
  134. nautobot/dcim/migrations/0030_migrate_region_and_site_data_to_locations.py +2 -2
  135. nautobot/dcim/migrations/0035_related_name_changes.py +1 -1
  136. nautobot/dcim/migrations/0036_remove_region_and_site.py +1 -1
  137. nautobot/dcim/migrations/0040_tagsfield.py +109 -0
  138. nautobot/dcim/migrations/{0040_ipam__namespaces.py → 0041_ipam__namespaces.py} +1 -1
  139. nautobot/dcim/migrations/0042_fixup_null_statuses.py +51 -0
  140. nautobot/dcim/migrations/0043_status_nonnullable.py +72 -0
  141. nautobot/dcim/models/cables.py +3 -33
  142. nautobot/dcim/models/device_component_templates.py +6 -0
  143. nautobot/dcim/models/device_components.py +12 -198
  144. nautobot/dcim/models/devices.py +30 -143
  145. nautobot/dcim/models/locations.py +3 -64
  146. nautobot/dcim/models/power.py +3 -50
  147. nautobot/dcim/models/racks.py +7 -84
  148. nautobot/dcim/navigation.py +141 -467
  149. nautobot/dcim/signals.py +0 -2
  150. nautobot/dcim/tables/locations.py +2 -2
  151. nautobot/dcim/tables/power.py +1 -2
  152. nautobot/dcim/templates/dcim/console_port_connection_list.html +7 -0
  153. nautobot/dcim/templates/dcim/devicetype.html +2 -2
  154. nautobot/dcim/templates/dcim/interface_connection_list.html +7 -0
  155. nautobot/dcim/templates/dcim/location.html +16 -1
  156. nautobot/dcim/templates/dcim/locationtype.html +15 -0
  157. nautobot/dcim/templates/dcim/power_port_connection_list.html +7 -0
  158. nautobot/dcim/templates/dcim/rackgroup.html +0 -12
  159. nautobot/dcim/tests/test_api.py +166 -81
  160. nautobot/dcim/tests/test_cablepaths.py +41 -35
  161. nautobot/dcim/tests/test_filters.py +67 -23
  162. nautobot/dcim/tests/test_forms.py +5 -205
  163. nautobot/dcim/tests/test_graphql.py +7 -2
  164. nautobot/dcim/tests/test_migrations.py +6 -11
  165. nautobot/dcim/tests/test_models.py +182 -110
  166. nautobot/dcim/tests/test_natural_ordering.py +11 -8
  167. nautobot/dcim/tests/test_signals.py +6 -3
  168. nautobot/dcim/tests/test_views.py +197 -175
  169. nautobot/dcim/urls.py +11 -16
  170. nautobot/dcim/views.py +7 -134
  171. nautobot/docs/additional-features/caching.md +6 -87
  172. nautobot/docs/additional-features/job-scheduling-and-approvals.md +3 -0
  173. nautobot/docs/additional-features/jobs.md +177 -195
  174. nautobot/docs/administration/nautobot-server.md +6 -21
  175. nautobot/docs/administration/replicating-nautobot.md +0 -10
  176. nautobot/docs/configuration/optional-settings.md +32 -41
  177. nautobot/docs/configuration/required-settings.md +11 -52
  178. nautobot/docs/development/application-registry.md +2 -13
  179. nautobot/docs/development/extending-models.md +15 -17
  180. nautobot/docs/development/generic-views.md +0 -2
  181. nautobot/docs/development/getting-started.md +55 -5
  182. nautobot/docs/development/navigation-menu.md +22 -93
  183. nautobot/docs/development/react-ui.md +105 -0
  184. nautobot/docs/development/role-internals.md +1 -3
  185. nautobot/docs/development/style-guide.md +6 -4
  186. nautobot/docs/index.md +3 -2
  187. nautobot/docs/installation/migrating-from-netbox.md +11 -42
  188. nautobot/docs/installation/nautobot.md +1 -1
  189. nautobot/docs/installation/tables/v2-api-behavior-changes.yaml +70 -0
  190. nautobot/docs/installation/tables/v2-api-removed-fields.yaml +142 -0
  191. nautobot/docs/installation/tables/v2-api-renamed-fields.yaml +124 -0
  192. nautobot/docs/installation/tables/v2-code-location-changes.yaml +241 -0
  193. nautobot/docs/installation/tables/v2-code-removals.yaml +67 -0
  194. nautobot/docs/installation/tables/v2-database-behavior-changes.yaml +37 -0
  195. nautobot/docs/installation/tables/v2-database-removed-fields.yaml +166 -0
  196. nautobot/docs/installation/tables/v2-database-renamed-fields.yaml +340 -0
  197. nautobot/docs/installation/tables/v2-filters-corrected-fields.yaml +28 -0
  198. nautobot/docs/installation/tables/v2-filters-enhanced-fields.yaml +241 -0
  199. nautobot/docs/installation/tables/v2-filters-removed-fields.yaml +553 -0
  200. nautobot/docs/installation/tables/v2-filters-renamed-fields.yaml +223 -0
  201. nautobot/docs/installation/tables/v2-logging-renamed-loggers.yaml +23 -0
  202. nautobot/docs/installation/upgrading-from-nautobot-v1.md +170 -747
  203. nautobot/docs/models/dcim/device.md +3 -0
  204. nautobot/docs/models/dcim/deviceredundancygroup.md +3 -3
  205. nautobot/docs/models/extras/computedfield.md +4 -4
  206. nautobot/docs/models/extras/gitrepository.md +3 -0
  207. nautobot/docs/models/extras/job.md +1 -0
  208. nautobot/docs/models/extras/jobbutton.md +18 -13
  209. nautobot/docs/models/extras/jobhook.md +7 -4
  210. nautobot/docs/models/extras/jobresult.md +6 -2
  211. nautobot/docs/models/extras/relationship.md +2 -2
  212. nautobot/docs/models/extras/status.md +6 -19
  213. nautobot/docs/models/ipam/ipaddress.md +3 -0
  214. nautobot/docs/models/virtualization/virtualmachine.md +3 -0
  215. nautobot/docs/plugins/development.md +83 -21
  216. nautobot/docs/release-notes/version-1.5.md +53 -0
  217. nautobot/docs/release-notes/version-2.0.md +180 -0
  218. nautobot/docs/requirements.txt +1 -0
  219. nautobot/docs/rest-api/overview.md +384 -215
  220. nautobot/docs/rest-api/ui-related-endpoints.md +9 -0
  221. nautobot/extras/admin.py +3 -5
  222. nautobot/extras/api/customfields.py +15 -39
  223. nautobot/extras/api/fields.py +0 -11
  224. nautobot/extras/api/mixins.py +45 -0
  225. nautobot/extras/api/relationships.py +63 -158
  226. nautobot/extras/api/serializers.py +165 -700
  227. nautobot/extras/api/urls.py +1 -1
  228. nautobot/extras/api/views.py +294 -280
  229. nautobot/extras/apps.py +4 -7
  230. nautobot/extras/choices.py +11 -9
  231. nautobot/extras/constants.py +9 -3
  232. nautobot/extras/datasources/__init__.py +2 -0
  233. nautobot/extras/datasources/git.py +135 -186
  234. nautobot/extras/datasources/registry.py +25 -35
  235. nautobot/extras/filters/__init__.py +20 -19
  236. nautobot/extras/filters/mixins.py +4 -4
  237. nautobot/extras/forms/forms.py +63 -127
  238. nautobot/extras/forms/mixins.py +23 -51
  239. nautobot/extras/health_checks.py +0 -33
  240. nautobot/extras/jobs.py +387 -565
  241. nautobot/extras/management/commands/runjob.py +24 -62
  242. nautobot/extras/managers.py +30 -7
  243. nautobot/extras/migrations/0058_jobresult_add_time_status_idxs.py +38 -0
  244. nautobot/extras/migrations/{0058_joblogentry_scheduledjob_webhook_data_migration.py → 0059_joblogentry_scheduledjob_webhook_data_migration.py} +1 -1
  245. nautobot/extras/migrations/{0059_alter_joblogentry_scheduledjob_webhook_fields.py → 0060_alter_joblogentry_scheduledjob_webhook_fields.py} +1 -1
  246. nautobot/extras/migrations/{0060_role_and_alter_status.py → 0061_role_and_alter_status.py} +1 -7
  247. nautobot/extras/migrations/{0061_collect_roles_from_related_apps_roles.py → 0062_collect_roles_from_related_apps_roles.py} +33 -32
  248. nautobot/extras/migrations/{0062_alter_role_options.py → 0063_alter_role_options.py} +1 -1
  249. nautobot/extras/migrations/{0063_alter_configcontext_and_add_new_role.py → 0064_alter_configcontext_and_add_new_role.py} +1 -1
  250. nautobot/extras/migrations/0065_configcontext_data_migrations.py +44 -0
  251. nautobot/extras/migrations/{0065_rename_configcontext_role.py → 0066_rename_configcontext_role.py} +1 -1
  252. nautobot/extras/migrations/{0066_jobresult__add_celery_fields.py → 0067_jobresult__add_celery_fields.py} +36 -2
  253. nautobot/extras/migrations/{0067_created_datetime.py → 0068_created_datetime.py} +1 -1
  254. nautobot/extras/migrations/{0068_remove_site_and_region_attributes_from_config_context.py → 0069_remove_site_and_region_attributes_from_config_context.py} +1 -1
  255. nautobot/extras/migrations/{0069_replace_related_names.py → 0070_replace_related_names.py} +1 -1
  256. nautobot/extras/migrations/{0070_rename_model_fields.py → 0071_rename_model_fields.py} +1 -1
  257. nautobot/extras/migrations/0072_job__unique_name_data_migration.py +86 -0
  258. nautobot/extras/migrations/{0072_job__unique_name.py → 0073_job__unique_name.py} +13 -9
  259. nautobot/extras/migrations/{0073_remove_gitrepository_fields.py → 0074_remove_gitrepository_fields.py} +1 -1
  260. nautobot/extras/migrations/{0074_rename_slug_to_key_for_custom_field.py → 0075_rename_slug_to_key_for_custom_field.py} +1 -1
  261. nautobot/extras/migrations/{0075_migrate_custom_field_data.py → 0076_migrate_custom_field_data.py} +1 -1
  262. nautobot/extras/migrations/{0076_remove_name_field_and_make_label_field_non_nullable.py → 0077_remove_name_field_and_make_label_field_non_nullable.py} +1 -1
  263. nautobot/extras/migrations/{0077_remove_slug.py → 0078_remove_slug.py} +1 -5
  264. nautobot/extras/migrations/0079_tagsfield.py +28 -0
  265. nautobot/extras/migrations/0080_rename_relationship_slug_to_key.py +17 -0
  266. nautobot/extras/migrations/0081_rename_relationship_name_to_label.py +29 -0
  267. nautobot/extras/migrations/0082_ensure_relationship_keys_are_unique.py +43 -0
  268. nautobot/extras/migrations/0083_rename_computed_field_slug_to_key.py +21 -0
  269. nautobot/extras/migrations/0084_taggeditem_cleanup.py +43 -0
  270. nautobot/extras/migrations/0085_taggeditem_uniqueness.py +22 -0
  271. nautobot/extras/migrations/0086_job__celery_task_fields__dryrun_support.py +81 -0
  272. nautobot/extras/migrations/0087_job__commit_default_data_migration.py +26 -0
  273. nautobot/extras/migrations/0088_joblogentry__log_level_default.py +17 -0
  274. nautobot/extras/migrations/0089_joblogentry__log_level_data_migration.py +34 -0
  275. nautobot/extras/migrations/0090_scheduledjob__data_migration.py +57 -0
  276. nautobot/extras/models/__init__.py +2 -3
  277. nautobot/extras/models/change_logging.py +0 -36
  278. nautobot/extras/models/customfields.py +39 -33
  279. nautobot/extras/models/datasources.py +48 -50
  280. nautobot/extras/models/groups.py +5 -6
  281. nautobot/extras/models/jobs.py +189 -321
  282. nautobot/extras/models/mixins.py +0 -71
  283. nautobot/extras/models/models.py +0 -19
  284. nautobot/extras/models/relationships.py +19 -13
  285. nautobot/extras/models/roles.py +0 -34
  286. nautobot/extras/models/secrets.py +2 -26
  287. nautobot/extras/models/statuses.py +6 -5
  288. nautobot/extras/models/tags.py +2 -17
  289. nautobot/extras/navigation.py +89 -307
  290. nautobot/extras/plugins/__init__.py +3 -120
  291. nautobot/extras/plugins/utils.py +0 -3
  292. nautobot/extras/plugins/validators.py +5 -4
  293. nautobot/extras/plugins/views.py +16 -3
  294. nautobot/extras/querysets.py +1 -7
  295. nautobot/extras/registry.py +3 -0
  296. nautobot/extras/signals.py +26 -60
  297. nautobot/extras/tables.py +34 -40
  298. nautobot/extras/tasks.py +0 -12
  299. nautobot/extras/templates/extras/configcontext.html +1 -1
  300. nautobot/extras/templates/extras/configcontextschema.html +16 -1
  301. nautobot/extras/templates/extras/customfield.html +0 -13
  302. nautobot/extras/templates/extras/gitrepository.html +3 -3
  303. nautobot/extras/templates/extras/inc/jobresult.html +10 -0
  304. nautobot/extras/templates/extras/inc/panel_jobhistory.html +1 -1
  305. nautobot/extras/templates/extras/job.html +35 -25
  306. nautobot/extras/templates/extras/job_approval_request.html +15 -30
  307. nautobot/extras/templates/extras/job_detail.html +13 -31
  308. nautobot/extras/templates/extras/job_edit.html +15 -17
  309. nautobot/extras/templates/extras/jobresult.html +24 -6
  310. nautobot/extras/templates/extras/scheduledjob.html +2 -2
  311. nautobot/extras/templates/extras/secret.html +28 -0
  312. nautobot/extras/templatetags/job_buttons.py +1 -0
  313. nautobot/extras/{tests/example_jobs → test_jobs}/api_test_job.py +13 -6
  314. nautobot/extras/test_jobs/atomic_transaction.py +53 -0
  315. nautobot/extras/test_jobs/dry_run.py +29 -0
  316. nautobot/extras/{tests/example_jobs/test_duplicate_name.py → test_jobs/duplicate_name.py} +4 -0
  317. nautobot/extras/test_jobs/duplicate_name2.py +9 -0
  318. nautobot/extras/test_jobs/fail.py +23 -0
  319. nautobot/extras/{tests/example_jobs/test_field_default.py → test_jobs/field_default.py} +4 -0
  320. nautobot/extras/{tests/example_jobs/test_field_order.py → test_jobs/field_order.py} +4 -0
  321. nautobot/extras/{tests/example_jobs/test_file_upload_fail.py → test_jobs/file_upload_fail.py} +11 -6
  322. nautobot/extras/test_jobs/file_upload_pass.py +25 -0
  323. nautobot/extras/test_jobs/has_sensitive_variables.py +25 -0
  324. nautobot/extras/test_jobs/ipaddress_vars.py +66 -0
  325. nautobot/extras/test_jobs/job_button_receiver.py +28 -0
  326. nautobot/extras/test_jobs/job_hook_receiver.py +29 -0
  327. nautobot/extras/test_jobs/job_variables.py +88 -0
  328. nautobot/extras/test_jobs/location_with_custom_field.py +45 -0
  329. nautobot/extras/test_jobs/log_redaction.py +20 -0
  330. nautobot/extras/test_jobs/log_skip_db_logging.py +17 -0
  331. nautobot/extras/test_jobs/modify_db.py +25 -0
  332. nautobot/extras/{tests/example_jobs/test_no_field_order.py → test_jobs/no_field_order.py} +4 -0
  333. nautobot/extras/test_jobs/object_var_optional.py +21 -0
  334. nautobot/extras/test_jobs/object_var_required.py +21 -0
  335. nautobot/extras/test_jobs/object_vars.py +26 -0
  336. nautobot/extras/test_jobs/pass.py +25 -0
  337. nautobot/extras/test_jobs/profiling.py +32 -0
  338. nautobot/extras/test_jobs/read_only_job.py +15 -0
  339. nautobot/extras/{tests/example_jobs/test_required_args.py → test_jobs/required_args.py} +4 -0
  340. nautobot/extras/{tests/example_jobs/test_soft_time_limit_greater_than_time_limit.py → test_jobs/soft_time_limit_greater_than_time_limit.py} +5 -1
  341. nautobot/extras/{tests/example_jobs/test_task_queues.py → test_jobs/task_queues.py} +5 -1
  342. nautobot/extras/tests/integration/test_computedfields.py +1 -1
  343. nautobot/extras/tests/integration/test_configcontextschema.py +5 -3
  344. nautobot/extras/tests/integration/test_customfields.py +4 -2
  345. nautobot/extras/tests/integration/test_dynamicgroups.py +1 -1
  346. nautobot/extras/tests/integration/test_jobs.py +25 -27
  347. nautobot/extras/tests/integration/test_notes.py +8 -4
  348. nautobot/extras/tests/integration/test_relationships.py +2 -2
  349. nautobot/extras/tests/test_api.py +649 -642
  350. nautobot/extras/tests/test_changelog.py +3 -3
  351. nautobot/extras/tests/test_context_managers.py +5 -3
  352. nautobot/extras/tests/test_customfields.py +92 -50
  353. nautobot/extras/tests/test_datasources.py +189 -112
  354. nautobot/extras/tests/test_dynamicgroups.py +7 -8
  355. nautobot/extras/tests/test_filters.py +137 -89
  356. nautobot/extras/tests/test_forms.py +73 -75
  357. nautobot/extras/tests/{test_scripts.py → test_job_variables.py} +43 -49
  358. nautobot/extras/tests/test_jobs.py +262 -263
  359. nautobot/extras/tests/test_migrations.py +4 -3
  360. nautobot/extras/tests/test_models.py +116 -161
  361. nautobot/extras/tests/test_plugins.py +38 -60
  362. nautobot/extras/tests/test_relationships.py +167 -120
  363. nautobot/extras/tests/test_tags.py +6 -11
  364. nautobot/extras/tests/test_utils.py +31 -1
  365. nautobot/extras/tests/test_views.py +201 -145
  366. nautobot/extras/tests/test_webhooks.py +6 -2
  367. nautobot/extras/urls.py +42 -42
  368. nautobot/extras/utils.py +137 -163
  369. nautobot/extras/views.py +78 -152
  370. nautobot/ipam/api/fields.py +17 -0
  371. nautobot/ipam/api/serializers.py +58 -164
  372. nautobot/ipam/api/urls.py +1 -1
  373. nautobot/ipam/api/views.py +3 -2
  374. nautobot/ipam/apps.py +1 -2
  375. nautobot/ipam/filters.py +1 -10
  376. nautobot/ipam/forms.py +4 -177
  377. nautobot/ipam/lookups.py +1 -0
  378. nautobot/ipam/management/commands/__init__.py +0 -0
  379. nautobot/ipam/management/commands/fix_prefix_broadcast.py +17 -0
  380. nautobot/ipam/migrations/0010_alter_ipam_role_add_new_role.py +1 -1
  381. nautobot/ipam/migrations/0011_migrate_ipam_role_data.py +32 -38
  382. nautobot/ipam/migrations/0020_related_name_changes.py +1 -1
  383. nautobot/ipam/migrations/0022_aggregate_to_prefix_data_migration.py +2 -2
  384. nautobot/ipam/migrations/0028_tagsfield.py +44 -0
  385. nautobot/ipam/migrations/0029_ip_address_to_interface_uniqueness_constraints.py +18 -0
  386. nautobot/ipam/migrations/{0028_ipam__namespaces.py → 0030_ipam__namespaces.py} +77 -28
  387. nautobot/ipam/migrations/0031_ipam__prefix__add_parent.py +58 -0
  388. nautobot/ipam/migrations/0032_ipam__namespaces_finish.py +63 -0
  389. nautobot/ipam/migrations/0033_fixup_null_statuses.py +26 -0
  390. nautobot/ipam/migrations/0034_status_nonnullable.py +36 -0
  391. nautobot/ipam/models.py +100 -236
  392. nautobot/ipam/navigation.py +36 -181
  393. nautobot/ipam/querysets.py +20 -25
  394. nautobot/ipam/signals.py +49 -6
  395. nautobot/ipam/tables.py +10 -3
  396. nautobot/ipam/templates/ipam/namespace_ipaddresses.html +11 -0
  397. nautobot/ipam/templates/ipam/namespace_prefixes.html +11 -0
  398. nautobot/ipam/templates/ipam/namespace_retrieve.html +17 -4
  399. nautobot/ipam/templates/ipam/namespace_vrfs.html +11 -0
  400. nautobot/ipam/templates/ipam/prefix.html +1 -1
  401. nautobot/ipam/templates/ipam/vlangroup.html +0 -13
  402. nautobot/ipam/templates/ipam/vrf_edit.html +6 -0
  403. nautobot/ipam/tests/integration/test_prefixes.py +3 -26
  404. nautobot/ipam/tests/test_api.py +22 -19
  405. nautobot/ipam/tests/test_filters.py +59 -23
  406. nautobot/ipam/tests/test_migrations.py +6 -10
  407. nautobot/ipam/tests/test_models.py +323 -198
  408. nautobot/ipam/tests/test_ordering.py +2 -2
  409. nautobot/ipam/tests/test_querysets.py +44 -24
  410. nautobot/ipam/tests/test_views.py +73 -26
  411. nautobot/ipam/urls.py +16 -0
  412. nautobot/ipam/{utils.py → utils/__init__.py} +2 -2
  413. nautobot/ipam/utils/migrations.py +713 -0
  414. nautobot/ipam/views.py +137 -20
  415. nautobot/project-static/docs/404.html +1178 -10
  416. nautobot/project-static/docs/additional-features/caching.html +1224 -159
  417. nautobot/project-static/docs/additional-features/change-logging.html +1180 -12
  418. nautobot/project-static/docs/additional-features/config-contexts.html +1180 -12
  419. nautobot/project-static/docs/additional-features/graphql.html +1179 -11
  420. nautobot/project-static/docs/additional-features/healthcheck.html +1180 -12
  421. nautobot/project-static/docs/additional-features/job-scheduling-and-approvals.html +1184 -12
  422. nautobot/project-static/docs/additional-features/jobs.html +1514 -328
  423. nautobot/project-static/docs/additional-features/napalm.html +1180 -12
  424. nautobot/project-static/docs/additional-features/prometheus-metrics.html +1180 -12
  425. nautobot/project-static/docs/additional-features/template-filters.html +1180 -12
  426. nautobot/project-static/docs/administration/celery-queues.html +1178 -10
  427. nautobot/project-static/docs/administration/nautobot-server.html +1451 -304
  428. nautobot/project-static/docs/administration/nautobot-shell.html +1178 -10
  429. nautobot/project-static/docs/administration/permissions.html +1178 -10
  430. nautobot/project-static/docs/administration/replicating-nautobot.html +1262 -113
  431. nautobot/project-static/docs/apps/index.html +1178 -10
  432. nautobot/project-static/docs/apps/nautobot-apps.html +1178 -10
  433. nautobot/project-static/docs/code-reference/nautobot/apps/__init__.html +1580 -426
  434. nautobot/project-static/docs/code-reference/nautobot/apps/admin.html +1178 -10
  435. nautobot/project-static/docs/code-reference/nautobot/apps/api.html +3481 -1838
  436. nautobot/project-static/docs/code-reference/nautobot/apps/choices.html +1178 -10
  437. nautobot/project-static/docs/code-reference/nautobot/apps/config.html +1178 -10
  438. nautobot/project-static/docs/code-reference/nautobot/apps/datasources.html +1185 -11
  439. nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +1719 -551
  440. nautobot/project-static/docs/code-reference/nautobot/apps/forms.html +2062 -930
  441. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +1946 -659
  442. nautobot/project-static/docs/code-reference/nautobot/apps/secrets.html +1180 -12
  443. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +1189 -21
  444. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +9283 -6218
  445. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +2734 -2122
  446. nautobot/project-static/docs/code-reference/nautobot/apps/urls.html +1178 -10
  447. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +2337 -1300
  448. nautobot/project-static/docs/configuration/authentication/ldap.html +1178 -10
  449. nautobot/project-static/docs/configuration/authentication/remote.html +1178 -10
  450. nautobot/project-static/docs/configuration/authentication/sso.html +1178 -10
  451. nautobot/project-static/docs/configuration/index.html +1178 -10
  452. nautobot/project-static/docs/configuration/optional-settings.html +1311 -160
  453. nautobot/project-static/docs/configuration/required-settings.html +1312 -211
  454. nautobot/project-static/docs/core-functionality/circuits.html +1178 -10
  455. nautobot/project-static/docs/core-functionality/device-types.html +1178 -10
  456. nautobot/project-static/docs/core-functionality/devices.html +1182 -10
  457. nautobot/project-static/docs/core-functionality/ipam.html +1182 -10
  458. nautobot/project-static/docs/core-functionality/power.html +1178 -10
  459. nautobot/project-static/docs/core-functionality/secrets.html +1178 -10
  460. nautobot/project-static/docs/core-functionality/services.html +1178 -10
  461. nautobot/project-static/docs/core-functionality/sites-and-racks.html +1178 -10
  462. nautobot/project-static/docs/core-functionality/tenancy.html +1178 -10
  463. nautobot/project-static/docs/core-functionality/virtualization.html +1182 -10
  464. nautobot/project-static/docs/core-functionality/vlans.html +1179 -11
  465. nautobot/project-static/docs/development/application-registry.html +1190 -42
  466. nautobot/project-static/docs/development/best-practices.html +1178 -10
  467. nautobot/project-static/docs/development/docker-compose-advanced-use-cases.html +1178 -10
  468. nautobot/project-static/docs/development/extending-models.html +1238 -83
  469. nautobot/project-static/docs/development/generic-views.html +1180 -14
  470. nautobot/project-static/docs/development/getting-started.html +1365 -90
  471. nautobot/project-static/docs/development/homepage.html +1178 -10
  472. nautobot/project-static/docs/development/index.html +1178 -10
  473. nautobot/project-static/docs/development/model-features.html +1178 -10
  474. nautobot/project-static/docs/development/natural-keys.html +1178 -10
  475. nautobot/project-static/docs/development/navigation-menu.html +1215 -125
  476. nautobot/project-static/docs/development/react-ui.html +4199 -0
  477. nautobot/project-static/docs/development/release-checklist.html +1178 -10
  478. nautobot/project-static/docs/development/role-internals.html +1179 -12
  479. nautobot/project-static/docs/development/style-guide.html +1188 -19
  480. nautobot/project-static/docs/development/templates.html +1178 -10
  481. nautobot/project-static/docs/development/testing.html +1178 -10
  482. nautobot/project-static/docs/development/user-preferences.html +1178 -10
  483. nautobot/project-static/docs/docker/index.html +1178 -10
  484. nautobot/project-static/docs/index.html +1183 -12
  485. nautobot/project-static/docs/installation/centos.html +1178 -10
  486. nautobot/project-static/docs/installation/external-authentication.html +1178 -10
  487. nautobot/project-static/docs/installation/http-server.html +1178 -10
  488. nautobot/project-static/docs/installation/index.html +1178 -10
  489. nautobot/project-static/docs/installation/migrating-from-netbox.html +1305 -189
  490. nautobot/project-static/docs/installation/migrating-from-postgresql.html +1178 -10
  491. nautobot/project-static/docs/installation/nautobot.html +1179 -11
  492. nautobot/project-static/docs/installation/region-and-site-data-migration-guide.html +1178 -10
  493. nautobot/project-static/docs/installation/selinux-troubleshooting.html +1178 -10
  494. nautobot/project-static/docs/installation/services.html +1178 -10
  495. nautobot/project-static/docs/installation/tables/v2-api-behavior-changes.yaml +70 -0
  496. nautobot/project-static/docs/installation/tables/v2-api-removed-fields.yaml +142 -0
  497. nautobot/project-static/docs/installation/tables/v2-api-renamed-fields.yaml +124 -0
  498. nautobot/project-static/docs/installation/tables/v2-code-location-changes.yaml +241 -0
  499. nautobot/project-static/docs/installation/tables/v2-code-removals.yaml +67 -0
  500. nautobot/project-static/docs/installation/tables/v2-database-behavior-changes.yaml +37 -0
  501. nautobot/project-static/docs/installation/tables/v2-database-removed-fields.yaml +166 -0
  502. nautobot/project-static/docs/installation/tables/v2-database-renamed-fields.yaml +340 -0
  503. nautobot/project-static/docs/installation/tables/v2-filters-corrected-fields.yaml +28 -0
  504. nautobot/project-static/docs/installation/tables/v2-filters-enhanced-fields.yaml +241 -0
  505. nautobot/project-static/docs/installation/tables/v2-filters-removed-fields.yaml +553 -0
  506. nautobot/project-static/docs/installation/tables/v2-filters-renamed-fields.yaml +223 -0
  507. nautobot/project-static/docs/installation/tables/v2-logging-renamed-loggers.yaml +23 -0
  508. nautobot/project-static/docs/installation/ubuntu.html +1178 -10
  509. nautobot/project-static/docs/installation/upgrading-from-nautobot-v1.html +3823 -2152
  510. nautobot/project-static/docs/installation/upgrading.html +1178 -10
  511. nautobot/project-static/docs/models/circuits/circuit.html +1293 -103
  512. nautobot/project-static/docs/models/circuits/circuittermination.html +1293 -103
  513. nautobot/project-static/docs/models/circuits/circuittype.html +1293 -103
  514. nautobot/project-static/docs/models/circuits/provider.html +1293 -103
  515. nautobot/project-static/docs/models/circuits/providernetwork.html +1293 -103
  516. nautobot/project-static/docs/models/dcim/cable.html +1324 -103
  517. nautobot/project-static/docs/models/dcim/consoleport.html +1293 -103
  518. nautobot/project-static/docs/models/dcim/consoleporttemplate.html +1293 -103
  519. nautobot/project-static/docs/models/dcim/consoleserverport.html +1293 -103
  520. nautobot/project-static/docs/models/dcim/consoleserverporttemplate.html +1293 -103
  521. nautobot/project-static/docs/models/dcim/device.html +1326 -132
  522. nautobot/project-static/docs/models/dcim/devicebay.html +1293 -103
  523. nautobot/project-static/docs/models/dcim/devicebaytemplate.html +1293 -103
  524. nautobot/project-static/docs/models/dcim/deviceredundancygroup.html +1379 -97
  525. nautobot/project-static/docs/models/dcim/devicetype.html +1293 -103
  526. nautobot/project-static/docs/models/dcim/frontport.html +1293 -103
  527. nautobot/project-static/docs/models/dcim/frontporttemplate.html +1293 -103
  528. nautobot/project-static/docs/models/dcim/interface.html +1293 -103
  529. nautobot/project-static/docs/models/dcim/interfacetemplate.html +1293 -103
  530. nautobot/project-static/docs/models/dcim/inventoryitem.html +1293 -103
  531. nautobot/project-static/docs/models/dcim/location.html +1293 -103
  532. nautobot/project-static/docs/models/dcim/locationtype.html +1293 -103
  533. nautobot/project-static/docs/models/dcim/manufacturer.html +1292 -102
  534. nautobot/project-static/docs/models/dcim/platform.html +1272 -82
  535. nautobot/project-static/docs/models/dcim/powerfeed.html +1270 -80
  536. nautobot/project-static/docs/models/dcim/poweroutlet.html +1272 -82
  537. nautobot/project-static/docs/models/dcim/poweroutlettemplate.html +1272 -82
  538. nautobot/project-static/docs/models/dcim/powerpanel.html +1270 -80
  539. nautobot/project-static/docs/models/dcim/powerport.html +1272 -82
  540. nautobot/project-static/docs/models/dcim/powerporttemplate.html +1272 -82
  541. nautobot/project-static/docs/models/dcim/rack.html +1272 -82
  542. nautobot/project-static/docs/models/dcim/rackgroup.html +1272 -82
  543. nautobot/project-static/docs/models/dcim/rackreservation.html +1272 -82
  544. nautobot/project-static/docs/models/dcim/rearport.html +1286 -96
  545. nautobot/project-static/docs/models/dcim/rearporttemplate.html +1286 -96
  546. nautobot/project-static/docs/models/dcim/region.html +1178 -10
  547. nautobot/project-static/docs/models/dcim/site.html +1178 -10
  548. nautobot/project-static/docs/models/dcim/virtualchassis.html +1284 -94
  549. nautobot/project-static/docs/models/extras/computedfield.html +1184 -16
  550. nautobot/project-static/docs/models/extras/configcontext.html +1314 -86
  551. nautobot/project-static/docs/models/extras/configcontextschema.html +1276 -86
  552. nautobot/project-static/docs/models/extras/customfield.html +1180 -12
  553. nautobot/project-static/docs/models/extras/customlink.html +1180 -12
  554. nautobot/project-static/docs/models/extras/dynamicgroup.html +1180 -12
  555. nautobot/project-static/docs/models/extras/exporttemplate.html +1180 -12
  556. nautobot/project-static/docs/models/extras/gitrepository.html +1184 -12
  557. nautobot/project-static/docs/models/extras/graphqlquery.html +1321 -86
  558. nautobot/project-static/docs/models/extras/imageattachment.html +1276 -86
  559. nautobot/project-static/docs/models/extras/job.html +1277 -86
  560. nautobot/project-static/docs/models/extras/jobbutton.html +1201 -29
  561. nautobot/project-static/docs/models/extras/jobhook.html +1188 -16
  562. nautobot/project-static/docs/models/extras/joblogentry.html +1274 -84
  563. nautobot/project-static/docs/models/extras/jobresult.html +1364 -169
  564. nautobot/project-static/docs/models/extras/note.html +1180 -12
  565. nautobot/project-static/docs/models/extras/relationship.html +1182 -14
  566. nautobot/project-static/docs/models/extras/role.html +1320 -86
  567. nautobot/project-static/docs/models/extras/secret.html +1314 -86
  568. nautobot/project-static/docs/models/extras/secretsgroup.html +1276 -86
  569. nautobot/project-static/docs/models/extras/status.html +1188 -59
  570. nautobot/project-static/docs/models/extras/tag.html +1180 -12
  571. nautobot/project-static/docs/models/extras/webhook.html +1180 -12
  572. nautobot/project-static/docs/models/ipam/ipaddress.html +1327 -102
  573. nautobot/project-static/docs/models/ipam/prefix.html +1276 -86
  574. nautobot/project-static/docs/models/ipam/rir.html +1276 -86
  575. nautobot/project-static/docs/models/ipam/routetarget.html +1276 -86
  576. nautobot/project-static/docs/models/ipam/service.html +1276 -86
  577. nautobot/project-static/docs/models/ipam/vlan.html +1276 -86
  578. nautobot/project-static/docs/models/ipam/vlangroup.html +1276 -86
  579. nautobot/project-static/docs/models/ipam/vrf.html +1276 -86
  580. nautobot/project-static/docs/models/tenancy/tenant.html +1276 -86
  581. nautobot/project-static/docs/models/tenancy/tenantgroup.html +1276 -86
  582. nautobot/project-static/docs/models/users/objectpermission.html +1314 -86
  583. nautobot/project-static/docs/models/users/token.html +1276 -86
  584. nautobot/project-static/docs/models/virtualization/cluster.html +1276 -86
  585. nautobot/project-static/docs/models/virtualization/clustergroup.html +1276 -86
  586. nautobot/project-static/docs/models/virtualization/clustertype.html +1276 -86
  587. nautobot/project-static/docs/models/virtualization/virtualmachine.html +1321 -127
  588. nautobot/project-static/docs/models/virtualization/vminterface.html +1276 -86
  589. nautobot/project-static/docs/objects.inv +0 -0
  590. nautobot/project-static/docs/plugins/development.html +1726 -495
  591. nautobot/project-static/docs/plugins/index.html +1178 -10
  592. nautobot/project-static/docs/plugins/porting-from-netbox.html +1178 -10
  593. nautobot/project-static/docs/release-notes/index.html +1178 -10
  594. nautobot/project-static/docs/release-notes/version-1.0.html +1178 -10
  595. nautobot/project-static/docs/release-notes/version-1.1.html +1178 -10
  596. nautobot/project-static/docs/release-notes/version-1.2.html +1178 -10
  597. nautobot/project-static/docs/release-notes/version-1.3.html +1178 -10
  598. nautobot/project-static/docs/release-notes/version-1.4.html +1178 -10
  599. nautobot/project-static/docs/release-notes/version-1.5.html +1608 -225
  600. nautobot/project-static/docs/release-notes/version-2.0.html +1547 -47
  601. nautobot/project-static/docs/requirements.txt +1 -0
  602. nautobot/project-static/docs/rest-api/authentication.html +1179 -11
  603. nautobot/project-static/docs/rest-api/filtering.html +1178 -10
  604. nautobot/project-static/docs/rest-api/overview.html +1841 -446
  605. nautobot/project-static/docs/rest-api/ui-related-endpoints.html +4057 -0
  606. nautobot/project-static/docs/search/search_index.json +1 -1
  607. nautobot/project-static/docs/sitemap.xml +197 -187
  608. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  609. nautobot/project-static/docs/user-guides/custom-fields.html +1178 -10
  610. nautobot/project-static/docs/user-guides/getting-started/creating-devices.html +1178 -10
  611. nautobot/project-static/docs/user-guides/getting-started/index.html +1178 -10
  612. nautobot/project-static/docs/user-guides/getting-started/interfaces.html +1178 -10
  613. nautobot/project-static/docs/user-guides/getting-started/ipam.html +1178 -10
  614. nautobot/project-static/docs/user-guides/getting-started/platforms.html +1178 -10
  615. nautobot/project-static/docs/user-guides/getting-started/regions.html +1178 -10
  616. nautobot/project-static/docs/user-guides/getting-started/search-bar.html +1178 -10
  617. nautobot/project-static/docs/user-guides/getting-started/tenants.html +1178 -10
  618. nautobot/project-static/docs/user-guides/getting-started/vlans-and-vlan-groups.html +1178 -10
  619. nautobot/project-static/docs/user-guides/git-data-source.html +1178 -10
  620. nautobot/project-static/docs/user-guides/graphql.html +1178 -10
  621. nautobot/project-static/docs/user-guides/relationships.html +1178 -10
  622. nautobot/project-static/docs/user-guides/s3-django-storage.html +1178 -10
  623. nautobot/project-static/js/forms.js +16 -9
  624. nautobot/project-static/js/theme.js +5 -0
  625. nautobot/tenancy/api/serializers.py +4 -32
  626. nautobot/tenancy/api/urls.py +1 -1
  627. nautobot/tenancy/forms.py +0 -28
  628. nautobot/tenancy/migrations/0008_tagsfield.py +19 -0
  629. nautobot/tenancy/models.py +0 -25
  630. nautobot/tenancy/navigation.py +6 -39
  631. nautobot/tenancy/templates/tenancy/tenant.html +12 -12
  632. nautobot/tenancy/templates/tenancy/tenantgroup.html +1 -1
  633. nautobot/tenancy/tests/test_api.py +1 -3
  634. nautobot/tenancy/tests/test_filters.py +10 -5
  635. nautobot/tenancy/views.py +0 -2
  636. nautobot/ui/.eslintignore +6 -0
  637. nautobot/ui/.gitignore +10 -0
  638. nautobot/ui/.prettierignore +9 -0
  639. nautobot/ui/.prettierrc +4 -0
  640. nautobot/ui/README.md +33 -0
  641. nautobot/ui/app_imports.js.j2 +7 -0
  642. nautobot/ui/craco.config.js +46 -0
  643. nautobot/ui/jsconfig-base.json +11 -0
  644. nautobot/ui/jsconfig.json +5 -0
  645. nautobot/ui/lib/nautobot-craco-alias-plugin.js +40 -0
  646. nautobot/ui/package-lock.json +21451 -0
  647. nautobot/ui/package.json +70 -0
  648. nautobot/ui/public/index.html +47 -0
  649. nautobot/ui/public/logo192.png +0 -0
  650. nautobot/ui/public/logo512.png +0 -0
  651. nautobot/ui/public/manifest.json +25 -0
  652. nautobot/ui/public/nautobot_logo.svg +131 -0
  653. nautobot/ui/public/robots.txt +3 -0
  654. nautobot/ui/src/App.js +71 -0
  655. nautobot/ui/src/components/AppFullWidthComponents.js +8 -0
  656. nautobot/ui/src/components/AppTab.js +40 -0
  657. nautobot/ui/src/components/Apps.js +60 -0
  658. nautobot/ui/src/components/HomeChangelogPanel.js +98 -0
  659. nautobot/ui/src/components/HomePanel.js +58 -0
  660. nautobot/ui/src/components/JobHistoryTable.js +78 -0
  661. nautobot/ui/src/components/Layout.js +53 -0
  662. nautobot/ui/src/components/LoadingWidget.js +25 -0
  663. nautobot/ui/src/components/Navbar.js +116 -0
  664. nautobot/ui/src/components/NotificationPopover.js +27 -0
  665. nautobot/ui/src/components/ObjectListTable.js +209 -0
  666. nautobot/ui/src/components/ReferenceDataTag.js +35 -0
  667. nautobot/ui/src/components/RouterButton.js +10 -0
  668. nautobot/ui/src/components/RouterLink.js +10 -0
  669. nautobot/ui/src/components/SidebarNav.js +147 -0
  670. nautobot/ui/src/components/Table.js +48 -0
  671. nautobot/ui/src/components/TableItem.js +71 -0
  672. nautobot/ui/src/components/__tests__/AppFullWidthComponents.test.js +16 -0
  673. nautobot/ui/src/components/__tests__/AppTab.test.js +21 -0
  674. nautobot/ui/src/components/__tests__/Apps.test.js +14 -0
  675. nautobot/ui/src/components/__tests__/Layout.test.js +33 -0
  676. nautobot/ui/src/components/__tests__/Table.test.js +36 -0
  677. nautobot/ui/src/components/__tests__/TableItem.test.js +37 -0
  678. nautobot/ui/src/components/__tests__/paginator.test.js +43 -0
  679. nautobot/ui/src/components/__tests__/paginator_form.test.js +13 -0
  680. nautobot/ui/src/components/pagination.js +93 -0
  681. nautobot/ui/src/components/paginator.js +79 -0
  682. nautobot/ui/src/components/paginator_form.js +43 -0
  683. nautobot/ui/src/components/usePagination.js +57 -0
  684. nautobot/ui/src/constants/apiPath.js +10 -0
  685. nautobot/ui/src/constants/icons.js +15 -0
  686. nautobot/ui/src/constants/size.js +15 -0
  687. nautobot/ui/src/index.js +65 -0
  688. nautobot/ui/src/reportWebVitals.js +15 -0
  689. nautobot/ui/src/router.js +77 -0
  690. nautobot/ui/src/utils/api.js +131 -0
  691. nautobot/ui/src/utils/app-import.js +15 -0
  692. nautobot/ui/src/utils/color.js +15 -0
  693. nautobot/ui/src/utils/date.js +14 -0
  694. nautobot/ui/src/utils/index.js +15 -0
  695. nautobot/ui/src/utils/navigation.js +32 -0
  696. nautobot/ui/src/utils/session.js +64 -0
  697. nautobot/ui/src/utils/store.js +242 -0
  698. nautobot/ui/src/utils/string.js +6 -0
  699. nautobot/ui/src/utils/url.js +4 -0
  700. nautobot/ui/src/views/Home.js +138 -0
  701. nautobot/ui/src/views/InstalledApps.js +80 -0
  702. nautobot/ui/src/views/Login.js +48 -0
  703. nautobot/ui/src/views/Logout.js +20 -0
  704. nautobot/ui/src/views/__tests__/BSCreateViewTemplate.test.js +11 -0
  705. nautobot/ui/src/views/__tests__/BSListViewTemplate.test.js +107 -0
  706. nautobot/ui/src/views/__tests__/Login.test.js +15 -0
  707. nautobot/ui/src/views/generic/GenericView.js +142 -0
  708. nautobot/ui/src/views/generic/ObjectCreate.js +96 -0
  709. nautobot/ui/src/views/generic/ObjectList.js +127 -0
  710. nautobot/ui/src/views/generic/ObjectRetrieve.js +551 -0
  711. nautobot/users/admin.py +1 -1
  712. nautobot/users/api/serializers.py +51 -61
  713. nautobot/users/api/urls.py +1 -1
  714. nautobot/users/api/views.py +53 -2
  715. nautobot/users/tests/test_api.py +110 -25
  716. nautobot/virtualization/api/serializers.py +18 -130
  717. nautobot/virtualization/api/urls.py +1 -1
  718. nautobot/virtualization/api/views.py +1 -22
  719. nautobot/virtualization/forms.py +13 -99
  720. nautobot/virtualization/migrations/0012_alter_virtualmachine_role_add_new_role.py +1 -1
  721. nautobot/virtualization/migrations/0013_migrate_virtualmachine_role_data.py +18 -11
  722. nautobot/virtualization/migrations/0015_rename_foreignkey_fields.py +1 -1
  723. nautobot/virtualization/migrations/0018_related_name_changes.py +1 -1
  724. nautobot/virtualization/migrations/0021_tagsfield_and_vminterface_to_primarymodel.py +39 -0
  725. nautobot/virtualization/migrations/0022_vminterface_timestamps_data_migration.py +17 -0
  726. nautobot/virtualization/migrations/{0021_ipam__namespaces.py → 0023_ipam__namespaces.py} +2 -2
  727. nautobot/virtualization/migrations/0024_fixup_null_statuses.py +25 -0
  728. nautobot/virtualization/migrations/0025_status_nonnullable.py +29 -0
  729. nautobot/virtualization/models.py +31 -123
  730. nautobot/virtualization/navigation.py +18 -99
  731. nautobot/virtualization/templates/virtualization/virtualmachine.html +2 -1
  732. nautobot/virtualization/templates/virtualization/virtualmachine_edit.html +6 -0
  733. nautobot/virtualization/tests/test_api.py +25 -26
  734. nautobot/virtualization/tests/test_filters.py +41 -15
  735. nautobot/virtualization/tests/test_models.py +31 -7
  736. nautobot/virtualization/tests/test_views.py +42 -25
  737. nautobot/virtualization/views.py +7 -6
  738. {nautobot-2.0.0a3.dist-info → nautobot-2.0.0b1.dist-info}/METADATA +3 -7
  739. {nautobot-2.0.0a3.dist-info → nautobot-2.0.0b1.dist-info}/RECORD +744 -602
  740. {nautobot-2.0.0a3.dist-info → nautobot-2.0.0b1.dist-info}/WHEEL +1 -1
  741. nautobot/circuits/api/nested_serializers.py +0 -69
  742. nautobot/core/templates/plugin_template/navigation.py-tpl +0 -22
  743. nautobot/dcim/api/nested_serializers.py +0 -356
  744. nautobot/dcim/templates/dcim/device_import.html +0 -5
  745. nautobot/dcim/templates/dcim/device_import_child.html +0 -5
  746. nautobot/dcim/templates/dcim/inc/device_import_header.html +0 -4
  747. nautobot/extras/api/nested_serializers.py +0 -353
  748. nautobot/extras/migrations/0064_configcontext_data_migrations.py +0 -41
  749. nautobot/extras/migrations/0071_job__unique_name_data_migration.py +0 -46
  750. nautobot/extras/reports.py +0 -60
  751. nautobot/extras/scripts.py +0 -72
  752. nautobot/extras/tests/example_jobs/script_variables.py +0 -67
  753. nautobot/extras/tests/example_jobs/test_duplicate_name2.py +0 -5
  754. nautobot/extras/tests/example_jobs/test_fail.py +0 -16
  755. nautobot/extras/tests/example_jobs/test_file_upload_pass.py +0 -20
  756. nautobot/extras/tests/example_jobs/test_ipaddress_vars.py +0 -52
  757. nautobot/extras/tests/example_jobs/test_job_button_receiver.py +0 -21
  758. nautobot/extras/tests/example_jobs/test_job_hook_receiver.py +0 -20
  759. nautobot/extras/tests/example_jobs/test_location_with_custom_field.py +0 -35
  760. nautobot/extras/tests/example_jobs/test_log_redaction.py +0 -14
  761. nautobot/extras/tests/example_jobs/test_modify_db.py +0 -18
  762. nautobot/extras/tests/example_jobs/test_object_var_optional.py +0 -14
  763. nautobot/extras/tests/example_jobs/test_object_var_required.py +0 -14
  764. nautobot/extras/tests/example_jobs/test_object_vars.py +0 -29
  765. nautobot/extras/tests/example_jobs/test_pass.py +0 -19
  766. nautobot/extras/tests/example_jobs/test_read_only_fail.py +0 -24
  767. nautobot/extras/tests/example_jobs/test_read_only_no_commit_field.py +0 -10
  768. nautobot/extras/tests/example_jobs/test_read_only_pass.py +0 -22
  769. nautobot/ipam/api/nested_serializers.py +0 -159
  770. nautobot/ipam/migrations/0029_ipam__prefix__add_parent.py +0 -31
  771. nautobot/ipam/migrations/0030_ipam__prefix__data_migration.py +0 -13
  772. nautobot/ipam/migrations/0031_ipam__ipaddress__add_parent.py +0 -41
  773. nautobot/ipam/migrations/0032_ipam__ipaddress__data_migration.py +0 -11
  774. nautobot/tenancy/api/nested_serializers.py +0 -31
  775. nautobot/users/api/nested_serializers.py +0 -67
  776. nautobot/virtualization/api/nested_serializers.py +0 -65
  777. /nautobot/extras/{tests/example_jobs → test_jobs}/__init__.py +0 -0
  778. /nautobot/{dcim/models/sites.py → ipam/management/__init__.py} +0 -0
  779. {nautobot-2.0.0a3.dist-info → nautobot-2.0.0b1.dist-info}/LICENSE.txt +0 -0
  780. {nautobot-2.0.0a3.dist-info → nautobot-2.0.0b1.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  from datetime import datetime, timedelta
2
2
  import uuid
3
+ import tempfile
3
4
  from unittest import mock, skip
4
5
 
5
6
  from django.conf import settings
@@ -25,10 +26,8 @@ from nautobot.dcim.models import (
25
26
  Rack,
26
27
  RackGroup,
27
28
  )
28
-
29
- # from nautobot.dcim.tests import test_views
30
- from nautobot.extras.api.nested_serializers import NestedJobResultSerializer
31
- from nautobot.extras.api.serializers import ConfigContextSerializer
29
+ from nautobot.dcim.tests import test_views
30
+ from nautobot.extras.api.serializers import ConfigContextSerializer, JobResultSerializer
32
31
  from nautobot.extras.choices import (
33
32
  DynamicGroupOperatorChoices,
34
33
  JobExecutionType,
@@ -67,11 +66,11 @@ from nautobot.extras.models import (
67
66
  )
68
67
  from nautobot.extras.models.jobs import JobHook, JobButton
69
68
 
70
- # from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
69
+ from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
71
70
  from nautobot.extras.utils import TaggableClassesQuery
72
71
 
73
- # from nautobot.ipam.factory import VLANFactory
74
- from nautobot.ipam.models import VLANGroup # , VLAN
72
+ from nautobot.ipam.factory import VLANFactory
73
+ from nautobot.ipam.models import VLANGroup, VLAN
75
74
  from nautobot.users.models import ObjectPermission
76
75
 
77
76
 
@@ -91,35 +90,24 @@ class AppTest(APITestCase):
91
90
  #
92
91
 
93
92
 
94
- @skip(reason="Content Types are BROKEN")
95
93
  class ComputedFieldTest(APIViewTestCases.APIViewTestCase):
96
94
  model = ComputedField
97
- brief_fields = [
98
- "content_type",
99
- "display",
100
- "id",
101
- "label",
102
- "url",
103
- ]
104
95
  choices_fields = ["content_type"]
105
96
  create_data = [
106
97
  {
107
98
  "content_type": "dcim.location",
108
- "slug": "cf4",
109
99
  "label": "Computed Field 4",
110
100
  "template": "{{ obj.name }}",
111
101
  "fallback_value": "error",
112
102
  },
113
103
  {
114
104
  "content_type": "dcim.location",
115
- "slug": "cf5",
116
105
  "label": "Computed Field 5",
117
106
  "template": "{{ obj.name }}",
118
107
  "fallback_value": "error",
119
108
  },
120
109
  {
121
110
  "content_type": "dcim.location",
122
- "slug": "cf6",
123
111
  "label": "Computed Field 6",
124
112
  "template": "{{ obj.name }}",
125
113
  },
@@ -132,7 +120,7 @@ class ComputedFieldTest(APIViewTestCases.APIViewTestCase):
132
120
  ]
133
121
  update_data = {
134
122
  "content_type": "dcim.location",
135
- "slug": "cf1",
123
+ "key": "cf1",
136
124
  "label": "My Computed Field",
137
125
  }
138
126
  bulk_update_data = {
@@ -146,21 +134,21 @@ class ComputedFieldTest(APIViewTestCases.APIViewTestCase):
146
134
  location_ct = ContentType.objects.get_for_model(Location)
147
135
 
148
136
  ComputedField.objects.create(
149
- slug="cf1",
137
+ key="cf1",
150
138
  label="Computed Field One",
151
139
  template="{{ obj.name }}",
152
140
  fallback_value="error",
153
141
  content_type=location_ct,
154
142
  )
155
143
  ComputedField.objects.create(
156
- slug="cf2",
144
+ key="cf2",
157
145
  label="Computed Field Two",
158
146
  template="{{ obj.name }}",
159
147
  fallback_value="error",
160
148
  content_type=location_ct,
161
149
  )
162
150
  ComputedField.objects.create(
163
- slug="cf3",
151
+ key="cf3",
164
152
  label="Computed Field Three",
165
153
  template="{{ obj.name }}",
166
154
  fallback_value="error",
@@ -186,7 +174,6 @@ class ComputedFieldTest(APIViewTestCases.APIViewTestCase):
186
174
 
187
175
  class ConfigContextTest(APIViewTestCases.APIViewTestCase):
188
176
  model = ConfigContext
189
- brief_fields = ["display", "id", "name", "url"]
190
177
  create_data = [
191
178
  {
192
179
  "name": "Config Context 4",
@@ -219,8 +206,11 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
219
206
  manufacturer = Manufacturer.objects.first()
220
207
  devicetype = DeviceType.objects.create(manufacturer=manufacturer, model="Device Type 1", slug="device-type-1")
221
208
  devicerole = Role.objects.get_for_model(Device).first()
209
+ devicestatus = Status.objects.get_for_model(Device).first()
222
210
  location = Location.objects.filter(location_type=LocationType.objects.get(name="Campus")).first()
223
- device = Device.objects.create(name="Device 1", device_type=devicetype, role=devicerole, location=location)
211
+ device = Device.objects.create(
212
+ name="Device 1", device_type=devicetype, role=devicerole, status=devicestatus, location=location
213
+ )
224
214
 
225
215
  # Test default config contexts (created at test setup)
226
216
  rendered_context = device.get_config_context()
@@ -231,7 +221,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
231
221
  # Test API response as well
232
222
  self.add_permissions("dcim.view_device")
233
223
  device_url = reverse("dcim-api:device-detail", kwargs={"pk": device.pk})
234
- response = self.client.get(device_url, **self.header)
224
+ response = self.client.get(device_url + "?include=config_context", **self.header)
235
225
  self.assertHttpStatus(response, status.HTTP_200_OK)
236
226
  self.assertIn("config_context", response.data)
237
227
  self.assertEqual(response.data["config_context"], {"foo": 123, "bar": 456, "baz": 789}, response.data)
@@ -242,7 +232,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
242
232
  configcontext4.locations.add(location)
243
233
  rendered_context = device.get_config_context()
244
234
  self.assertEqual(rendered_context["location_data"], "ABC")
245
- response = self.client.get(device_url, **self.header)
235
+ response = self.client.get(device_url + "?include=config_context", **self.header)
246
236
  self.assertHttpStatus(response, status.HTTP_200_OK)
247
237
  self.assertIn("config_context", response.data)
248
238
  self.assertEqual(response.data["config_context"]["location_data"], "ABC", response.data["config_context"])
@@ -253,7 +243,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
253
243
  configcontext5.locations.add(location)
254
244
  rendered_context = device.get_config_context()
255
245
  self.assertEqual(rendered_context["foo"], 999)
256
- response = self.client.get(device_url, **self.header)
246
+ response = self.client.get(device_url + "?include=config_context", **self.header)
257
247
  self.assertHttpStatus(response, status.HTTP_200_OK)
258
248
  self.assertIn("config_context", response.data)
259
249
  self.assertEqual(response.data["config_context"]["foo"], 999, response.data["config_context"])
@@ -265,7 +255,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
265
255
  configcontext6.locations.add(location2)
266
256
  rendered_context = device.get_config_context()
267
257
  self.assertEqual(rendered_context["bar"], 456)
268
- response = self.client.get(device_url, **self.header)
258
+ response = self.client.get(device_url + "?include=config_context", **self.header)
269
259
  self.assertHttpStatus(response, status.HTTP_200_OK)
270
260
  self.assertIn("config_context", response.data)
271
261
  self.assertEqual(response.data["config_context"]["bar"], 456, response.data["config_context"])
@@ -289,7 +279,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
289
279
  }
290
280
  response = self.client.post(self._get_list_url(), data, format="json", **self.header)
291
281
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
292
- self.assertEqual(response.data["config_context_schema"]["id"], str(schema.pk))
282
+ self.assertEqual(response.data["config_context_schema"], self.absolute_api_url(schema))
293
283
 
294
284
  def test_schema_validation_fails(self):
295
285
  """
@@ -326,7 +316,6 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
326
316
 
327
317
  class ConfigContextSchemaTest(APIViewTestCases.APIViewTestCase):
328
318
  model = ConfigContextSchema
329
- brief_fields = ["display", "id", "name", "slug", "url"]
330
319
  create_data = [
331
320
  {
332
321
  "name": "Schema 4",
@@ -389,31 +378,33 @@ class ContentTypeTest(APITestCase):
389
378
 
390
379
 
391
380
  class CreatedUpdatedFilterTest(APITestCase):
392
- def setUp(self):
393
- super().setUp()
394
-
395
- self.location1 = Location.objects.filter(location_type=LocationType.objects.get(name="Campus")).first()
396
- self.rackgroup1 = RackGroup.objects.create(
397
- location=self.location1, name="Test Rack Group 1", slug="test-rack-group-1"
398
- )
399
- self.rackrole1 = Role.objects.get_for_model(Rack).first()
400
- self.rack1 = Rack.objects.create(
401
- location=self.location1,
402
- rack_group=self.rackgroup1,
403
- role=self.rackrole1,
381
+ @classmethod
382
+ def setUpTestData(cls):
383
+ cls.location1 = Location.objects.filter(location_type=LocationType.objects.get(name="Campus")).first()
384
+ cls.rackgroup1 = RackGroup.objects.create(
385
+ location=cls.location1, name="Test Rack Group 1", slug="test-rack-group-1"
386
+ )
387
+ cls.rackrole1 = Role.objects.get_for_model(Rack).first()
388
+ cls.rackstatus1 = Status.objects.get_for_model(Rack).first()
389
+ cls.rack1 = Rack.objects.create(
390
+ location=cls.location1,
391
+ rack_group=cls.rackgroup1,
392
+ role=cls.rackrole1,
393
+ status=cls.rackstatus1,
404
394
  name="Test Rack 1",
405
395
  u_height=42,
406
396
  )
407
- self.rack2 = Rack.objects.create(
408
- location=self.location1,
409
- rack_group=self.rackgroup1,
410
- role=self.rackrole1,
397
+ cls.rack2 = Rack.objects.create(
398
+ location=cls.location1,
399
+ rack_group=cls.rackgroup1,
400
+ role=cls.rackrole1,
401
+ status=cls.rackstatus1,
411
402
  name="Test Rack 2",
412
403
  u_height=42,
413
404
  )
414
405
 
415
406
  # change the created and last_updated of one
416
- Rack.objects.filter(pk=self.rack2.pk).update(
407
+ Rack.objects.filter(pk=cls.rack2.pk).update(
417
408
  created=make_aware(datetime(2001, 2, 3, 0, 1, 2, 3)),
418
409
  last_updated=make_aware(datetime(2001, 2, 3, 1, 2, 3, 4)),
419
410
  )
@@ -483,12 +474,10 @@ class CreatedUpdatedFilterTest(APITestCase):
483
474
  self.assertEqual(response.data["results"][0]["id"], str(self.rack2.pk))
484
475
 
485
476
 
486
- @skip(reason="Content Types are BROKEN")
487
477
  class CustomFieldTest(APIViewTestCases.APIViewTestCase):
488
478
  """Tests for the CustomField REST API."""
489
479
 
490
480
  model = CustomField
491
- brief_fields = ["display", "id", "key", "url"]
492
481
  create_data = [
493
482
  {
494
483
  "content_types": ["dcim.location"],
@@ -561,7 +550,6 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
561
550
 
562
551
  class CustomLinkTest(APIViewTestCases.APIViewTestCase):
563
552
  model = CustomLink
564
- brief_fields = ["content_type", "display", "id", "name", "url"]
565
553
  create_data = [
566
554
  {
567
555
  "content_type": "dcim.location",
@@ -627,10 +615,17 @@ class DynamicGroupTestMixin:
627
615
  def setUpTestData(cls):
628
616
  # Create the objects required for devices.
629
617
  location_type = LocationType.objects.get(name="Campus")
618
+ location_status = Status.objects.get_for_model(Location).first()
630
619
  locations = (
631
- Location.objects.create(name="Location 1", slug="location-1", location_type=location_type),
632
- Location.objects.create(name="Location 2", slug="location-2", location_type=location_type),
633
- Location.objects.create(name="Location 3", slug="location-3", location_type=location_type),
620
+ Location.objects.create(
621
+ name="Location 1", slug="location-1", location_type=location_type, status=location_status
622
+ ),
623
+ Location.objects.create(
624
+ name="Location 2", slug="location-2", location_type=location_type, status=location_status
625
+ ),
626
+ Location.objects.create(
627
+ name="Location 3", slug="location-3", location_type=location_type, status=location_status
628
+ ),
634
629
  )
635
630
 
636
631
  manufacturer = Manufacturer.objects.first()
@@ -686,7 +681,6 @@ class DynamicGroupTestMixin:
686
681
 
687
682
  class DynamicGroupTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase):
688
683
  model = DynamicGroup
689
- brief_fields = ["content_type", "display", "id", "name", "url"]
690
684
  choices_fields = ["content_type"]
691
685
  create_data = [
692
686
  {
@@ -719,7 +713,6 @@ class DynamicGroupTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase):
719
713
 
720
714
  class DynamicGroupMembershipTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase):
721
715
  model = DynamicGroupMembership
722
- brief_fields = ["display", "group", "id", "operator", "parent_group", "url", "weight"]
723
716
  choices_fields = ["operator"]
724
717
 
725
718
  @classmethod
@@ -778,10 +771,18 @@ class DynamicGroupMembershipTest(DynamicGroupTestMixin, APIViewTestCases.APIView
778
771
  },
779
772
  ]
780
773
 
774
+ # TODO: Either improve test base or or write a more specific test for this model.
775
+ @skip("DynamicGroupMembership has a `name` property but it's the Group name and not exposed on the API")
776
+ def test_list_objects_ascending_ordered(self):
777
+ pass
778
+
779
+ @skip("DynamicGroupMembership has a `name` property but it's the Group name and not exposed on the API")
780
+ def test_list_objects_descending_ordered(self):
781
+ pass
782
+
781
783
 
782
784
  class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
783
785
  model = ExportTemplate
784
- brief_fields = ["display", "id", "name", "url"]
785
786
  create_data = [
786
787
  {
787
788
  "content_type": "dcim.device",
@@ -827,12 +828,12 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
827
828
 
828
829
  class GitRepositoryTest(APIViewTestCases.APIViewTestCase):
829
830
  model = GitRepository
830
- brief_fields = ["display", "id", "name", "url"]
831
831
  bulk_update_data = {
832
832
  "branch": "develop",
833
833
  }
834
834
  choices_fields = ["provided_contents"]
835
835
  slug_source = "name"
836
+ slugify_function = staticmethod(slugify_dashes_to_underscores)
836
837
 
837
838
  @classmethod
838
839
  def setUpTestData(cls):
@@ -844,37 +845,38 @@ class GitRepositoryTest(APIViewTestCases.APIViewTestCase):
844
845
  cls.repos = (
845
846
  GitRepository(
846
847
  name="Repo 1",
847
- slug="repo-1",
848
+ slug="repo_1",
848
849
  remote_url="https://example.com/repo1.git",
849
850
  secrets_group=secrets_groups[0],
850
851
  ),
851
852
  GitRepository(
852
853
  name="Repo 2",
853
- slug="repo-2",
854
+ slug="repo_2",
854
855
  remote_url="https://example.com/repo2.git",
855
856
  secrets_group=secrets_groups[0],
856
857
  ),
857
- GitRepository(name="Repo 3", slug="repo-3", remote_url="https://example.com/repo3.git"),
858
+ GitRepository(name="Repo 3", slug="repo_3", remote_url="https://example.com/repo3.git"),
858
859
  )
859
860
  for repo in cls.repos:
860
- repo.save(trigger_resync=False)
861
+ repo.save()
861
862
 
862
863
  cls.create_data = [
863
864
  {
864
865
  "name": "New Git Repository 1",
865
- "slug": "new-git-repository-1",
866
+ "slug": "new_git_repository_1",
866
867
  "remote_url": "https://example.com/newrepo1.git",
867
868
  "secrets_group": secrets_groups[1].pk,
869
+ "provided_contents": ["extras.configcontext", "extras.exporttemplate"],
868
870
  },
869
871
  {
870
872
  "name": "New Git Repository 2",
871
- "slug": "new-git-repository-2",
873
+ "slug": "new_git_repository_2",
872
874
  "remote_url": "https://example.com/newrepo2.git",
873
875
  "secrets_group": secrets_groups[1].pk,
874
876
  },
875
877
  {
876
878
  "name": "New Git Repository 3",
877
- "slug": "new-git-repository-3",
879
+ "slug": "new_git_repository_3",
878
880
  "remote_url": "https://example.com/newrepo3.git",
879
881
  "secrets_group": secrets_groups[1].pk,
880
882
  },
@@ -885,6 +887,13 @@ class GitRepositoryTest(APIViewTestCases.APIViewTestCase):
885
887
  },
886
888
  ]
887
889
 
890
+ # slug is enforced non-editable in clean because we want it to be providable by the user on creation
891
+ # but not modified afterward
892
+ cls.update_data = {
893
+ "name": "A Different Repo Name",
894
+ "remote_url": "https://example.com/fake.git",
895
+ }
896
+
888
897
  @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
889
898
  @mock.patch("nautobot.extras.api.views.get_worker_count")
890
899
  def test_run_git_sync_no_celery_worker(self, mock_get_worker_count):
@@ -920,10 +929,9 @@ class GitRepositoryTest(APIViewTestCases.APIViewTestCase):
920
929
  self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
921
930
 
922
931
  @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
923
- @mock.patch("nautobot.extras.api.views.get_worker_count")
924
- def test_run_git_sync_with_permissions(self, mock_get_worker_count):
932
+ @mock.patch("nautobot.extras.api.views.get_worker_count", return_value=1)
933
+ def test_run_git_sync_with_permissions(self, _):
925
934
  """Git sync request can be submitted successfully."""
926
- mock_get_worker_count.return_value = 1
927
935
  self.add_permissions("extras.add_gitrepository")
928
936
  self.add_permissions("extras.change_gitrepository")
929
937
  url = reverse("extras-api:gitrepository-sync", kwargs={"pk": self.repos[0].id})
@@ -937,7 +945,7 @@ class GitRepositoryTest(APIViewTestCases.APIViewTestCase):
937
945
  url = self._get_list_url()
938
946
  data = {
939
947
  "name": "plugin_test",
940
- "slug": "plugin-test",
948
+ "slug": "plugin_test",
941
949
  "remote_url": "https://localhost/plugin-test",
942
950
  "provided_contents": ["example_plugin.textfile"],
943
951
  }
@@ -948,8 +956,6 @@ class GitRepositoryTest(APIViewTestCases.APIViewTestCase):
948
956
 
949
957
  class GraphQLQueryTest(APIViewTestCases.APIViewTestCase):
950
958
  model = GraphQLQuery
951
- brief_fields = ["display", "id", "name", "url"]
952
-
953
959
  create_data = [
954
960
  {
955
961
  "name": "graphql-query-4",
@@ -1097,7 +1103,6 @@ class ImageAttachmentTest(
1097
1103
  APIViewTestCases.DeleteObjectViewTestCase,
1098
1104
  ):
1099
1105
  model = ImageAttachment
1100
- brief_fields = ["display", "id", "image", "name", "url"]
1101
1106
  choices_fields = ["content_type"]
1102
1107
 
1103
1108
  @classmethod
@@ -1131,6 +1136,15 @@ class ImageAttachmentTest(
1131
1136
  image_width=100,
1132
1137
  )
1133
1138
 
1139
+ # TODO: Unskip after resolving #2908, #2909
1140
+ @skip("DRF's built-in OrderingFilter triggering natural key attribute error in our base")
1141
+ def test_list_objects_ascending_ordered(self):
1142
+ pass
1143
+
1144
+ @skip("DRF's built-in OrderingFilter triggering natural key attribute error in our base")
1145
+ def test_list_objects_descending_ordered(self):
1146
+ pass
1147
+
1134
1148
 
1135
1149
  class JobTest(
1136
1150
  # note no CreateObjectViewTestCase - we do not support user creation of Job records
@@ -1142,7 +1156,6 @@ class JobTest(
1142
1156
  """Test cases for the Jobs REST API."""
1143
1157
 
1144
1158
  model = Job
1145
- brief_fields = ["display", "grouping", "id", "job_class_name", "module_name", "name", "slug", "source", "url"]
1146
1159
  choices_fields = None
1147
1160
  update_data = {
1148
1161
  # source, module_name, job_class_name, installed are NOT editable
@@ -1150,18 +1163,15 @@ class JobTest(
1150
1163
  "grouping": "Overridden grouping",
1151
1164
  "name_override": True,
1152
1165
  "name": "Overridden name",
1153
- "slug": "overridden-slug",
1154
1166
  "description_override": True,
1155
1167
  "description": "This is an overridden description.",
1156
1168
  "enabled": True,
1157
1169
  "approval_required_override": True,
1158
1170
  "approval_required": True,
1159
- "commit_default_override": True,
1160
- "commit_default": False,
1171
+ "dryrun_default_override": True,
1172
+ "dryrun_default": True,
1161
1173
  "hidden_override": True,
1162
1174
  "hidden": True,
1163
- "read_only_override": True,
1164
- "read_only": True,
1165
1175
  "soft_time_limit_override": True,
1166
1176
  "soft_time_limit": 350.1,
1167
1177
  "time_limit_override": True,
@@ -1181,14 +1191,16 @@ class JobTest(
1181
1191
 
1182
1192
  def setUp(self):
1183
1193
  super().setUp()
1184
- self.default_job_name = "local/api_test_job/APITestJob"
1194
+ self.default_job_name = "api_test_job.APITestJob"
1195
+ self.job_class = get_job(self.default_job_name)
1196
+ self.assertIsNotNone(self.job_class)
1185
1197
  self.job_model = Job.objects.get_for_class_path(self.default_job_name)
1186
1198
  self.job_model.enabled = True
1187
1199
  self.job_model.validated_save()
1188
1200
 
1189
1201
  run_success_response_status = status.HTTP_201_CREATED
1190
1202
 
1191
- def get_run_url(self, class_path="local/api_test_job/APITestJob"):
1203
+ def get_run_url(self, class_path="api_test_job.APITestJob"):
1192
1204
  job_model = Job.objects.get_for_class_path(class_path)
1193
1205
  return reverse("extras-api:job-run", kwargs={"pk": job_model.pk})
1194
1206
 
@@ -1208,7 +1220,7 @@ class JobTest(
1208
1220
 
1209
1221
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1210
1222
  def test_update_job_with_sensitive_variables_set_approval_required_to_true(self):
1211
- job_model = Job.objects.get_for_class_path("local/api_test_job/APITestJob")
1223
+ job_model = Job.objects.get_for_class_path("api_test_job.APITestJob")
1212
1224
  job_model.has_sensitive_variables = True
1213
1225
  job_model.has_sensitive_variables_override = True
1214
1226
  job_model.validated_save()
@@ -1230,7 +1242,7 @@ class JobTest(
1230
1242
 
1231
1243
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1232
1244
  def test_update_approval_required_job_set_has_sensitive_variables_to_true(self):
1233
- job_model = Job.objects.get_for_class_path("local/api_test_job/APITestJob")
1245
+ job_model = Job.objects.get_for_class_path("api_test_job.APITestJob")
1234
1246
  job_model.approval_required = True
1235
1247
  job_model.approval_required_override = True
1236
1248
  job_model.validated_save()
@@ -1275,7 +1287,7 @@ class JobTest(
1275
1287
  mock_get_worker_count.return_value = 1
1276
1288
  obj_perm = ObjectPermission(
1277
1289
  name="Test permission",
1278
- constraints={"module_name__in": ["test_pass", "test_fail"]},
1290
+ constraints={"module_name__in": ["pass", "fail"]},
1279
1291
  actions=["run"],
1280
1292
  )
1281
1293
  obj_perm.save()
@@ -1289,10 +1301,10 @@ class JobTest(
1289
1301
  self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
1290
1302
 
1291
1303
  # Try post to permitted job
1292
- job_model = Job.objects.get_for_class_path("local/test_pass/TestPass")
1304
+ job_model = Job.objects.get_for_class_path("pass.TestPass")
1293
1305
  job_model.enabled = True
1294
1306
  job_model.validated_save()
1295
- url = self.get_run_url("local/test_pass/TestPass")
1307
+ url = self.get_run_url("pass.TestPass")
1296
1308
  response = self.client.post(url, **self.header)
1297
1309
  self.assertHttpStatus(response, self.run_success_response_status)
1298
1310
 
@@ -1320,7 +1332,6 @@ class JobTest(
1320
1332
  self.add_permissions("extras.run_job")
1321
1333
 
1322
1334
  job_model = Job(
1323
- source="local",
1324
1335
  module_name="uninstalled_module",
1325
1336
  job_class_name="NoSuchJob",
1326
1337
  grouping="Uninstalled Module",
@@ -1330,7 +1341,7 @@ class JobTest(
1330
1341
  )
1331
1342
  job_model.validated_save()
1332
1343
 
1333
- url = self.get_run_url("local/uninstalled_module/NoSuchJob")
1344
+ url = self.get_run_url("uninstalled_module.NoSuchJob")
1334
1345
  with disable_warnings("django.request"):
1335
1346
  response = self.client.post(url, {}, format="json", **self.header)
1336
1347
  self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
@@ -1351,7 +1362,6 @@ class JobTest(
1351
1362
 
1352
1363
  data = {
1353
1364
  "data": job_data,
1354
- "commit": True,
1355
1365
  }
1356
1366
 
1357
1367
  url = self.get_run_url()
@@ -1377,7 +1387,6 @@ class JobTest(
1377
1387
 
1378
1388
  data = {
1379
1389
  "data": job_data,
1380
- "commit": True,
1381
1390
  "schedule": {
1382
1391
  "name": "test",
1383
1392
  "interval": "future",
@@ -1390,17 +1399,19 @@ class JobTest(
1390
1399
  self.assertHttpStatus(response, self.run_success_response_status)
1391
1400
 
1392
1401
  schedule = ScheduledJob.objects.last()
1393
- self.assertEqual(schedule.kwargs["data"]["var4"], str(device_role.pk))
1402
+ self.assertEqual(schedule.kwargs["var4"], str(device_role.pk))
1394
1403
 
1395
1404
  self.assertIn("scheduled_job", response.data)
1396
1405
  self.assertIn("job_result", response.data)
1397
1406
  self.assertEqual(response.data["scheduled_job"]["id"], str(schedule.pk))
1407
+ self.assertEqual(response.data["scheduled_job"]["url"], self.absolute_api_url(schedule))
1408
+ self.assertEqual(response.data["scheduled_job"]["name"], schedule.name)
1409
+ # Python < 3.11 doesn't understand the datetime string "2023-04-27T18:33:16.017865Z",
1410
+ # but it *does* understand the string "2023-04-27T18:33:17.330836+00:00"
1398
1411
  self.assertEqual(
1399
- response.data["scheduled_job"]["url"],
1400
- "http://nautobot.example.com" + reverse("extras-api:scheduledjob-detail", kwargs={"pk": schedule.pk}),
1412
+ datetime.fromisoformat(response.data["scheduled_job"]["start_time"].replace("Z", "+00:00")),
1413
+ schedule.start_time,
1401
1414
  )
1402
- self.assertEqual(response.data["scheduled_job"]["name"], schedule.name)
1403
- self.assertEqual(response.data["scheduled_job"]["start_time"], schedule.start_time)
1404
1415
  self.assertEqual(response.data["scheduled_job"]["interval"], schedule.interval)
1405
1416
  self.assertIsNone(response.data["job_result"])
1406
1417
 
@@ -1429,7 +1440,6 @@ class JobTest(
1429
1440
 
1430
1441
  data = {
1431
1442
  "data": job_data,
1432
- "commit": True,
1433
1443
  # schedule is omitted
1434
1444
  }
1435
1445
 
@@ -1445,13 +1455,15 @@ class JobTest(
1445
1455
  self.assertIsNotNone(schedule)
1446
1456
  self.assertEqual(schedule.interval, JobExecutionType.TYPE_IMMEDIATELY)
1447
1457
  self.assertEqual(schedule.approval_required, self.job_model.approval_required)
1448
- self.assertEqual(schedule.kwargs["data"]["var4"], str(device_role.pk))
1458
+ self.assertEqual(schedule.kwargs["var4"], str(device_role.pk))
1449
1459
 
1450
1460
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1451
1461
  @mock.patch("nautobot.extras.api.views.get_worker_count")
1452
- def test_run_job_object_var_lookup(self, mock_get_worker_count):
1462
+ @mock.patch("nautobot.extras.models.jobs.JobResult.enqueue_job")
1463
+ def test_run_job_object_var_lookup(self, mock_enqueue_job, mock_get_worker_count):
1453
1464
  """Job run requests can reference objects by their attributes."""
1454
1465
  mock_get_worker_count.return_value = 1
1466
+ mock_enqueue_job.return_value = None
1455
1467
  self.add_permissions("extras.run_job")
1456
1468
  device_role = Role.objects.get_for_model(Device).first()
1457
1469
  job_data = {
@@ -1463,7 +1475,7 @@ class JobTest(
1463
1475
 
1464
1476
  # This handles things like ObjectVar fields looked up by non-UUID
1465
1477
  # Jobs are executed with deserialized data
1466
- deserialized_data = get_job(self.default_job_name).deserialize_data(job_data)
1478
+ deserialized_data = self.job_class.deserialize_data(job_data)
1467
1479
 
1468
1480
  self.assertEqual(
1469
1481
  deserialized_data,
@@ -1474,24 +1486,39 @@ class JobTest(
1474
1486
  response = self.client.post(url, {"data": job_data}, format="json", **self.header)
1475
1487
  self.assertHttpStatus(response, self.run_success_response_status)
1476
1488
 
1477
- job_result = JobResult.objects.get(name=self.default_job_name)
1478
- self.assertIn("data", job_result.task_kwargs)
1489
+ # Ensure the enqueue_job args deserialize to the same as originally inputted
1490
+ expected_enqueue_job_args = (self.job_model, self.user)
1491
+ expected_enqueue_job_kwargs = {
1492
+ "task_queue": settings.CELERY_TASK_DEFAULT_QUEUE,
1493
+ **self.job_class.serialize_data(deserialized_data),
1494
+ }
1495
+ mock_enqueue_job.assert_called_with(*expected_enqueue_job_args, **expected_enqueue_job_kwargs)
1479
1496
 
1480
- # Ensure the stored task_kwargs deserialize to the same as originally inputted
1481
- self.assertEqual(
1482
- get_job("local/api_test_job/APITestJob").deserialize_data(job_result.task_kwargs["data"]), deserialized_data
1483
- )
1497
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1498
+ @mock.patch("nautobot.extras.api.views.get_worker_count")
1499
+ def test_run_job_response_job_result(self, mock_get_worker_count):
1500
+ """Test job run response contains nested job result."""
1501
+ mock_get_worker_count.return_value = 1
1502
+ self.add_permissions("extras.run_job")
1503
+ device_role = Role.objects.get_for_model(Device).first()
1504
+ job_data = {
1505
+ "var1": "FooBar",
1506
+ "var2": 123,
1507
+ "var3": False,
1508
+ "var4": {"name": device_role.name},
1509
+ }
1510
+
1511
+ url = self.get_run_url()
1512
+ response = self.client.post(url, {"data": job_data}, format="json", **self.header)
1513
+ self.assertHttpStatus(response, self.run_success_response_status)
1514
+
1515
+ job_result = JobResult.objects.get(name=self.job_model.name)
1484
1516
 
1485
1517
  self.assertIn("scheduled_job", response.data)
1486
1518
  self.assertIn("job_result", response.data)
1487
1519
  self.assertIsNone(response.data["scheduled_job"])
1488
- # The urls in a NestedJobResultSerializer depends on the request context, which we don't have
1489
1520
  data_job_result = response.data["job_result"]
1490
- del data_job_result["url"]
1491
- del data_job_result["user"]["url"]
1492
- expected_data_job_result = NestedJobResultSerializer(job_result, context={"request": None}).data
1493
- del expected_data_job_result["url"]
1494
- del expected_data_job_result["user"]["url"]
1521
+ expected_data_job_result = JobResultSerializer(job_result, context={"request": response.wsgi_request}).data
1495
1522
  self.assertEqual(data_job_result, expected_data_job_result)
1496
1523
 
1497
1524
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
@@ -1501,7 +1528,7 @@ class JobTest(
1501
1528
 
1502
1529
  test_file = SimpleUploadedFile(name="test_file.txt", content=b"I am content.\n")
1503
1530
 
1504
- job_model = Job.objects.get_for_class_path("local/test_field_order/TestFieldOrder")
1531
+ job_model = Job.objects.get_for_class_path("field_order.TestFieldOrder")
1505
1532
  job_model.enabled = True
1506
1533
  job_model.validated_save()
1507
1534
 
@@ -1512,10 +1539,9 @@ class JobTest(
1512
1539
  "var2": "Ground control to Major Tom",
1513
1540
  "var23": "Commencing countdown, engines on",
1514
1541
  "var1": test_file,
1515
- "_commit": True,
1516
1542
  }
1517
1543
 
1518
- url = self.get_run_url(class_path="local/test_field_order/TestFieldOrder")
1544
+ url = self.get_run_url(class_path="field_order.TestFieldOrder")
1519
1545
  response = self.client.post(url, data=job_data, **self.header)
1520
1546
  self.assertHttpStatus(response, self.run_success_response_status)
1521
1547
 
@@ -1526,7 +1552,7 @@ class JobTest(
1526
1552
 
1527
1553
  test_file = SimpleUploadedFile(name="test_file.txt", content=b"I am content.\n")
1528
1554
 
1529
- job_model = Job.objects.get_for_class_path("local/test_field_order/TestFieldOrder")
1555
+ job_model = Job.objects.get_for_class_path("field_order.TestFieldOrder")
1530
1556
  job_model.enabled = True
1531
1557
  job_model.validated_save()
1532
1558
 
@@ -1539,7 +1565,7 @@ class JobTest(
1539
1565
  "var1": test_file,
1540
1566
  }
1541
1567
 
1542
- url = self.get_run_url(class_path="local/test_field_order/TestFieldOrder")
1568
+ url = self.get_run_url(class_path="field_order.TestFieldOrder")
1543
1569
  response = self.client.post(url, data=job_data, **self.header)
1544
1570
  self.assertHttpStatus(response, self.run_success_response_status)
1545
1571
 
@@ -1550,7 +1576,7 @@ class JobTest(
1550
1576
 
1551
1577
  test_file = SimpleUploadedFile(name="test_file.txt", content=b"I am content.\n")
1552
1578
 
1553
- job_model = Job.objects.get_for_class_path("local/test_field_order/TestFieldOrder")
1579
+ job_model = Job.objects.get_for_class_path("field_order.TestFieldOrder")
1554
1580
  job_model.enabled = True
1555
1581
  job_model.validated_save()
1556
1582
 
@@ -1561,13 +1587,12 @@ class JobTest(
1561
1587
  "var2": "Ground control to Major Tom",
1562
1588
  "var23": "Commencing countdown, engines on",
1563
1589
  "var1": test_file,
1564
- "_commit": True,
1565
1590
  "_schedule_start_time": str(datetime.now() + timedelta(minutes=1)),
1566
1591
  "_schedule_interval": "future",
1567
1592
  "_schedule_name": "test",
1568
1593
  }
1569
1594
 
1570
- url = self.get_run_url(class_path="local/test_field_order/TestFieldOrder")
1595
+ url = self.get_run_url(class_path="field_order.TestFieldOrder")
1571
1596
  response = self.client.post(url, data=job_data, **self.header)
1572
1597
  self.assertHttpStatus(response, self.run_success_response_status)
1573
1598
 
@@ -1580,7 +1605,6 @@ class JobTest(
1580
1605
  d = Role.objects.get_for_model(Device).first()
1581
1606
  data = {
1582
1607
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1583
- "commit": True,
1584
1608
  "schedule": {
1585
1609
  "start_time": str(datetime.now() + timedelta(minutes=1)),
1586
1610
  "interval": "future",
@@ -1593,17 +1617,17 @@ class JobTest(
1593
1617
  self.assertHttpStatus(response, self.run_success_response_status)
1594
1618
 
1595
1619
  schedule = ScheduledJob.objects.last()
1596
- self.assertEqual(schedule.kwargs["scheduled_job_pk"], str(schedule.pk))
1597
-
1598
1620
  self.assertIn("scheduled_job", response.data)
1599
1621
  self.assertIn("job_result", response.data)
1600
1622
  self.assertEqual(response.data["scheduled_job"]["id"], str(schedule.pk))
1623
+ self.assertEqual(response.data["scheduled_job"]["url"], self.absolute_api_url(schedule))
1624
+ self.assertEqual(response.data["scheduled_job"]["name"], schedule.name)
1625
+ # Python < 3.11 doesn't understand the datetime string "2023-04-27T18:33:16.017865Z",
1626
+ # but it *does* understand the string "2023-04-27T18:33:17.330836+00:00"
1601
1627
  self.assertEqual(
1602
- response.data["scheduled_job"]["url"],
1603
- "http://nautobot.example.com" + reverse("extras-api:scheduledjob-detail", kwargs={"pk": schedule.pk}),
1628
+ datetime.fromisoformat(response.data["scheduled_job"]["start_time"].replace("Z", "+00:00")),
1629
+ schedule.start_time,
1604
1630
  )
1605
- self.assertEqual(response.data["scheduled_job"]["name"], schedule.name)
1606
- self.assertEqual(response.data["scheduled_job"]["start_time"], schedule.start_time)
1607
1631
  self.assertEqual(response.data["scheduled_job"]["interval"], schedule.interval)
1608
1632
  self.assertIsNone(response.data["job_result"])
1609
1633
 
@@ -1620,7 +1644,6 @@ class JobTest(
1620
1644
  url = reverse("extras-api:job-run", kwargs={"pk": job_model.pk})
1621
1645
  data = {
1622
1646
  "data": {},
1623
- "commit": True,
1624
1647
  "schedule": {
1625
1648
  "start_time": str(datetime.now() + timedelta(minutes=1)),
1626
1649
  "interval": "future",
@@ -1651,7 +1674,6 @@ class JobTest(
1651
1674
  url = reverse("extras-api:job-run", kwargs={"pk": job_model.pk})
1652
1675
  data = {
1653
1676
  "data": {},
1654
- "commit": True,
1655
1677
  "schedule": {
1656
1678
  "interval": "immediately",
1657
1679
  "name": "test",
@@ -1668,30 +1690,27 @@ class JobTest(
1668
1690
  )
1669
1691
 
1670
1692
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1671
- @mock.patch("nautobot.extras.api.views.get_worker_count")
1672
- def test_run_a_job_with_sensitive_variables_immediately(self, mock_get_worker_count):
1673
- mock_get_worker_count.return_value = 1
1693
+ @mock.patch("nautobot.extras.api.views.get_worker_count", return_value=1)
1694
+ def test_run_a_job_with_sensitive_variables_immediately(self, _):
1674
1695
  self.add_permissions("extras.run_job")
1675
1696
  d = Role.objects.get_for_model(Device).first()
1676
1697
  data = {
1677
1698
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1678
- "commit": True,
1679
1699
  "schedule": {
1680
1700
  "interval": "immediately",
1681
1701
  "name": "test",
1682
1702
  },
1683
1703
  }
1684
- job = Job.objects.get_for_class_path(self.default_job_name)
1685
- job.has_sensitive_variables = True
1686
- job.has_sensitive_variables_override = True
1687
- job.validated_save()
1704
+ self.job_model.has_sensitive_variables = True
1705
+ self.job_model.has_sensitive_variables_override = True
1706
+ self.job_model.validated_save()
1688
1707
 
1689
1708
  url = self.get_run_url()
1690
1709
  response = self.client.post(url, data, format="json", **self.header)
1691
1710
  self.assertHttpStatus(response, self.run_success_response_status)
1692
1711
 
1693
- job_result = JobResult.objects.get(name=self.default_job_name)
1694
- self.assertEqual(job_result.task_kwargs, None)
1712
+ job_result = JobResult.objects.get(name=self.job_model.name)
1713
+ self.assertEqual(job_result.task_kwargs, {})
1695
1714
 
1696
1715
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1697
1716
  @mock.patch("nautobot.extras.api.views.get_worker_count")
@@ -1701,7 +1720,6 @@ class JobTest(
1701
1720
  d = Role.objects.get_for_model(Device).first()
1702
1721
  data = {
1703
1722
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1704
- "commit": True,
1705
1723
  "schedule": {
1706
1724
  "start_time": str(datetime.now() - timedelta(minutes=1)),
1707
1725
  "interval": "future",
@@ -1721,7 +1739,6 @@ class JobTest(
1721
1739
  d = Role.objects.get_for_model(Device).first()
1722
1740
  data = {
1723
1741
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1724
- "commit": True,
1725
1742
  "schedule": {
1726
1743
  "start_time": str(datetime.now() + timedelta(minutes=1)),
1727
1744
  "interval": "hourly",
@@ -1738,12 +1755,14 @@ class JobTest(
1738
1755
  self.assertIn("scheduled_job", response.data)
1739
1756
  self.assertIn("job_result", response.data)
1740
1757
  self.assertEqual(response.data["scheduled_job"]["id"], str(schedule.pk))
1758
+ self.assertEqual(response.data["scheduled_job"]["url"], self.absolute_api_url(schedule))
1759
+ self.assertEqual(response.data["scheduled_job"]["name"], schedule.name)
1760
+ # Python < 3.11 doesn't understand the datetime string "2023-04-27T18:33:16.017865Z",
1761
+ # but it *does* understand the string "2023-04-27T18:33:17.330836+00:00"
1741
1762
  self.assertEqual(
1742
- response.data["scheduled_job"]["url"],
1743
- "http://nautobot.example.com" + reverse("extras-api:scheduledjob-detail", kwargs={"pk": schedule.pk}),
1763
+ datetime.fromisoformat(response.data["scheduled_job"]["start_time"].replace("Z", "+00:00")),
1764
+ schedule.start_time,
1744
1765
  )
1745
- self.assertEqual(response.data["scheduled_job"]["name"], schedule.name)
1746
- self.assertEqual(response.data["scheduled_job"]["start_time"], schedule.start_time)
1747
1766
  self.assertEqual(response.data["scheduled_job"]["interval"], schedule.interval)
1748
1767
  self.assertIsNone(response.data["job_result"])
1749
1768
 
@@ -1753,7 +1772,6 @@ class JobTest(
1753
1772
 
1754
1773
  data = {
1755
1774
  "data": "invalid",
1756
- "commit": True,
1757
1775
  }
1758
1776
 
1759
1777
  url = self.get_run_url()
@@ -1773,7 +1791,6 @@ class JobTest(
1773
1791
 
1774
1792
  data = {
1775
1793
  "data": job_data,
1776
- "commit": True,
1777
1794
  }
1778
1795
 
1779
1796
  url = self.get_run_url()
@@ -1792,7 +1809,6 @@ class JobTest(
1792
1809
 
1793
1810
  data = {
1794
1811
  "data": job_data,
1795
- "commit": True,
1796
1812
  }
1797
1813
 
1798
1814
  url = self.get_run_url()
@@ -1808,7 +1824,6 @@ class JobTest(
1808
1824
  d = Role.objects.get_for_model(Device).first()
1809
1825
  data = {
1810
1826
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1811
- "commit": True,
1812
1827
  "task_queue": "invalid",
1813
1828
  }
1814
1829
 
@@ -1827,7 +1842,6 @@ class JobTest(
1827
1842
  d = Role.objects.get_for_model(Device).first()
1828
1843
  data = {
1829
1844
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1830
- "commit": True,
1831
1845
  "task_queue": settings.CELERY_TASK_DEFAULT_QUEUE,
1832
1846
  }
1833
1847
 
@@ -1840,21 +1854,28 @@ class JobTest(
1840
1854
  def test_run_job_with_default_queue_with_empty_job_model_task_queues(self, _):
1841
1855
  self.add_permissions("extras.run_job")
1842
1856
  data = {
1843
- "commit": True,
1844
1857
  "task_queue": settings.CELERY_TASK_DEFAULT_QUEUE,
1845
1858
  }
1846
1859
 
1847
- job_model = Job.objects.get_for_class_path("local/test_pass/TestPass")
1860
+ job_model = Job.objects.get_for_class_path("pass.TestPass")
1848
1861
  job_model.enabled = True
1849
1862
  job_model.validated_save()
1850
- url = self.get_run_url("local/test_pass/TestPass")
1863
+ url = self.get_run_url("pass.TestPass")
1851
1864
  response = self.client.post(url, data, format="json", **self.header)
1852
1865
  self.assertHttpStatus(response, self.run_success_response_status)
1853
1866
 
1867
+ # TODO: Either improve test base or or write a more specific test for this model.
1868
+ @skip("Job has a `name` property but grouping is also used to sort Jobs")
1869
+ def test_list_objects_ascending_ordered(self):
1870
+ pass
1871
+
1872
+ @skip("Job has a `name` property but grouping is also used to sort Jobs")
1873
+ def test_list_objects_descending_ordered(self):
1874
+ pass
1875
+
1854
1876
 
1855
1877
  class JobHookTest(APIViewTestCases.APIViewTestCase):
1856
1878
  model = JobHook
1857
- brief_fields = ["display", "id", "name", "url"]
1858
1879
  choices_fields = []
1859
1880
  update_data = {
1860
1881
  "name": "Overridden name",
@@ -1961,7 +1982,6 @@ class JobHookTest(APIViewTestCases.APIViewTestCase):
1961
1982
 
1962
1983
  class JobButtonTest(APIViewTestCases.APIViewTestCase):
1963
1984
  model = JobButton
1964
- brief_fields = ["display", "id", "name", "url"]
1965
1985
  choices_fields = ["button_class"]
1966
1986
 
1967
1987
  @classmethod
@@ -2020,49 +2040,37 @@ class JobResultTest(
2020
2040
  APIViewTestCases.DeleteObjectViewTestCase,
2021
2041
  ):
2022
2042
  model = JobResult
2023
- brief_fields = ["date_created", "date_done", "display", "id", "name", "status", "url", "user"]
2024
2043
 
2025
2044
  @classmethod
2026
2045
  def setUpTestData(cls):
2027
2046
  jobs = Job.objects.all()[:2]
2028
- job_ct = ContentType.objects.get_for_model(Job)
2029
- git_ct = ContentType.objects.get_for_model(GitRepository)
2030
2047
 
2031
2048
  JobResult.objects.create(
2032
2049
  job_model=jobs[0],
2033
2050
  name=jobs[0].class_path,
2034
- obj_type=job_ct,
2035
- date_done=datetime.now(),
2051
+ date_done=now(),
2036
2052
  user=None,
2037
2053
  status=JobResultStatusChoices.STATUS_SUCCESS,
2038
- data={"output": "\nRan for 3 seconds"},
2039
- task_kwargs=None,
2054
+ task_kwargs={},
2040
2055
  scheduled_job=None,
2041
- task_id=uuid.uuid4(),
2042
2056
  )
2043
2057
  JobResult.objects.create(
2044
2058
  job_model=None,
2045
- name="Git Repository",
2046
- obj_type=git_ct,
2047
- date_done=datetime.now(),
2059
+ name="deleted_module.deleted_job",
2060
+ date_done=now(),
2048
2061
  user=None,
2049
2062
  status=JobResultStatusChoices.STATUS_SUCCESS,
2050
- data=None,
2051
2063
  task_kwargs={"repository_pk": uuid.uuid4()},
2052
2064
  scheduled_job=None,
2053
- task_id=uuid.uuid4(),
2054
2065
  )
2055
2066
  JobResult.objects.create(
2056
2067
  job_model=jobs[1],
2057
2068
  name=jobs[1].class_path,
2058
- obj_type=job_ct,
2059
2069
  date_done=None,
2060
2070
  user=None,
2061
2071
  status=JobResultStatusChoices.STATUS_PENDING,
2062
- data=None,
2063
2072
  task_kwargs={"data": {"device": uuid.uuid4(), "multichoices": ["red", "green"], "checkbox": False}},
2064
2073
  scheduled_job=None,
2065
- task_id=uuid.uuid4(),
2066
2074
  )
2067
2075
 
2068
2076
 
@@ -2071,27 +2079,11 @@ class JobLogEntryTest(
2071
2079
  APIViewTestCases.ListObjectsViewTestCase,
2072
2080
  ):
2073
2081
  model = JobLogEntry
2074
- brief_fields = [
2075
- "absolute_url",
2076
- "created",
2077
- "display",
2078
- "grouping",
2079
- "id",
2080
- "job_result",
2081
- "log_level",
2082
- "log_object",
2083
- "message",
2084
- "url",
2085
- ]
2086
2082
  choices_fields = []
2087
2083
 
2088
2084
  @classmethod
2089
2085
  def setUpTestData(cls):
2090
- cls.job_result = JobResult.objects.create(
2091
- name="test",
2092
- task_id=uuid.uuid4(),
2093
- obj_type=ContentType.objects.get_for_model(GitRepository),
2094
- )
2086
+ cls.job_result = JobResult.objects.create(name="test")
2095
2087
 
2096
2088
  for log_level in ("debug", "info", "success", "warning"):
2097
2089
  JobLogEntry.objects.create(
@@ -2114,16 +2106,15 @@ class ScheduledJobTest(
2114
2106
  APIViewTestCases.ListObjectsViewTestCase,
2115
2107
  ):
2116
2108
  model = ScheduledJob
2117
- brief_fields = ["crontab", "display", "id", "interval", "name", "start_time", "url"]
2118
2109
  choices_fields = []
2119
2110
 
2120
2111
  @classmethod
2121
2112
  def setUpTestData(cls):
2122
2113
  user = User.objects.create(username="user1", is_active=True)
2123
- job_model = Job.objects.get_for_class_path("local/test_pass/TestPass")
2114
+ job_model = Job.objects.get_for_class_path("pass.TestPass")
2124
2115
  ScheduledJob.objects.create(
2125
2116
  name="test1",
2126
- task="nautobot.extras.jobs.scheduled_job_handler",
2117
+ task="pass.TestPass",
2127
2118
  job_class=job_model.class_path,
2128
2119
  job_model=job_model,
2129
2120
  interval=JobExecutionType.TYPE_IMMEDIATELY,
@@ -2133,7 +2124,7 @@ class ScheduledJobTest(
2133
2124
  )
2134
2125
  ScheduledJob.objects.create(
2135
2126
  name="test2",
2136
- task="nautobot.extras.jobs.scheduled_job_handler",
2127
+ task="pass.TestPass",
2137
2128
  job_class=job_model.class_path,
2138
2129
  job_model=job_model,
2139
2130
  interval=JobExecutionType.TYPE_IMMEDIATELY,
@@ -2143,7 +2134,7 @@ class ScheduledJobTest(
2143
2134
  )
2144
2135
  ScheduledJob.objects.create(
2145
2136
  name="test3",
2146
- task="nautobot.extras.jobs.scheduled_job_handler",
2137
+ task="pass.TestPass",
2147
2138
  job_class=job_model.class_path,
2148
2139
  job_model=job_model,
2149
2140
  interval=JobExecutionType.TYPE_IMMEDIATELY,
@@ -2152,17 +2143,26 @@ class ScheduledJobTest(
2152
2143
  start_time=now(),
2153
2144
  )
2154
2145
 
2146
+ # TODO: Unskip after resolving #2908, #2909
2147
+ @skip("DRF's built-in OrderingFilter triggering natural key attribute error in our base")
2148
+ def test_list_objects_ascending_ordered(self):
2149
+ pass
2150
+
2151
+ @skip("DRF's built-in OrderingFilter triggering natural key attribute error in our base")
2152
+ def test_list_objects_descending_ordered(self):
2153
+ pass
2154
+
2155
2155
 
2156
2156
  class JobApprovalTest(APITestCase):
2157
2157
  @classmethod
2158
2158
  def setUpTestData(cls):
2159
2159
  cls.additional_user = User.objects.create(username="user1", is_active=True)
2160
- cls.job_model = Job.objects.get_for_class_path("local/test_pass/TestPass")
2160
+ cls.job_model = Job.objects.get_for_class_path("pass.TestPass")
2161
2161
  cls.job_model.enabled = True
2162
2162
  cls.job_model.save()
2163
2163
  cls.scheduled_job = ScheduledJob.objects.create(
2164
2164
  name="test",
2165
- task="nautobot.extras.jobs.scheduled_job_handler",
2165
+ task="pass.TestPass",
2166
2166
  job_class=cls.job_model.class_path,
2167
2167
  job_model=cls.job_model,
2168
2168
  interval=JobExecutionType.TYPE_IMMEDIATELY,
@@ -2170,6 +2170,19 @@ class JobApprovalTest(APITestCase):
2170
2170
  approval_required=True,
2171
2171
  start_time=now(),
2172
2172
  )
2173
+ cls.dryrun_job_model = Job.objects.get_for_class_path("dry_run.TestDryRun")
2174
+ cls.dryrun_job_model.enabled = True
2175
+ cls.dryrun_job_model.save()
2176
+ cls.dryrun_scheduled_job = ScheduledJob.objects.create(
2177
+ name="test",
2178
+ task="dry_run.TestDryRun",
2179
+ job_class=cls.dryrun_job_model.class_path,
2180
+ job_model=cls.dryrun_job_model,
2181
+ interval=JobExecutionType.TYPE_IMMEDIATELY,
2182
+ user=cls.additional_user,
2183
+ approval_required=True,
2184
+ start_time=now(),
2185
+ )
2173
2186
 
2174
2187
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2175
2188
  def test_approve_job_anonymous(self):
@@ -2203,7 +2216,7 @@ class JobApprovalTest(APITestCase):
2203
2216
  self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
2204
2217
  scheduled_job = ScheduledJob.objects.create(
2205
2218
  name="test",
2206
- task="nautobot.extras.jobs.scheduled_job_handler",
2219
+ task="pass.TestPass",
2207
2220
  job_class=self.job_model.class_path,
2208
2221
  job_model=self.job_model,
2209
2222
  interval=JobExecutionType.TYPE_IMMEDIATELY,
@@ -2227,7 +2240,7 @@ class JobApprovalTest(APITestCase):
2227
2240
  self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
2228
2241
  scheduled_job = ScheduledJob.objects.create(
2229
2242
  name="test",
2230
- task="nautobot.extras.jobs.scheduled_job_handler",
2243
+ task="pass.TestPass",
2231
2244
  job_class=self.job_model.class_path,
2232
2245
  job_model=self.job_model,
2233
2246
  interval=JobExecutionType.TYPE_FUTURE,
@@ -2245,7 +2258,7 @@ class JobApprovalTest(APITestCase):
2245
2258
  self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
2246
2259
  scheduled_job = ScheduledJob.objects.create(
2247
2260
  name="test",
2248
- task="nautobot.extras.jobs.scheduled_job_handler",
2261
+ task="pass.TestPass",
2249
2262
  job_class=self.job_model.class_path,
2250
2263
  job_model=self.job_model,
2251
2264
  interval=JobExecutionType.TYPE_FUTURE,
@@ -2289,7 +2302,7 @@ class JobApprovalTest(APITestCase):
2289
2302
 
2290
2303
  @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
2291
2304
  def test_dry_run_job_without_permission(self):
2292
- url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.scheduled_job.pk})
2305
+ url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.dryrun_scheduled_job.pk})
2293
2306
  with disable_warnings("django.request"):
2294
2307
  response = self.client.post(url, **self.header)
2295
2308
  self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
@@ -2297,29 +2310,27 @@ class JobApprovalTest(APITestCase):
2297
2310
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2298
2311
  def test_dry_run_job_without_run_job_permission(self):
2299
2312
  self.add_permissions("extras.view_scheduledjob")
2300
- url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.scheduled_job.pk})
2313
+ url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.dryrun_scheduled_job.pk})
2301
2314
  response = self.client.post(url, **self.header)
2302
2315
  self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
2303
2316
 
2304
2317
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2305
2318
  def test_dry_run_job(self):
2306
2319
  self.add_permissions("extras.run_job", "extras.view_scheduledjob")
2307
- url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.scheduled_job.pk})
2320
+ url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.dryrun_scheduled_job.pk})
2308
2321
  response = self.client.post(url, **self.header)
2309
2322
  self.assertHttpStatus(response, status.HTTP_200_OK)
2310
2323
 
2324
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2325
+ def test_dry_run_not_supported(self):
2326
+ self.add_permissions("extras.run_job", "extras.view_scheduledjob")
2327
+ url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.scheduled_job.pk})
2328
+ response = self.client.post(url, **self.header)
2329
+ self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
2330
+
2311
2331
 
2312
2332
  class NoteTest(APIViewTestCases.APIViewTestCase):
2313
2333
  model = Note
2314
- brief_fields = [
2315
- "assigned_object",
2316
- "display",
2317
- "id",
2318
- "note",
2319
- "slug",
2320
- "url",
2321
- "user",
2322
- ]
2323
2334
  choices_fields = ["assigned_object_type"]
2324
2335
 
2325
2336
  @classmethod
@@ -2370,376 +2381,363 @@ class NoteTest(APIViewTestCases.APIViewTestCase):
2370
2381
  )
2371
2382
 
2372
2383
 
2373
- # @skip(reason="Content Types are BROKEN")
2374
- # class RelationshipTest(APIViewTestCases.APIViewTestCase, RequiredRelationshipTestMixin):
2375
- # model = Relationship
2376
- # brief_fields = ["display", "id", "name", "slug", "url"]
2377
-
2378
- # create_data = [
2379
- # {
2380
- # "name": "Device VLANs",
2381
- # "slug": "device-vlans",
2382
- # "type": "many-to-many",
2383
- # "source_type": "ipam.vlan",
2384
- # "destination_type": "dcim.device",
2385
- # },
2386
- # {
2387
- # "name": "Primary VLAN",
2388
- # "slug": "primary-vlan",
2389
- # "type": "one-to-many",
2390
- # "source_type": "ipam.vlan",
2391
- # "destination_type": "dcim.device",
2392
- # },
2393
- # {
2394
- # "name": "Primary Interface",
2395
- # "slug": "primary-interface",
2396
- # "type": "one-to-one",
2397
- # "source_type": "dcim.device",
2398
- # "source_label": "primary interface",
2399
- # "destination_type": "dcim.interface",
2400
- # "destination_hidden": True,
2401
- # },
2402
- # {
2403
- # "name": "Relationship 1",
2404
- # "type": "one-to-one",
2405
- # "source_type": "dcim.device",
2406
- # "source_label": "primary interface",
2407
- # "destination_type": "dcim.interface",
2408
- # "destination_hidden": True,
2409
- # },
2410
- # ]
2411
-
2412
- # bulk_update_data = {
2413
- # "source_filter": {"slug": ["some-slug"]},
2414
- # }
2415
- # choices_fields = ["destination_type", "source_type", "type", "required_on"]
2416
- # slug_source = "name"
2417
- # slugify_function = staticmethod(slugify_dashes_to_underscores)
2418
-
2419
- # @classmethod
2420
- # def setUpTestData(cls):
2421
- # location_type = ContentType.objects.get_for_model(Location)
2422
- # device_type = ContentType.objects.get_for_model(Device)
2423
-
2424
- # cls.relationships = (
2425
- # Relationship(
2426
- # name="Related locations",
2427
- # slug="related-locations",
2428
- # type="symmetric-many-to-many",
2429
- # source_type=location_type,
2430
- # destination_type=location_type,
2431
- # ),
2432
- # Relationship(
2433
- # name="Unrelated locations",
2434
- # slug="unrelated-locations",
2435
- # type="many-to-many",
2436
- # source_type=location_type,
2437
- # source_label="Other locations (from source side)",
2438
- # destination_type=location_type,
2439
- # destination_label="Other locations (from destination side)",
2440
- # ),
2441
- # Relationship(
2442
- # name="Devices found elsewhere",
2443
- # slug="devices-elsewhere",
2444
- # type="many-to-many",
2445
- # source_type=location_type,
2446
- # destination_type=device_type,
2447
- # ),
2448
- # )
2449
- # for relationship in cls.relationships:
2450
- # relationship.validated_save()
2451
- # cls.lt = LocationType.objects.get(name="Campus")
2452
- # location_status = Status.objects.get_for_model(Location).first()
2453
- # cls.location = Location.objects.create(name="Location 1", status=location_status, location_type=cls.lt)
2454
-
2455
- # def test_get_all_relationships_on_location(self):
2456
- # """Verify that all relationships are accurately represented when requested."""
2457
- # self.add_permissions("dcim.view_location")
2458
- # response = self.client.get(
2459
- # reverse("dcim-api:location-detail", kwargs={"pk": self.location.pk}) + "?include=relationships",
2460
- # **self.header,
2461
- # )
2462
- # self.assertHttpStatus(response, status.HTTP_200_OK)
2463
- # self.assertIn("relationships", response.data)
2464
- # self.assertIsInstance(response.data["relationships"], dict)
2465
- # self.maxDiff = None
2466
- # self.assertEqual(
2467
- # {
2468
- # self.relationships[0].slug: {
2469
- # "id": str(self.relationships[0].pk),
2470
- # "url": (
2471
- # "http://nautobot.example.com"
2472
- # + reverse("extras-api:relationship-detail", kwargs={"pk": self.relationships[0].pk})
2473
- # ),
2474
- # "name": self.relationships[0].name,
2475
- # "type": self.relationships[0].type,
2476
- # "peer": {
2477
- # "label": "locations",
2478
- # "object_type": "dcim.location",
2479
- # "objects": [],
2480
- # },
2481
- # },
2482
- # self.relationships[1].slug: {
2483
- # "id": str(self.relationships[1].pk),
2484
- # "url": (
2485
- # "http://nautobot.example.com"
2486
- # + reverse("extras-api:relationship-detail", kwargs={"pk": self.relationships[1].pk})
2487
- # ),
2488
- # "name": self.relationships[1].name,
2489
- # "type": self.relationships[1].type,
2490
- # "destination": {
2491
- # "label": self.relationships[1].source_label, # yes -- it's a bit confusing
2492
- # "object_type": "dcim.location",
2493
- # "objects": [],
2494
- # },
2495
- # "source": {
2496
- # "label": self.relationships[1].destination_label, # yes -- it's a bit confusing
2497
- # "object_type": "dcim.location",
2498
- # "objects": [],
2499
- # },
2500
- # },
2501
- # self.relationships[2].slug: {
2502
- # "id": str(self.relationships[2].pk),
2503
- # "url": (
2504
- # "http://nautobot.example.com"
2505
- # + reverse("extras-api:relationship-detail", kwargs={"pk": self.relationships[2].pk})
2506
- # ),
2507
- # "name": self.relationships[2].name,
2508
- # "type": self.relationships[2].type,
2509
- # "destination": {
2510
- # "label": "devices",
2511
- # "object_type": "dcim.device",
2512
- # "objects": [],
2513
- # },
2514
- # },
2515
- # },
2516
- # response.data["relationships"],
2517
- # )
2518
-
2519
- # def test_populate_relationship_associations_on_location_create(self):
2520
- # """Verify that relationship associations can be populated at instance creation time."""
2521
- # location_type = LocationType.objects.get(name="Campus")
2522
- # existing_location_1 = Location.objects.create(
2523
- # name="Existing Location 1",
2524
- # status=Status.objects.get_for_model(Location).first(),
2525
- # location_type=location_type,
2526
- # )
2527
- # existing_location_2 = Location.objects.create(
2528
- # name="Existing Location 2",
2529
- # status=Status.objects.get_for_model(Location).first(),
2530
- # location_type=location_type,
2531
- # )
2532
- # manufacturer = Manufacturer.objects.first()
2533
- # device_type = DeviceType.objects.create(
2534
- # manufacturer=manufacturer,
2535
- # model="device Type 1",
2536
- # slug="device-type-1",
2537
- # )
2538
- # device_role = Role.objects.get_for_model(Device).first()
2539
- # device_status = Status.objects.get_for_model(Device).first()
2540
- # existing_device_1 = Device.objects.create(
2541
- # name="existing-device-location-1",
2542
- # status=device_status,
2543
- # role=device_role,
2544
- # device_type=device_type,
2545
- # location=existing_location_1,
2546
- # )
2547
- # existing_device_2 = Device.objects.create(
2548
- # name="existing-device-location-2",
2549
- # status=device_status,
2550
- # role=device_role,
2551
- # device_type=device_type,
2552
- # location=existing_location_2,
2553
- # )
2554
-
2555
- # self.add_permissions("dcim.view_location", "dcim.add_location", "extras.add_relationshipassociation")
2556
- # response = self.client.post(
2557
- # reverse("dcim-api:location-list"),
2558
- # data={
2559
- # "name": "New location",
2560
- # "status": Status.objects.get_for_model(Location).first().pk,
2561
- # "location_type": location_type.pk,
2562
- # "relationships": {
2563
- # self.relationships[0].slug: {
2564
- # "peer": {
2565
- # "objects": [str(existing_location_1.pk)],
2566
- # },
2567
- # },
2568
- # self.relationships[1].slug: {
2569
- # "source": {
2570
- # "objects": [str(existing_location_2.pk)],
2571
- # },
2572
- # },
2573
- # self.relationships[2].slug: {
2574
- # "destination": {
2575
- # "objects": [
2576
- # {"name": "existing-device-location-1"},
2577
- # {"name": "existing-device-location-2"},
2578
- # ],
2579
- # },
2580
- # },
2581
- # },
2582
- # },
2583
- # format="json",
2584
- # **self.header,
2585
- # )
2586
- # self.assertHttpStatus(response, status.HTTP_201_CREATED)
2587
- # new_location_id = response.data["id"]
2588
- # # Peer case - don't distinguish source/destination
2589
- # self.assertTrue(
2590
- # RelationshipAssociation.objects.filter(
2591
- # relationship=self.relationships[0],
2592
- # source_type=self.relationships[0].source_type,
2593
- # source_id__in=[existing_location_1.pk, new_location_id],
2594
- # destination_type=self.relationships[0].destination_type,
2595
- # destination_id__in=[existing_location_1.pk, new_location_id],
2596
- # ).exists()
2597
- # )
2598
- # self.assertTrue(
2599
- # RelationshipAssociation.objects.filter(
2600
- # relationship=self.relationships[1],
2601
- # source_type=self.relationships[1].source_type,
2602
- # source_id=existing_location_2.pk,
2603
- # destination_type=self.relationships[1].destination_type,
2604
- # destination_id=new_location_id,
2605
- # ).exists()
2606
- # )
2607
- # self.assertTrue(
2608
- # RelationshipAssociation.objects.filter(
2609
- # relationship=self.relationships[2],
2610
- # source_type=self.relationships[2].source_type,
2611
- # source_id=new_location_id,
2612
- # destination_type=self.relationships[2].destination_type,
2613
- # destination_id=existing_device_1.pk,
2614
- # ).exists()
2615
- # )
2616
- # self.assertTrue(
2617
- # RelationshipAssociation.objects.filter(
2618
- # relationship=self.relationships[2],
2619
- # source_type=self.relationships[2].source_type,
2620
- # source_id=new_location_id,
2621
- # destination_type=self.relationships[2].destination_type,
2622
- # destination_id=existing_device_2.pk,
2623
- # ).exists()
2624
- # )
2625
-
2626
- # def test_required_relationships(self):
2627
- # """
2628
- # 1. Try creating an object when no required target object exists
2629
- # 2. Try creating an object without specifying required target object(s)
2630
- # 3. Try creating an object when all required data is present
2631
- # 4. Test various bulk create/edit scenarios
2632
- # """
2633
-
2634
- # # Parameterized tests (for creating and updating single objects):
2635
- # self.required_relationships_test(interact_with="api")
2636
-
2637
- # # 4. Bulk create/edit tests:
2638
-
2639
- # # VLAN endpoint to POST, PATCH and PUT multiple objects to:
2640
- # vlan_list_endpoint = reverse(get_route_for_model(VLAN, "list", api=True))
2641
-
2642
- # def send_bulk_data(http_method, data):
2643
- # return getattr(self.client, http_method)(
2644
- # vlan_list_endpoint,
2645
- # data=data,
2646
- # format="json",
2647
- # **self.header,
2648
- # )
2649
-
2650
- # device_status = Status.objects.get_for_model(Device).first()
2651
-
2652
- # # Try deleting all devices and then creating 2 VLANs (fails):
2653
- # Device.objects.all().delete()
2654
- # response = send_bulk_data(
2655
- # "post",
2656
- # data=[
2657
- # {"vid": "1", "name": "1", "status": device_status.pk},
2658
- # {"vid": "2", "name": "2", "status": device_status.pk},
2659
- # ],
2660
- # )
2661
- # self.assertHttpStatus(response, 400)
2662
- # self.assertEqual(
2663
- # {
2664
- # "relationships": {
2665
- # "vlans-devices-m2m": [
2666
- # "VLANs require at least one device, but no devices exist yet. "
2667
- # "Create a device by posting to /api/dcim/devices/",
2668
- # 'You need to specify ["relationships"]["vlans-devices-m2m"]["source"]["objects"].',
2669
- # ]
2670
- # }
2671
- # },
2672
- # response.json(),
2673
- # )
2674
-
2675
- # # Create test device for association
2676
- # device_for_association = test_views.create_test_device("VLAN Required Device")
2677
- # required_relationship_json = {"vlans-devices-m2m": {"source": {"objects": [str(device_for_association.id)]}}}
2678
- # expected_error_json = {
2679
- # "relationships": {
2680
- # "vlans-devices-m2m": [
2681
- # 'You need to specify ["relationships"]["vlans-devices-m2m"]["source"]["objects"].'
2682
- # ]
2683
- # }
2684
- # }
2685
-
2686
- # # Test POST, PATCH and PUT
2687
- # for method in ["post", "patch", "put"]:
2688
- # if method == "post":
2689
- # vlan1_json_data = {
2690
- # "vid": "1",
2691
- # "name": "1",
2692
- # "status": device_status.pk,
2693
- # }
2694
- # vlan2_json_data = {
2695
- # "vid": "2",
2696
- # "name": "2",
2697
- # "status": device_status.pk,
2698
- # }
2699
- # else:
2700
- # vlan1, vlan2 = VLANFactory.create_batch(2)
2701
- # vlan1_json_data = {"status": device_status.pk, "id": str(vlan1.id)}
2702
- # # Add required fields for PUT method:
2703
- # if method == "put":
2704
- # vlan1_json_data.update({"vid": vlan1.vid, "name": vlan1.name})
2705
-
2706
- # vlan2_json_data = {"status": device_status.pk, "id": str(vlan2.id)}
2707
- # # Add required fields for PUT method:
2708
- # if method == "put":
2709
- # vlan2_json_data.update({"vid": vlan2.vid, "name": vlan2.name})
2710
-
2711
- # # Try method without specifying required relationships for either vlan1 or vlan2 (fails)
2712
- # json_data = [vlan1_json_data, vlan2_json_data]
2713
- # response = send_bulk_data(method, json_data)
2714
- # self.assertHttpStatus(response, 400)
2715
- # self.assertEqual(response.json(), expected_error_json)
2716
-
2717
- # # Try method specifying required relationships for just vlan1 (fails)
2718
- # vlan1_json_data["relationships"] = required_relationship_json
2719
- # json_data = [vlan1_json_data, vlan2_json_data]
2720
- # response = send_bulk_data(method, json_data)
2721
- # self.assertHttpStatus(response, 400)
2722
- # self.assertEqual(response.json(), expected_error_json)
2723
-
2724
- # # Try method specifying required relationships for both vlan1 and vlan2 (succeeds)
2725
- # vlan2_json_data["relationships"] = required_relationship_json
2726
- # json_data = [vlan1_json_data, vlan2_json_data]
2727
- # response = send_bulk_data(method, json_data)
2728
- # if method == "post":
2729
- # self.assertHttpStatus(response, 201)
2730
- # else:
2731
- # self.assertHttpStatus(response, 200)
2732
-
2733
- # # Check the relationship associations were actually created
2734
- # for vlan in response.json():
2735
- # associated_device = vlan["relationships"]["vlans-devices-m2m"]["source"]["objects"][0]
2736
- # self.assertEqual(str(device_for_association.id), associated_device["id"])
2737
-
2738
-
2739
- @skip(reason="Content Types are BROKEN")
2384
+ class RelationshipTest(APIViewTestCases.APIViewTestCase, RequiredRelationshipTestMixin):
2385
+ model = Relationship
2386
+
2387
+ create_data = [
2388
+ {
2389
+ "label": "Device VLANs",
2390
+ "key": "device_vlans",
2391
+ "type": "many-to-many",
2392
+ "source_type": "ipam.vlan",
2393
+ "destination_type": "dcim.device",
2394
+ },
2395
+ {
2396
+ "label": "Primary VLAN",
2397
+ "key": "primary_vlan",
2398
+ "type": "one-to-many",
2399
+ "source_type": "ipam.vlan",
2400
+ "destination_type": "dcim.device",
2401
+ },
2402
+ {
2403
+ "label": "Primary Interface",
2404
+ "key": "primary_interface",
2405
+ "type": "one-to-one",
2406
+ "source_type": "dcim.device",
2407
+ "source_label": "primary interface",
2408
+ "destination_type": "dcim.interface",
2409
+ "destination_hidden": True,
2410
+ },
2411
+ {
2412
+ "label": "Relationship 1",
2413
+ "type": "one-to-one",
2414
+ "source_type": "dcim.device",
2415
+ "source_label": "primary interface",
2416
+ "destination_type": "dcim.interface",
2417
+ "destination_hidden": True,
2418
+ },
2419
+ ]
2420
+
2421
+ bulk_update_data = {
2422
+ "source_filter": {"slug": ["some-slug"]},
2423
+ }
2424
+ choices_fields = ["destination_type", "source_type", "type", "required_on"]
2425
+ slug_source = "label"
2426
+ slugify_function = staticmethod(slugify_dashes_to_underscores)
2427
+
2428
+ @classmethod
2429
+ def setUpTestData(cls):
2430
+ location_type = ContentType.objects.get_for_model(Location)
2431
+ device_type = ContentType.objects.get_for_model(Device)
2432
+
2433
+ cls.relationships = (
2434
+ Relationship(
2435
+ label="Related locations",
2436
+ key="related_locations",
2437
+ type="symmetric-many-to-many",
2438
+ source_type=location_type,
2439
+ destination_type=location_type,
2440
+ ),
2441
+ Relationship(
2442
+ label="Unrelated locations",
2443
+ key="unrelated_locations",
2444
+ type="many-to-many",
2445
+ source_type=location_type,
2446
+ source_label="Other locations (from source side)",
2447
+ destination_type=location_type,
2448
+ destination_label="Other locations (from destination side)",
2449
+ ),
2450
+ Relationship(
2451
+ label="Devices found elsewhere",
2452
+ key="devices_elsewhere",
2453
+ type="many-to-many",
2454
+ source_type=location_type,
2455
+ destination_type=device_type,
2456
+ ),
2457
+ )
2458
+ for relationship in cls.relationships:
2459
+ relationship.validated_save()
2460
+ cls.lt = LocationType.objects.get(name="Campus")
2461
+ location_status = Status.objects.get_for_model(Location).first()
2462
+ cls.location = Location.objects.create(name="Location 1", status=location_status, location_type=cls.lt)
2463
+
2464
+ def test_get_all_relationships_on_location(self):
2465
+ """Verify that all relationships are accurately represented when requested."""
2466
+ self.add_permissions("dcim.view_location")
2467
+ response = self.client.get(
2468
+ reverse("dcim-api:location-detail", kwargs={"pk": self.location.pk}) + "?include=relationships",
2469
+ **self.header,
2470
+ )
2471
+ self.assertHttpStatus(response, status.HTTP_200_OK)
2472
+ self.assertIn("relationships", response.data)
2473
+ self.assertIsInstance(response.data["relationships"], dict)
2474
+ self.maxDiff = None
2475
+ self.assertEqual(
2476
+ {
2477
+ self.relationships[0].key: {
2478
+ "id": str(self.relationships[0].pk),
2479
+ "url": self.absolute_api_url(self.relationships[0]),
2480
+ "label": self.relationships[0].label,
2481
+ "type": self.relationships[0].type,
2482
+ "peer": {
2483
+ "label": "locations",
2484
+ "object_type": "dcim.location",
2485
+ "objects": [],
2486
+ },
2487
+ },
2488
+ self.relationships[1].key: {
2489
+ "id": str(self.relationships[1].pk),
2490
+ "url": self.absolute_api_url(self.relationships[1]),
2491
+ "label": self.relationships[1].label,
2492
+ "type": self.relationships[1].type,
2493
+ "destination": {
2494
+ "label": self.relationships[1].source_label, # yes -- it's a bit confusing
2495
+ "object_type": "dcim.location",
2496
+ "objects": [],
2497
+ },
2498
+ "source": {
2499
+ "label": self.relationships[1].destination_label, # yes -- it's a bit confusing
2500
+ "object_type": "dcim.location",
2501
+ "objects": [],
2502
+ },
2503
+ },
2504
+ self.relationships[2].key: {
2505
+ "id": str(self.relationships[2].pk),
2506
+ "url": self.absolute_api_url(self.relationships[2]),
2507
+ "label": self.relationships[2].label,
2508
+ "type": self.relationships[2].type,
2509
+ "destination": {
2510
+ "label": "devices",
2511
+ "object_type": "dcim.device",
2512
+ "objects": [],
2513
+ },
2514
+ },
2515
+ },
2516
+ response.data["relationships"],
2517
+ )
2518
+
2519
+ def test_populate_relationship_associations_on_location_create(self):
2520
+ """Verify that relationship associations can be populated at instance creation time."""
2521
+ location_type = LocationType.objects.get(name="Campus")
2522
+ existing_location_1 = Location.objects.create(
2523
+ name="Existing Location 1",
2524
+ status=Status.objects.get_for_model(Location).first(),
2525
+ location_type=location_type,
2526
+ )
2527
+ existing_location_2 = Location.objects.create(
2528
+ name="Existing Location 2",
2529
+ status=Status.objects.get_for_model(Location).first(),
2530
+ location_type=location_type,
2531
+ )
2532
+ manufacturer = Manufacturer.objects.first()
2533
+ device_type = DeviceType.objects.create(
2534
+ manufacturer=manufacturer,
2535
+ model="device Type 1",
2536
+ slug="device-type-1",
2537
+ )
2538
+ device_role = Role.objects.get_for_model(Device).first()
2539
+ device_status = Status.objects.get_for_model(Device).first()
2540
+ existing_device_1 = Device.objects.create(
2541
+ name="existing-device-location-1",
2542
+ status=device_status,
2543
+ role=device_role,
2544
+ device_type=device_type,
2545
+ location=existing_location_1,
2546
+ )
2547
+ existing_device_2 = Device.objects.create(
2548
+ name="existing-device-location-2",
2549
+ status=device_status,
2550
+ role=device_role,
2551
+ device_type=device_type,
2552
+ location=existing_location_2,
2553
+ )
2554
+
2555
+ self.add_permissions("dcim.view_location", "dcim.add_location", "extras.add_relationshipassociation")
2556
+ response = self.client.post(
2557
+ reverse("dcim-api:location-list"),
2558
+ data={
2559
+ "name": "New location",
2560
+ "status": Status.objects.get_for_model(Location).first().pk,
2561
+ "location_type": location_type.pk,
2562
+ "relationships": {
2563
+ self.relationships[0].key: {
2564
+ "peer": {
2565
+ "objects": [str(existing_location_1.pk)],
2566
+ },
2567
+ },
2568
+ self.relationships[1].key: {
2569
+ "source": {
2570
+ "objects": [str(existing_location_2.pk)],
2571
+ },
2572
+ },
2573
+ self.relationships[2].key: {
2574
+ "destination": {
2575
+ "objects": [
2576
+ {"name": "existing-device-location-1"},
2577
+ {"name": "existing-device-location-2"},
2578
+ ],
2579
+ },
2580
+ },
2581
+ },
2582
+ },
2583
+ format="json",
2584
+ **self.header,
2585
+ )
2586
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
2587
+ new_location_id = response.data["id"]
2588
+ # Peer case - don't distinguish source/destination
2589
+ self.assertTrue(
2590
+ RelationshipAssociation.objects.filter(
2591
+ relationship=self.relationships[0],
2592
+ source_type=self.relationships[0].source_type,
2593
+ source_id__in=[existing_location_1.pk, new_location_id],
2594
+ destination_type=self.relationships[0].destination_type,
2595
+ destination_id__in=[existing_location_1.pk, new_location_id],
2596
+ ).exists()
2597
+ )
2598
+ self.assertTrue(
2599
+ RelationshipAssociation.objects.filter(
2600
+ relationship=self.relationships[1],
2601
+ source_type=self.relationships[1].source_type,
2602
+ source_id=existing_location_2.pk,
2603
+ destination_type=self.relationships[1].destination_type,
2604
+ destination_id=new_location_id,
2605
+ ).exists()
2606
+ )
2607
+ self.assertTrue(
2608
+ RelationshipAssociation.objects.filter(
2609
+ relationship=self.relationships[2],
2610
+ source_type=self.relationships[2].source_type,
2611
+ source_id=new_location_id,
2612
+ destination_type=self.relationships[2].destination_type,
2613
+ destination_id=existing_device_1.pk,
2614
+ ).exists()
2615
+ )
2616
+ self.assertTrue(
2617
+ RelationshipAssociation.objects.filter(
2618
+ relationship=self.relationships[2],
2619
+ source_type=self.relationships[2].source_type,
2620
+ source_id=new_location_id,
2621
+ destination_type=self.relationships[2].destination_type,
2622
+ destination_id=existing_device_2.pk,
2623
+ ).exists()
2624
+ )
2625
+
2626
+ def test_required_relationships(self):
2627
+ """
2628
+ 1. Try creating an object when no required target object exists
2629
+ 2. Try creating an object without specifying required target object(s)
2630
+ 3. Try creating an object when all required data is present
2631
+ 4. Test various bulk create/edit scenarios
2632
+ """
2633
+
2634
+ # Parameterized tests (for creating and updating single objects):
2635
+ self.required_relationships_test(interact_with="api")
2636
+
2637
+ # 4. Bulk create/edit tests:
2638
+
2639
+ # VLAN endpoint to POST, PATCH and PUT multiple objects to:
2640
+ vlan_list_endpoint = reverse(get_route_for_model(VLAN, "list", api=True))
2641
+
2642
+ def send_bulk_data(http_method, data):
2643
+ return getattr(self.client, http_method)(
2644
+ vlan_list_endpoint,
2645
+ data=data,
2646
+ format="json",
2647
+ **self.header,
2648
+ )
2649
+
2650
+ device_status = Status.objects.get_for_model(Device).first()
2651
+
2652
+ # Try deleting all devices and then creating 2 VLANs (fails):
2653
+ Device.objects.all().delete()
2654
+ response = send_bulk_data(
2655
+ "post",
2656
+ data=[
2657
+ {"vid": "1", "name": "1", "status": device_status.pk},
2658
+ {"vid": "2", "name": "2", "status": device_status.pk},
2659
+ ],
2660
+ )
2661
+ self.assertHttpStatus(response, 400)
2662
+ self.assertEqual(
2663
+ {
2664
+ "relationships": {
2665
+ "vlans_devices_m2m": [
2666
+ "VLANs require at least one device, but no devices exist yet. "
2667
+ "Create a device by posting to /api/dcim/devices/",
2668
+ 'You need to specify ["relationships"]["vlans_devices_m2m"]["source"]["objects"].',
2669
+ ]
2670
+ }
2671
+ },
2672
+ response.json(),
2673
+ )
2674
+
2675
+ # Create test device for association
2676
+ device_for_association = test_views.create_test_device("VLAN Required Device")
2677
+ required_relationship_json = {"vlans_devices_m2m": {"source": {"objects": [str(device_for_association.id)]}}}
2678
+ expected_error_json = {
2679
+ "relationships": {
2680
+ "vlans_devices_m2m": [
2681
+ 'You need to specify ["relationships"]["vlans_devices_m2m"]["source"]["objects"].'
2682
+ ]
2683
+ }
2684
+ }
2685
+
2686
+ # Test POST, PATCH and PUT
2687
+ for method in ["post", "patch", "put"]:
2688
+ if method == "post":
2689
+ vlan1_json_data = {
2690
+ "vid": "1",
2691
+ "name": "1",
2692
+ "status": device_status.pk,
2693
+ }
2694
+ vlan2_json_data = {
2695
+ "vid": "2",
2696
+ "name": "2",
2697
+ "status": device_status.pk,
2698
+ }
2699
+ else:
2700
+ vlan1, vlan2 = VLANFactory.create_batch(2)
2701
+ vlan1_json_data = {"status": device_status.pk, "id": str(vlan1.id)}
2702
+ # Add required fields for PUT method:
2703
+ if method == "put":
2704
+ vlan1_json_data.update({"vid": vlan1.vid, "name": vlan1.name})
2705
+
2706
+ vlan2_json_data = {"status": device_status.pk, "id": str(vlan2.id)}
2707
+ # Add required fields for PUT method:
2708
+ if method == "put":
2709
+ vlan2_json_data.update({"vid": vlan2.vid, "name": vlan2.name})
2710
+
2711
+ # Try method without specifying required relationships for either vlan1 or vlan2 (fails)
2712
+ json_data = [vlan1_json_data, vlan2_json_data]
2713
+ response = send_bulk_data(method, json_data)
2714
+ self.assertHttpStatus(response, 400)
2715
+ self.assertEqual(response.json(), expected_error_json)
2716
+
2717
+ # Try method specifying required relationships for just vlan1 (fails)
2718
+ vlan1_json_data["relationships"] = required_relationship_json
2719
+ json_data = [vlan1_json_data, vlan2_json_data]
2720
+ response = send_bulk_data(method, json_data)
2721
+ self.assertHttpStatus(response, 400)
2722
+ self.assertEqual(response.json(), expected_error_json)
2723
+
2724
+ # Try method specifying required relationships for both vlan1 and vlan2 (succeeds)
2725
+ vlan2_json_data["relationships"] = required_relationship_json
2726
+ json_data = [vlan1_json_data, vlan2_json_data]
2727
+ response = send_bulk_data(method, json_data)
2728
+ if method == "post":
2729
+ self.assertHttpStatus(response, 201)
2730
+ else:
2731
+ self.assertHttpStatus(response, 200)
2732
+
2733
+ # Check the relationship associations were actually created
2734
+ for vlan in response.json():
2735
+ associated_device = vlan["relationships"]["vlans_devices_m2m"]["source"]["objects"][0]
2736
+ self.assertEqual(str(device_for_association.id), associated_device["id"])
2737
+
2738
+
2740
2739
  class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
2741
2740
  model = RelationshipAssociation
2742
- brief_fields = ["destination_id", "display", "id", "relationship", "source_id", "url"]
2743
2741
  choices_fields = ["destination_type", "source_type"]
2744
2742
 
2745
2743
  @classmethod
@@ -2749,8 +2747,8 @@ class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
2749
2747
  cls.location_status = Status.objects.get_for_model(Location).first()
2750
2748
 
2751
2749
  cls.relationship = Relationship(
2752
- name="Devices found elsewhere",
2753
- slug="elsewhere-devices",
2750
+ label="Devices found elsewhere",
2751
+ key="elsewhere_devices",
2754
2752
  type="many-to-many",
2755
2753
  source_type=cls.location_type,
2756
2754
  destination_type=cls.device_type,
@@ -2840,8 +2838,8 @@ class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
2840
2838
  """Test creation of invalid relationship association restricted by destination/source filter."""
2841
2839
 
2842
2840
  relationship = Relationship.objects.create(
2843
- name="Device to location Rel 1",
2844
- slug="device-to-location-rel-1",
2841
+ label="Device to location Rel 1",
2842
+ key="device_to_location_rel_1",
2845
2843
  source_type=self.device_type,
2846
2844
  source_filter={"name": [self.devices[0].name]},
2847
2845
  destination_type=self.location_type,
@@ -2882,7 +2880,7 @@ class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
2882
2880
  self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
2883
2881
  self.assertEqual(
2884
2882
  response.data[side],
2885
- [f"{field_error_name} violates {relationship.name} {side}_filter restriction"],
2883
+ [f"{field_error_name} violates {relationship.label} {side}_filter restriction"],
2886
2884
  )
2887
2885
 
2888
2886
  def test_model_clean_method_is_called(self):
@@ -2910,62 +2908,37 @@ class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
2910
2908
  """
2911
2909
  self.add_permissions("dcim.view_location")
2912
2910
  response = self.client.get(
2913
- reverse("dcim-api:location-detail", kwargs={"pk": self.locations[0].pk}) + "?include=relationships",
2911
+ reverse("dcim-api:location-detail", kwargs={"pk": self.locations[0].pk})
2912
+ + "?include=relationships"
2913
+ + "&depth=1",
2914
2914
  **self.header,
2915
2915
  )
2916
2916
  self.assertHttpStatus(response, status.HTTP_200_OK)
2917
2917
  self.assertIn("relationships", response.data)
2918
2918
  self.assertIsInstance(response.data["relationships"], dict)
2919
2919
  # Ensure consistent ordering
2920
- response.data["relationships"][self.relationship.slug]["destination"]["objects"].sort(key=lambda v: v["name"])
2920
+ response.data["relationships"][self.relationship.key]["destination"]["objects"].sort(key=lambda v: v["name"])
2921
2921
  self.maxDiff = None
2922
- self.assertEqual(
2923
- {
2924
- self.relationship.slug: {
2925
- "id": str(self.relationship.pk),
2926
- "url": (
2927
- "http://nautobot.example.com"
2928
- + reverse("extras-api:relationship-detail", kwargs={"pk": self.relationship.pk})
2929
- ),
2930
- "name": self.relationship.name,
2931
- "type": "many-to-many",
2932
- "destination": {
2933
- "label": "devices",
2934
- "object_type": "dcim.device",
2935
- "objects": [
2936
- {
2937
- "id": str(self.devices[0].pk),
2938
- "url": (
2939
- "http://nautobot.example.com"
2940
- + reverse("dcim-api:device-detail", kwargs={"pk": self.devices[0].pk})
2941
- ),
2942
- "display": self.devices[0].display,
2943
- "name": self.devices[0].name,
2944
- },
2945
- {
2946
- "id": str(self.devices[1].pk),
2947
- "url": (
2948
- "http://nautobot.example.com"
2949
- + reverse("dcim-api:device-detail", kwargs={"pk": self.devices[1].pk})
2950
- ),
2951
- "display": self.devices[1].display,
2952
- "name": self.devices[1].name,
2953
- },
2954
- {
2955
- "id": str(self.devices[2].pk),
2956
- "url": (
2957
- "http://nautobot.example.com"
2958
- + reverse("dcim-api:device-detail", kwargs={"pk": self.devices[2].pk})
2959
- ),
2960
- "display": self.devices[2].display,
2961
- "name": self.devices[2].name,
2962
- },
2963
- ],
2964
- },
2965
- },
2966
- },
2967
- response.data["relationships"],
2968
- )
2922
+ relationship_data = response.data["relationships"][self.relationship.key]
2923
+ self.assertEqual(relationship_data["id"], str(self.relationship.pk))
2924
+ self.assertEqual(relationship_data["url"], self.absolute_api_url(self.relationship))
2925
+ self.assertEqual(relationship_data["label"], self.relationship.label)
2926
+ self.assertEqual(relationship_data["type"], "many-to-many")
2927
+ self.assertEqual(relationship_data["destination"]["label"], "devices")
2928
+ self.assertEqual(relationship_data["destination"]["object_type"], "dcim.device")
2929
+
2930
+ objects = response.data["relationships"][self.relationship.key]["destination"]["objects"]
2931
+ for i, obj in enumerate(objects):
2932
+ self.assertEqual(obj["id"], str(self.devices[i].pk))
2933
+ self.assertEqual(obj["url"], self.absolute_api_url(self.devices[i]))
2934
+ self.assertEqual(
2935
+ obj["display"],
2936
+ self.devices[i].display,
2937
+ )
2938
+ self.assertEqual(
2939
+ obj["name"],
2940
+ self.devices[i].name,
2941
+ )
2969
2942
 
2970
2943
  def test_update_association_data_on_location(self):
2971
2944
  """
@@ -3027,21 +3000,21 @@ class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
3027
3000
 
3028
3001
  with self.subTest("Error handling: wrong relationship"):
3029
3002
  Relationship.objects.create(
3030
- name="Device-to-Device",
3031
- slug="device-to-device",
3003
+ label="Device-to-Device",
3004
+ key="device_to_device",
3032
3005
  source_type=self.device_type,
3033
3006
  destination_type=self.device_type,
3034
3007
  type=RelationshipTypeChoices.TYPE_ONE_TO_ONE,
3035
3008
  )
3036
3009
  response = self.client.patch(
3037
3010
  url,
3038
- {"relationships": {"device-to-device": {"peer": {"objects": []}}}},
3011
+ {"relationships": {"device_to_device": {"peer": {"objects": []}}}},
3039
3012
  format="json",
3040
3013
  **self.header,
3041
3014
  )
3042
3015
  self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
3043
3016
  self.assertEqual(
3044
- str(response.data["relationships"][0]), '"device-to-device" is not a relationship on dcim.Location'
3017
+ str(response.data["relationships"][0]), '"device_to_device" is not a relationship on dcim.Location'
3045
3018
  )
3046
3019
  self.assertEqual(3, RelationshipAssociation.objects.filter(relationship=self.relationship).count())
3047
3020
  for association in self.associations:
@@ -3050,7 +3023,7 @@ class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
3050
3023
  with self.subTest("Error handling: wrong relationship side"):
3051
3024
  response = self.client.patch(
3052
3025
  url,
3053
- {"relationships": {self.relationship.slug: {"source": {"objects": []}}}},
3026
+ {"relationships": {self.relationship.key: {"source": {"objects": []}}}},
3054
3027
  format="json",
3055
3028
  **self.header,
3056
3029
  )
@@ -3068,7 +3041,7 @@ class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
3068
3041
  url,
3069
3042
  {
3070
3043
  "relationships": {
3071
- self.relationship.slug: {
3044
+ self.relationship.key: {
3072
3045
  "destination": {
3073
3046
  "objects": [
3074
3047
  # remove devices[0] by omission
@@ -3095,7 +3068,6 @@ class RelationshipAssociationTest(APIViewTestCases.APIViewTestCase):
3095
3068
 
3096
3069
  class SecretTest(APIViewTestCases.APIViewTestCase):
3097
3070
  model = Secret
3098
- brief_fields = ["display", "id", "name", "url"]
3099
3071
  bulk_update_data = {}
3100
3072
 
3101
3073
  create_data = [
@@ -3146,10 +3118,50 @@ class SecretTest(APIViewTestCases.APIViewTestCase):
3146
3118
  for secret in secrets:
3147
3119
  secret.validated_save()
3148
3120
 
3121
+ def test_secret_check(self):
3122
+ """
3123
+ Ensure that we can check the validity of a secret.
3124
+ """
3125
+
3126
+ with self.subTest("Secret is not accessible"):
3127
+ test_secret = Secret.objects.create(
3128
+ name="secret-check-test-not-accessible",
3129
+ provider="text-file",
3130
+ parameters={"path": "/tmp/does-not-matter"},
3131
+ )
3132
+ response = self.client.get(reverse("extras-api:secret-check", kwargs={"pk": test_secret.pk}), **self.header)
3133
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
3134
+
3135
+ self.add_permissions("extras.view_secret")
3136
+
3137
+ with self.subTest("Secret check successful"):
3138
+ with tempfile.NamedTemporaryFile() as secret_file:
3139
+ secret_file.write(b"HELLO WORLD")
3140
+ test_secret = Secret.objects.create(
3141
+ name="secret-check-test-accessible",
3142
+ provider="text-file",
3143
+ parameters={"path": secret_file.name},
3144
+ )
3145
+ response = self.client.get(
3146
+ reverse("extras-api:secret-check", kwargs={"pk": test_secret.pk}), **self.header
3147
+ )
3148
+ self.assertHttpStatus(response, status.HTTP_200_OK)
3149
+ self.assertEqual(response.data["result"], True)
3150
+
3151
+ with self.subTest("Secret check failed"):
3152
+ test_secret = Secret.objects.create(
3153
+ name="secret-check-test-failed",
3154
+ provider="text-file",
3155
+ parameters={"path": "/tmp/does-not-exist"},
3156
+ )
3157
+ response = self.client.get(reverse("extras-api:secret-check", kwargs={"pk": test_secret.pk}), **self.header)
3158
+ self.assertHttpStatus(response, status.HTTP_200_OK)
3159
+ self.assertEqual(response.data["result"], False)
3160
+ self.assertIn("SecretValueNotFoundError", response.data["message"])
3161
+
3149
3162
 
3150
3163
  class SecretsGroupTest(APIViewTestCases.APIViewTestCase):
3151
3164
  model = SecretsGroup
3152
- brief_fields = ["display", "id", "name", "url"]
3153
3165
  bulk_update_data = {}
3154
3166
 
3155
3167
  @classmethod
@@ -3200,7 +3212,6 @@ class SecretsGroupTest(APIViewTestCases.APIViewTestCase):
3200
3212
 
3201
3213
  class SecretsGroupAssociationTest(APIViewTestCases.APIViewTestCase):
3202
3214
  model = SecretsGroupAssociation
3203
- brief_fields = ["access_type", "display", "id", "secret", "secret_type", "url"]
3204
3215
  bulk_update_data = {}
3205
3216
  choices_fields = ["access_type", "secret_type"]
3206
3217
 
@@ -3267,7 +3278,6 @@ class SecretsGroupAssociationTest(APIViewTestCases.APIViewTestCase):
3267
3278
 
3268
3279
  class StatusTest(APIViewTestCases.APIViewTestCase):
3269
3280
  model = Status
3270
- brief_fields = ["display", "id", "name", "url"]
3271
3281
  bulk_update_data = {
3272
3282
  "color": "000000",
3273
3283
  }
@@ -3298,7 +3308,6 @@ class StatusTest(APIViewTestCases.APIViewTestCase):
3298
3308
 
3299
3309
  class TagTest(APIViewTestCases.APIViewTestCase):
3300
3310
  model = Tag
3301
- brief_fields = ["color", "display", "id", "name", "slug", "url"]
3302
3311
  create_data = [
3303
3312
  {"name": "Tag 4", "slug": "tag-4", "content_types": [Location._meta.label_lower]},
3304
3313
  {"name": "Tag 5", "slug": "tag-5", "content_types": [Location._meta.label_lower]},
@@ -3382,7 +3391,6 @@ class TagTest(APIViewTestCases.APIViewTestCase):
3382
3391
 
3383
3392
  class WebhookTest(APIViewTestCases.APIViewTestCase):
3384
3393
  model = Webhook
3385
- brief_fields = ["display", "id", "name", "url"]
3386
3394
  create_data = [
3387
3395
  {
3388
3396
  "content_types": ["dcim.consoleport"],
@@ -3611,7 +3619,6 @@ class WebhookTest(APIViewTestCases.APIViewTestCase):
3611
3619
 
3612
3620
  class RoleTest(APIViewTestCases.APIViewTestCase):
3613
3621
  model = Role
3614
- brief_fields = ["display", "id", "name", "url"]
3615
3622
  bulk_update_data = {
3616
3623
  "color": "000000",
3617
3624
  }