nautobot 3.0.0a2__py3-none-any.whl → 3.0.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nautobot might be problematic. Click here for more details.

Files changed (420) hide show
  1. nautobot/apps/choices.py +0 -2
  2. nautobot/apps/filters.py +7 -9
  3. nautobot/apps/models.py +2 -2
  4. nautobot/apps/ui.py +9 -1
  5. nautobot/circuits/filters.py +3 -2
  6. nautobot/circuits/navigation.py +3 -2
  7. nautobot/circuits/templates/circuits/circuit.html +1 -1
  8. nautobot/circuits/templates/circuits/circuit_create.html +3 -3
  9. nautobot/circuits/templates/circuits/circuittermination.html +1 -1
  10. nautobot/circuits/templates/circuits/circuittermination_create.html +9 -24
  11. nautobot/circuits/templates/circuits/circuittype.html +1 -1
  12. nautobot/circuits/templates/circuits/inc/circuit_termination_cable_fragment.html +6 -6
  13. nautobot/circuits/templates/circuits/inc/speed_widget.html +12 -12
  14. nautobot/circuits/templates/circuits/providernetwork.html +1 -1
  15. nautobot/circuits/tests/integration/test_circuit.py +10 -13
  16. nautobot/cloud/filters.py +1 -1
  17. nautobot/cloud/navigation.py +3 -2
  18. nautobot/core/api/schema.py +1 -1
  19. nautobot/core/api/serializers.py +6 -1
  20. nautobot/core/api/urls.py +1 -0
  21. nautobot/core/api/views.py +8 -0
  22. nautobot/core/apps/__init__.py +11 -10
  23. nautobot/core/celery/__init__.py +3 -5
  24. nautobot/core/checks.py +46 -0
  25. nautobot/core/cli/bootstrap_v3_to_v5.py +70 -1
  26. nautobot/core/cli/migrate_deprecated_templates.py +200 -0
  27. nautobot/core/constants.py +3 -0
  28. nautobot/core/context_processors.py +9 -1
  29. nautobot/core/forms/forms.py +1 -1
  30. nautobot/core/jobs/__init__.py +6 -3
  31. nautobot/core/jobs/groups.py +31 -1
  32. nautobot/core/management/commands/generate_test_data.py +28 -9
  33. nautobot/core/models/generics.py +9 -1
  34. nautobot/core/models/tree_queries.py +10 -5
  35. nautobot/core/settings.py +18 -12
  36. nautobot/core/settings.yaml +13 -7
  37. nautobot/core/signals.py +12 -1
  38. nautobot/core/tables.py +13 -6
  39. nautobot/core/templates/40x.html +1 -1
  40. nautobot/core/templates/500.html +2 -2
  41. nautobot/core/templates/admin/config/config.html +12 -12
  42. nautobot/core/templates/admin/index.html +3 -3
  43. nautobot/core/templates/buttons/export.html +1 -1
  44. nautobot/core/templates/components/button/dropdown.html +5 -3
  45. nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
  46. nautobot/core/templates/components/panel/panel.html +3 -3
  47. nautobot/core/templates/components/tab/content_wrapper.html +2 -3
  48. nautobot/core/templates/components/tab/label_wrapper_distinct_view.html +1 -1
  49. nautobot/core/templates/echarts/echarts.html +1 -1
  50. nautobot/core/templates/generic/object_bulk_add_component.html +2 -1
  51. nautobot/core/templates/generic/object_bulk_create.html +4 -3
  52. nautobot/core/templates/generic/object_bulk_destroy.html +3 -3
  53. nautobot/core/templates/generic/object_bulk_remove.html +2 -2
  54. nautobot/core/templates/generic/object_bulk_update.html +5 -4
  55. nautobot/core/templates/generic/object_create.html +5 -4
  56. nautobot/core/templates/generic/object_import.html +2 -1
  57. nautobot/core/templates/generic/object_list.html +12 -4
  58. nautobot/core/templates/generic/object_notes.html +5 -3
  59. nautobot/core/templates/generic/object_retrieve.html +2 -3
  60. nautobot/core/templates/graphene/graphiql.html +7 -7
  61. nautobot/core/templates/home.html +1 -1
  62. nautobot/core/templates/import_success.html +2 -1
  63. nautobot/core/templates/inc/computed_fields/panel_data.html +1 -1
  64. nautobot/core/templates/inc/created_updated.html +7 -3
  65. nautobot/core/templates/inc/custom_fields/panel_data.html +1 -1
  66. nautobot/core/templates/inc/form_static_field.html +6 -0
  67. nautobot/core/templates/inc/header.html +1 -1
  68. nautobot/core/templates/inc/image_attachments.html +2 -1
  69. nautobot/core/templates/inc/nav_menu.html +2 -1
  70. nautobot/core/templates/inc/search_panel.html +4 -4
  71. nautobot/core/templates/login.html +4 -2
  72. nautobot/core/templates/nautobot_config.py.j2 +6 -5
  73. nautobot/core/templates/redoc_ui.html +7 -0
  74. nautobot/core/templates/search.html +1 -1
  75. nautobot/core/templates/swagger_ui.html +17 -3
  76. nautobot/core/templates/system_jobs/import_objects.html +1 -2
  77. nautobot/core/templates/utilities/confirmation_form.html +2 -2
  78. nautobot/core/templates/utilities/obj_table.html +10 -2
  79. nautobot/core/templates/utilities/render_field.html +7 -7
  80. nautobot/core/templates/utilities/render_jinja2.html +2 -2
  81. nautobot/core/templates/utilities/templatetags/filter_form_drawer.html +4 -4
  82. nautobot/core/templates/utilities/theme_preview.html +16 -3
  83. nautobot/core/templates/widgets/selectwithdisabled_option.html +3 -1
  84. nautobot/core/templatetags/helpers.py +52 -6
  85. nautobot/core/testing/api.py +68 -9
  86. nautobot/core/testing/filters.py +0 -23
  87. nautobot/core/testing/integration.py +23 -10
  88. nautobot/core/testing/mixins.py +2 -0
  89. nautobot/core/testing/views.py +4 -0
  90. nautobot/core/tests/integration/test_app_home.py +34 -30
  91. nautobot/core/tests/integration/test_app_navbar.py +3 -0
  92. nautobot/core/tests/nautobot_config_without_example_apps.py +4 -0
  93. nautobot/core/tests/runner.py +9 -1
  94. nautobot/core/tests/test_api.py +5 -3
  95. nautobot/core/tests/test_breadcrumbs.py +6 -7
  96. nautobot/core/tests/test_checks.py +28 -0
  97. nautobot/core/tests/test_cli.py +40 -0
  98. nautobot/core/tests/test_config.py +2 -1
  99. nautobot/core/tests/test_forms.py +55 -13
  100. nautobot/core/tests/test_jobs.py +75 -1
  101. nautobot/core/tests/test_nautobot_server.py +2 -0
  102. nautobot/core/tests/test_navigations.py +76 -1
  103. nautobot/core/tests/test_patch_social_django.py +42 -0
  104. nautobot/core/tests/test_tables.py +3 -1
  105. nautobot/core/tests/test_templatetags_helpers.py +53 -13
  106. nautobot/core/tests/test_templatetags_ui_framework.py +4 -4
  107. nautobot/core/tests/test_tree_queries.py +14 -1
  108. nautobot/core/tests/test_ui.py +1 -1
  109. nautobot/core/tests/test_utils.py +31 -4
  110. nautobot/core/tests/test_views.py +159 -31
  111. nautobot/core/ui/breadcrumbs.py +2 -12
  112. nautobot/core/ui/choices.py +142 -10
  113. nautobot/core/ui/constants.py +76 -12
  114. nautobot/core/ui/object_detail.py +92 -12
  115. nautobot/core/urls.py +12 -1
  116. nautobot/core/utils/cache.py +2 -1
  117. nautobot/core/utils/filtering.py +17 -17
  118. nautobot/core/utils/lookup.py +3 -8
  119. nautobot/core/utils/module_loading.py +21 -0
  120. nautobot/core/utils/patch_social_django.py +128 -0
  121. nautobot/core/views/__init__.py +38 -1
  122. nautobot/core/views/generic.py +3 -3
  123. nautobot/core/views/mixins.py +15 -3
  124. nautobot/core/views/renderers.py +2 -0
  125. nautobot/core/views/viewsets.py +2 -1
  126. nautobot/data_validation/apps.py +1 -5
  127. nautobot/data_validation/custom_validators.py +4 -4
  128. nautobot/data_validation/filters.py +1 -1
  129. nautobot/data_validation/forms.py +40 -0
  130. nautobot/data_validation/migrations/0001_initial.py +0 -7
  131. nautobot/data_validation/migrations/0002_data_migration_from_app.py +0 -12
  132. nautobot/data_validation/models.py +16 -7
  133. nautobot/data_validation/navigation.py +8 -1
  134. nautobot/data_validation/tables.py +12 -5
  135. nautobot/data_validation/templates/data_validation/datacompliance_tab.html +1 -0
  136. nautobot/data_validation/templates/data_validation/device_constraints.html +61 -0
  137. nautobot/data_validation/tests/__init__.py +2 -2
  138. nautobot/data_validation/tests/migrations/test_migrations.py +83 -3
  139. nautobot/data_validation/tests/test_data_compliance_rules.py +12 -7
  140. nautobot/data_validation/tests/test_filters.py +8 -6
  141. nautobot/data_validation/tests/test_models.py +15 -0
  142. nautobot/data_validation/tests/test_views.py +190 -32
  143. nautobot/data_validation/urls.py +2 -5
  144. nautobot/data_validation/views.py +73 -40
  145. nautobot/dcim/api/serializers.py +0 -13
  146. nautobot/dcim/apps.py +4 -0
  147. nautobot/dcim/choices.py +16 -0
  148. nautobot/dcim/custom_validators.py +84 -0
  149. nautobot/dcim/filter_mixins.py +353 -4
  150. nautobot/dcim/{filters/__init__.py → filters.py} +2 -35
  151. nautobot/dcim/forms.py +1 -1
  152. nautobot/dcim/migrations/0078_remove_device_location_tenant_name_uniqueness.py +16 -0
  153. nautobot/dcim/migrations/0079_device_name_data_migration.py +59 -0
  154. nautobot/dcim/models/device_components.py +81 -68
  155. nautobot/dcim/models/devices.py +13 -16
  156. nautobot/dcim/navigation.py +7 -6
  157. nautobot/dcim/tables/devices.py +3 -0
  158. nautobot/dcim/tables/template_code.py +14 -14
  159. nautobot/dcim/templates/dcim/cable.html +2 -61
  160. nautobot/dcim/templates/dcim/cable_connect.html +28 -112
  161. nautobot/dcim/templates/dcim/cable_edit.html +2 -5
  162. nautobot/dcim/templates/dcim/cable_retrieve.html +61 -0
  163. nautobot/dcim/templates/dcim/cable_trace.html +1 -3
  164. nautobot/dcim/templates/dcim/cable_update.html +5 -0
  165. nautobot/dcim/templates/dcim/consoleport.html +6 -5
  166. nautobot/dcim/templates/dcim/consoleserverport.html +6 -5
  167. nautobot/dcim/templates/dcim/device/config.html +2 -2
  168. nautobot/dcim/templates/dcim/device/consoleports.html +1 -1
  169. nautobot/dcim/templates/dcim/device/consoleserverports.html +1 -1
  170. nautobot/dcim/templates/dcim/device/devicebays.html +1 -1
  171. nautobot/dcim/templates/dcim/device/frontports.html +1 -1
  172. nautobot/dcim/templates/dcim/device/interfaces.html +1 -1
  173. nautobot/dcim/templates/dcim/device/inventory.html +1 -1
  174. nautobot/dcim/templates/dcim/device/lldp_neighbors.html +1 -1
  175. nautobot/dcim/templates/dcim/device/modulebays.html +1 -1
  176. nautobot/dcim/templates/dcim/device/poweroutlets.html +1 -1
  177. nautobot/dcim/templates/dcim/device/powerports.html +1 -1
  178. nautobot/dcim/templates/dcim/device/rearports.html +1 -1
  179. nautobot/dcim/templates/dcim/device/status.html +8 -8
  180. nautobot/dcim/templates/dcim/device/wireless.html +1 -1
  181. nautobot/dcim/templates/dcim/device.html +1 -1
  182. nautobot/dcim/templates/dcim/device_component_add.html +2 -2
  183. nautobot/dcim/templates/dcim/device_create.html +5 -3
  184. nautobot/dcim/templates/dcim/device_interface_delete.html +1 -1
  185. nautobot/dcim/templates/dcim/device_list.html +73 -10
  186. nautobot/dcim/templates/dcim/devicebay_populate.html +2 -2
  187. nautobot/dcim/templates/dcim/devicetype.html +1 -1
  188. nautobot/dcim/templates/dcim/devicetype_component_add.html +2 -2
  189. nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +14 -0
  190. nautobot/dcim/templates/dcim/frontport.html +9 -8
  191. nautobot/dcim/templates/dcim/inc/edit_form_softwareversion_js.html +2 -2
  192. nautobot/dcim/templates/dcim/interface.html +26 -6
  193. nautobot/dcim/templates/dcim/interface_bulk_delete.html +1 -1
  194. nautobot/dcim/templates/dcim/inventoryitem_add.html +3 -1
  195. nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
  196. nautobot/dcim/templates/dcim/inventoryitem_edit.html +3 -1
  197. nautobot/dcim/templates/dcim/location_retrieve.html +1 -242
  198. nautobot/dcim/templates/dcim/module/base.html +49 -9
  199. nautobot/dcim/templates/dcim/module_list.html +57 -8
  200. nautobot/dcim/templates/dcim/modulefamily_retrieve.html +1 -1
  201. nautobot/dcim/templates/dcim/moduletype_retrieve.html +49 -9
  202. nautobot/dcim/templates/dcim/platform_create.html +1 -1
  203. nautobot/dcim/templates/dcim/powerfeed.html +1 -1
  204. nautobot/dcim/templates/dcim/powerpanel.html +1 -1
  205. nautobot/dcim/templates/dcim/powerport.html +5 -4
  206. nautobot/dcim/templates/dcim/rack_elevation_list.html +16 -4
  207. nautobot/dcim/templates/dcim/rack_retrieve.html +33 -15
  208. nautobot/dcim/templates/dcim/rearport.html +7 -6
  209. nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
  210. nautobot/dcim/templates/dcim/virtualchassis_add_member.html +16 -14
  211. nautobot/dcim/templates/dcim/virtualchassis_update.html +14 -6
  212. nautobot/dcim/tests/integration/test_controller.py +1 -0
  213. nautobot/dcim/tests/test_api.py +8 -0
  214. nautobot/dcim/tests/test_custom_validators.py +229 -0
  215. nautobot/dcim/tests/test_filters.py +12 -6
  216. nautobot/dcim/tests/test_models.py +63 -4
  217. nautobot/dcim/tests/test_views.py +63 -22
  218. nautobot/dcim/urls.py +64 -21
  219. nautobot/dcim/utils.py +3 -3
  220. nautobot/dcim/views.py +547 -273
  221. nautobot/extras/api/views.py +9 -1
  222. nautobot/extras/choices.py +2 -13
  223. nautobot/extras/{filters/mixins.py → filter_mixins.py} +1 -1
  224. nautobot/extras/{filters/customfields.py → filter_mixins_customfields.py} +42 -6
  225. nautobot/extras/{filters/__init__.py → filters.py} +14 -46
  226. nautobot/extras/forms/forms.py +5 -13
  227. nautobot/extras/forms/mixins.py +0 -41
  228. nautobot/extras/management/__init__.py +9 -0
  229. nautobot/extras/migrations/0127_approval_workflow_models.py +6 -6
  230. nautobot/extras/migrations/0129_jobresult_debug_log_count_jobresult_error_log_count_and_more.py +37 -0
  231. nautobot/extras/migrations/0130_jobresult_generate_log_entry_counts.py +42 -0
  232. nautobot/extras/models/__init__.py +1 -2
  233. nautobot/extras/models/approvals.py +22 -13
  234. nautobot/extras/models/contacts.py +2 -0
  235. nautobot/extras/models/groups.py +44 -5
  236. nautobot/extras/models/jobs.py +59 -1
  237. nautobot/extras/models/mixins.py +28 -0
  238. nautobot/extras/models/models.py +13 -0
  239. nautobot/extras/models/secrets.py +1 -0
  240. nautobot/extras/models/statuses.py +0 -15
  241. nautobot/extras/navigation.py +13 -9
  242. nautobot/extras/plugins/__init__.py +33 -55
  243. nautobot/extras/plugins/tables.py +3 -3
  244. nautobot/extras/plugins/urls.py +2 -21
  245. nautobot/extras/plugins/utils.py +1 -33
  246. nautobot/extras/plugins/views.py +0 -4
  247. nautobot/extras/signals.py +20 -19
  248. nautobot/extras/tables.py +52 -68
  249. nautobot/extras/templates/extras/approval_dashboard.html +7 -5
  250. nautobot/extras/templates/extras/approvalworkflowdefinition_update.html +4 -2
  251. nautobot/extras/templates/extras/approvalworkflowstage_retrieve.html +20 -12
  252. nautobot/extras/templates/extras/computedfield.html +1 -1
  253. nautobot/extras/templates/extras/configcontext.html +1 -1
  254. nautobot/extras/templates/extras/configcontextschema_validation.html +2 -2
  255. nautobot/extras/templates/extras/customfield.html +1 -1
  256. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +11 -5
  257. nautobot/extras/templates/extras/dynamicgroup_update.html +1 -1
  258. nautobot/extras/templates/extras/gitrepository_result.html +0 -2
  259. nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -96
  260. nautobot/extras/templates/extras/inc/approval_buttons_column.html +20 -6
  261. nautobot/extras/templates/extras/inc/bulk_edit_overridable_field.html +8 -7
  262. nautobot/extras/templates/extras/inc/configcontext_format.html +10 -3
  263. nautobot/extras/templates/extras/inc/graphqlquery_execute.html +71 -0
  264. nautobot/extras/templates/extras/inc/job_tiles.html +15 -3
  265. nautobot/extras/templates/extras/inc/json_format.html +10 -3
  266. nautobot/extras/templates/extras/inc/overridable_field.html +13 -12
  267. nautobot/extras/templates/extras/job.html +29 -12
  268. nautobot/extras/templates/extras/job_bulk_edit.html +18 -0
  269. nautobot/extras/templates/extras/job_edit.html +52 -46
  270. nautobot/extras/templates/extras/job_list.html +29 -25
  271. nautobot/extras/templates/extras/marketplace.html +5 -9
  272. nautobot/extras/templates/extras/object_configcontext.html +1 -1
  273. nautobot/extras/templates/extras/object_dynamicgroups.html +2 -2
  274. nautobot/extras/templates/extras/objectchange_retrieve.html +19 -37
  275. nautobot/extras/templates/extras/plugin_detail.html +26 -21
  276. nautobot/extras/templates/extras/plugins_list.html +16 -26
  277. nautobot/extras/templates/extras/role_retrieve.html +64 -0
  278. nautobot/extras/templates/extras/scheduledjob.html +4 -2
  279. nautobot/extras/templates/extras/secretsgroup.html +1 -1
  280. nautobot/extras/templates/extras/tag.html +1 -1
  281. nautobot/extras/templatetags/custom_links.py +12 -12
  282. nautobot/extras/templatetags/job_buttons.py +14 -12
  283. nautobot/extras/test_jobs/invalid_import.py +9 -0
  284. nautobot/extras/test_jobs/log_counts_by_level.py +23 -0
  285. nautobot/extras/test_jobs/missing_import.py +11 -0
  286. nautobot/extras/tests/integration/test_configcontextschema.py +27 -26
  287. nautobot/extras/tests/integration/test_customfields.py +8 -7
  288. nautobot/extras/tests/integration/test_dynamicgroups.py +5 -1
  289. nautobot/extras/tests/integration/test_plugin_banner.py +3 -0
  290. nautobot/extras/tests/integration/test_plugins.py +18 -6
  291. nautobot/extras/tests/test_api.py +27 -18
  292. nautobot/extras/tests/test_approvals.py +38 -38
  293. nautobot/extras/tests/test_changelog.py +35 -3
  294. nautobot/extras/tests/test_customfields.py +22 -13
  295. nautobot/extras/tests/test_customfields_filters.py +479 -0
  296. nautobot/extras/tests/test_dynamicgroups.py +39 -1
  297. nautobot/extras/tests/test_filters.py +21 -19
  298. nautobot/extras/tests/test_forms.py +18 -21
  299. nautobot/extras/tests/test_jobs.py +25 -4
  300. nautobot/extras/tests/test_migrations.py +1 -0
  301. nautobot/extras/tests/test_models.py +13 -31
  302. nautobot/extras/tests/test_plugins.py +36 -10
  303. nautobot/extras/tests/test_views.py +31 -30
  304. nautobot/extras/views.py +81 -19
  305. nautobot/ipam/factory.py +7 -0
  306. nautobot/ipam/filter_mixins.py +38 -0
  307. nautobot/ipam/filters.py +27 -38
  308. nautobot/ipam/formfields.py +1 -1
  309. nautobot/ipam/forms.py +6 -3
  310. nautobot/ipam/migrations/0030_ipam__namespaces.py +13 -0
  311. nautobot/ipam/migrations/0031_ipam___data_migrations.py +4 -1
  312. nautobot/ipam/migrations/0054_namespace_tenant.py +25 -0
  313. nautobot/ipam/models.py +29 -2
  314. nautobot/ipam/navigation.py +3 -2
  315. nautobot/ipam/signals.py +71 -0
  316. nautobot/ipam/tables.py +13 -6
  317. nautobot/ipam/templates/ipam/inc/toggle_available.html +10 -10
  318. nautobot/ipam/templates/ipam/inc/vlangroup_header.html +1 -0
  319. nautobot/ipam/templates/ipam/ipaddress.html +14 -0
  320. nautobot/ipam/templates/ipam/ipaddress_merge.html +3 -3
  321. nautobot/ipam/templates/ipam/ipaddresstointerface_retrieve.html +1 -0
  322. nautobot/ipam/templates/ipam/namespace_update.html +15 -0
  323. nautobot/ipam/templates/ipam/prefix_delete.html +1 -1
  324. nautobot/ipam/templates/ipam/prefix_list.html +14 -13
  325. nautobot/ipam/templates/ipam/service.html +1 -1
  326. nautobot/ipam/templates/ipam/vlan.html +1 -1
  327. nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
  328. nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
  329. nautobot/ipam/tests/migration/test_migrations.py +89 -0
  330. nautobot/ipam/tests/test_api.py +13 -6
  331. nautobot/ipam/tests/test_filters.py +10 -0
  332. nautobot/ipam/tests/test_forms.py +1 -1
  333. nautobot/ipam/tests/test_models.py +43 -1
  334. nautobot/ipam/tests/test_tables.py +1 -2
  335. nautobot/ipam/tests/test_utils.py +1 -1
  336. nautobot/ipam/tests/test_views.py +13 -14
  337. nautobot/ipam/ui.py +0 -17
  338. nautobot/ipam/utils/migrations.py +16 -2
  339. nautobot/ipam/utils/testing.py +9 -3
  340. nautobot/ipam/views.py +46 -6
  341. nautobot/project-static/dist/css/nautobot.css +1 -1
  342. nautobot/project-static/dist/css/nautobot.css.map +1 -1
  343. nautobot/project-static/dist/js/nautobot.js +1 -1
  344. nautobot/project-static/dist/js/nautobot.js.map +1 -1
  345. nautobot/project-static/js/cabletrace.js +1 -1
  346. nautobot/project-static/js/interface_filtering.js +20 -16
  347. nautobot/project-static/nautobot-icons/battery-3.svg +3 -0
  348. nautobot/project-static/nautobot-icons/cloud.svg +1 -1
  349. nautobot/project-static/nautobot-icons/control-panel.svg +1 -1
  350. nautobot/project-static/nautobot-icons/device-lifecycle.svg +1 -1
  351. nautobot/project-static/nautobot-icons/elements.svg +1 -1
  352. nautobot/project-static/nautobot-icons/extensibility.svg +3 -0
  353. nautobot/project-static/nautobot-icons/hammer.svg +1 -1
  354. nautobot/project-static/nautobot-icons/organization.svg +3 -0
  355. nautobot/project-static/nautobot-icons/secrets.svg +1 -1
  356. nautobot/project-static/nautobot-icons/security.svg +3 -0
  357. nautobot/project-static/nautobot-icons/server.svg +1 -1
  358. nautobot/project-static/nautobot-icons/star-filled.svg +1 -1
  359. nautobot/project-static/nautobot-icons/star.svg +1 -1
  360. nautobot/tenancy/api/serializers.py +1 -0
  361. nautobot/tenancy/api/views.py +2 -1
  362. nautobot/tenancy/{filters/__init__.py → filters.py} +2 -10
  363. nautobot/tenancy/navigation.py +3 -1
  364. nautobot/tenancy/tests/test_filters.py +0 -2
  365. nautobot/tenancy/views.py +2 -1
  366. nautobot/ui/src/js/collapse.js +3 -3
  367. nautobot/ui/src/js/nautobot.js +16 -0
  368. nautobot/ui/src/scss/colors.scss +1 -1
  369. nautobot/ui/src/scss/nautobot.scss +61 -28
  370. nautobot/users/templates/users/profile.html +45 -12
  371. nautobot/users/templates/users/sessionkey_delete.html +1 -1
  372. nautobot/users/tests/test_api.py +4 -0
  373. nautobot/users/views.py +4 -2
  374. nautobot/virtualization/models.py +1 -68
  375. nautobot/virtualization/navigation.py +3 -2
  376. nautobot/virtualization/templates/virtualization/virtual_machine_vminterface_delete.html +1 -1
  377. nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
  378. nautobot/virtualization/templates/virtualization/virtualmachine_list.html +2 -2
  379. nautobot/virtualization/templates/virtualization/virtualmachine_update.html +3 -1
  380. nautobot/virtualization/tests/test_api.py +3 -0
  381. nautobot/virtualization/tests/test_models.py +44 -4
  382. nautobot/vpn/__init__.py +0 -0
  383. nautobot/vpn/api/serializers.py +113 -0
  384. nautobot/vpn/api/urls.py +19 -0
  385. nautobot/vpn/api/views.py +70 -0
  386. nautobot/vpn/apps.py +8 -0
  387. nautobot/vpn/choices.py +171 -0
  388. nautobot/vpn/factory.py +209 -0
  389. nautobot/vpn/filters.py +233 -0
  390. nautobot/vpn/forms.py +486 -0
  391. nautobot/vpn/homepage.py +19 -0
  392. nautobot/vpn/migrations/0001_initial.py +541 -0
  393. nautobot/vpn/migrations/0002_populate_defaults.py +199 -0
  394. nautobot/vpn/migrations/__init__.py +0 -0
  395. nautobot/vpn/models.py +527 -0
  396. nautobot/vpn/navigation.py +98 -0
  397. nautobot/vpn/tables.py +380 -0
  398. nautobot/vpn/templates/vpn/vpnprofile.html +2 -0
  399. nautobot/vpn/templates/vpn/vpnprofile_create.html +150 -0
  400. nautobot/vpn/tests/__init__.py +0 -0
  401. nautobot/vpn/tests/test_api.py +341 -0
  402. nautobot/vpn/tests/test_filters.py +139 -0
  403. nautobot/vpn/tests/test_forms.py +294 -0
  404. nautobot/vpn/tests/test_models.py +97 -0
  405. nautobot/vpn/tests/test_views.py +281 -0
  406. nautobot/vpn/urls.py +16 -0
  407. nautobot/vpn/views.py +437 -0
  408. nautobot/wireless/navigation.py +3 -2
  409. nautobot/wireless/tests/integration/test_radio_profile.py +1 -5
  410. nautobot/wireless/tests/test_api.py +1 -1
  411. {nautobot-3.0.0a2.dist-info → nautobot-3.0.0a3.dist-info}/METADATA +14 -14
  412. {nautobot-3.0.0a2.dist-info → nautobot-3.0.0a3.dist-info}/RECORD +417 -366
  413. {nautobot-3.0.0a2.dist-info → nautobot-3.0.0a3.dist-info}/entry_points.txt +1 -0
  414. nautobot/data_validation/template_content.py +0 -42
  415. nautobot/dcim/filters/mixins.py +0 -354
  416. nautobot/ipam/templates/ipam/inc/prefix_header_extra_content_table.html +0 -4
  417. /nautobot/tenancy/{filters/mixins.py → filter_mixins.py} +0 -0
  418. {nautobot-3.0.0a2.dist-info → nautobot-3.0.0a3.dist-info}/LICENSE.txt +0 -0
  419. {nautobot-3.0.0a2.dist-info → nautobot-3.0.0a3.dist-info}/NOTICE +0 -0
  420. {nautobot-3.0.0a2.dist-info → nautobot-3.0.0a3.dist-info}/WHEEL +0 -0
@@ -2,25 +2,43 @@
2
2
  {% load helpers %}
3
3
  {% load static %}
4
4
 
5
- {% block extra_buttons %}
6
- <a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
7
- <span class="mdi mdi-chevron-left" aria-hidden="true"></span> Previous Rack
8
- </a>
9
- <a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
10
- <span class="mdi mdi-chevron-right" aria-hidden="true"></span> Next Rack
11
- </a>
12
- {% endblock extra_buttons %}
5
+ {% block extra_styles %}
6
+ <!-- Invert the rack elevation in dark mode as a quick fix until we have a proper dark mode svg generation -->
7
+ <style>
8
+ [data-theme="dark"] .rack_elevation {
9
+ filter: invert(1) hue-rotate(180deg);
10
+ }
11
+ </style>
12
+ {% endblock %}
13
13
 
14
- {% block title %}Rack {{ object }}{% endblock title %}
15
-
16
- {% block panel_buttons %}
17
- <button class="btn btn-sm btn-secondary toggle-fullname" selected="selected">
14
+ {% block extra_buttons %}
15
+ {% if prev_rack %}
16
+ <a href="{% url 'dcim:rack' pk=prev_rack.pk %}" class="btn btn-primary">
17
+ <span class="mdi mdi-chevron-left" aria-hidden="true"></span> Previous Rack
18
+ </a>
19
+ {% else %}
20
+ <a aria-disabled="true" class="btn btn-primary disabled">
21
+ <span class="mdi mdi-chevron-left" aria-hidden="true"></span> Previous Rack
22
+ </a>
23
+ {% endif %}
24
+ {% if next_rack %}
25
+ <a href="{% url 'dcim:rack' pk=next_rack.pk %}" class="btn btn-primary">
26
+ <span class="mdi mdi-chevron-right" aria-hidden="true"></span> Next Rack
27
+ </a>
28
+ {% else %}
29
+ <a aria-disabled="true" class="btn btn-primary disabled">
30
+ <span class="mdi mdi-chevron-right" aria-hidden="true"></span> Next Rack
31
+ </a>
32
+ {% endif %}
33
+ <button class="btn btn-secondary toggle-fullname" selected="selected">
18
34
  <span class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></span> Show Device Full Name
19
35
  </button>
20
- <button class="btn btn-sm btn-secondary toggle-images" selected="selected">
36
+ <button class="btn btn-secondary toggle-images" selected="selected">
21
37
  <span class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></span> Show Images
22
38
  </button>
23
- {% endblock panel_buttons %}
39
+ {% endblock extra_buttons %}
40
+
41
+ {% block title %}Rack {{ object }}{% endblock title %}
24
42
 
25
43
  {% block content_left_page %}
26
44
  <div class="card">
@@ -160,7 +178,7 @@
160
178
  <span class="badge" style="color: {{ powerfeed.status.color|fgcolor }}; background-color: #{{powerfeed.status.color}}">{{ powerfeed.get_status_display }}</span>
161
179
  </td>
162
180
  <td>
163
- <span class="badge badge-{{ powerfeed.get_type_class }}">{{ powerfeed.get_type_display }}</span>
181
+ <span class="badge bg-{{ powerfeed.get_type_class }}">{{ powerfeed.get_type_display }}</span>
164
182
  </td>
165
183
  {% with power_port=powerfeed.connected_endpoint %}
166
184
  {% if power_port %}
@@ -72,14 +72,15 @@
72
72
  Not connected
73
73
  {% if perms.dcim.add_cable %}
74
74
  <span class="dropdown float-end">
75
- <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
75
+ {# comment The "fixed" strategy allows the dropdown to break out of the containing card #}
76
+ <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" data-bs-popper-config='{"strategy": "fixed"}'>
76
77
  <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
77
78
  </button>
78
- <ul class="dropdown-menu dropdown-menu-right">
79
- <li><a href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='interface' %}?return_url={{ object.get_absolute_url }}">Interface</a></li>
80
- <li><a href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}">Front Port</a></li>
81
- <li><a href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}">Rear Port</a></li>
82
- <li><a href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='circuit-termination' %}?return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
79
+ <ul class="dropdown-menu dropdown-menu-end">
80
+ <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='interface' %}?return_url={{ object.get_absolute_url }}">Interface</a></li>
81
+ <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}">Front Port</a></li>
82
+ <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}">Rear Port</a></li>
83
+ <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='circuit-termination' %}?return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
83
84
  </ul>
84
85
  </span>
85
86
  {% endif %}
@@ -1,2 +1,2 @@
1
- {% extends 'dcim/virtualchassis_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -3,25 +3,27 @@
3
3
 
4
4
  {% block content %}
5
5
  <form action="" method="post" enctype="multipart/form-data" class="h-100 vstack">
6
- {% csrf_token %}b
7
- <div class="col-xl-8 offset-lg-2 col-lg-10 offset-md-1">
6
+ {% csrf_token %}
7
+ <div class="row justify-content-center">
8
8
  <h3 class="mb-16">{% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}</h3>
9
- {% if membership_form.non_field_errors %}
10
- <div class="card border-danger">
11
- <div class="card-header bg-danger-subtle border-danger text-body">
12
- <strong>Errors</strong>
9
+ <div class="col-xl-8 col-lg-10">
10
+ {% if membership_form.non_field_errors %}
11
+ <div class="card border-danger">
12
+ <div class="card-header bg-danger-subtle border-danger text-body">
13
+ <strong>Errors</strong>
14
+ </div>
15
+ <div class="card-body">
16
+ {{ membership_form.non_field_errors }}
17
+ </div>
13
18
  </div>
19
+ {% endif %}
20
+ <div class="card">
21
+ <div class="card-header"><strong>Add New Member</strong></div>
14
22
  <div class="card-body">
15
- {{ membership_form.non_field_errors }}
23
+ {% render_form member_select_form %}
24
+ {% render_form membership_form %}
16
25
  </div>
17
26
  </div>
18
- {% endif %}
19
- <div class="card">
20
- <div class="card-header"><strong>Add New Member</strong></div>
21
- <div class="card-body">
22
- {% render_form member_select_form %}
23
- {% render_form membership_form %}
24
- </div>
25
27
  </div>
26
28
  </div>
27
29
  <div class="nb-form-sticky-footer">
@@ -2,14 +2,15 @@
2
2
  {% load helpers %}
3
3
  {% load form_helpers %}
4
4
 
5
+ {% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}
6
+
5
7
  {% block content %}
6
8
  <form action="" method="post" enctype="multipart/form-data" class="h-100 vstack">
7
9
  {% csrf_token %}
8
10
  {{ pk_form.pk }}
9
11
  {{ formset.management_form }}
10
- <div class="row align-content-start flex-fill">
11
- <div class="col-xl-8 offset-lg-2 col-lg-10 offset-md-1">
12
- <h3 class="mb-16">{% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}</h3>
12
+ <div class="row justify-content-center flex-fill">
13
+ <div class="col-xl-8 col-lg-10">
13
14
  {% if vc_form.non_field_errors %}
14
15
  <div class="card border-danger">
15
16
  <div class="card-header bg-danger-subtle border-danger text-body">
@@ -72,9 +73,16 @@
72
73
  </td>
73
74
  <td>
74
75
  {% if virtual_chassis.present_in_database %}
75
- <a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" class="btn btn-danger btn-sm{% if virtual_chassis.master == device %} disabled{% endif %}">
76
- <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>
77
- </a>
76
+ {% if virtual_chassis.master != device %}
77
+ <a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}"
78
+ class="btn btn-danger btn-sm">
79
+ <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>
80
+ </a>
81
+ {% else %}
82
+ <a aria-disabled="true" class="btn btn-danger btn-sm disabled">
83
+ <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>
84
+ </a>
85
+ {% endif %}
78
86
  {% endif %}
79
87
  </td>
80
88
  </tr>
@@ -38,6 +38,7 @@ class ControllerTestCase(SeleniumTestCase):
38
38
 
39
39
  # Create Controller
40
40
  self.click_navbar_entry("Devices", "Controllers")
41
+
41
42
  self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:controller_list"))
42
43
  self.click_list_view_add_button()
43
44
  self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:controller_add"))
@@ -1471,6 +1471,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
1471
1471
  class DeviceTest(APIViewTestCases.APIViewTestCase):
1472
1472
  model = Device
1473
1473
  choices_fields = ["face"]
1474
+ validation_excluded_fields = [
1475
+ "software_image_files", # M2M field, excluded by default
1476
+ ]
1474
1477
 
1475
1478
  @classmethod
1476
1479
  def setUpTestData(cls):
@@ -2168,6 +2171,9 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
2168
2171
  model = Interface
2169
2172
  peer_termination_type = Interface
2170
2173
  choices_fields = ["mode", "type"]
2174
+ validation_excluded_fields = [
2175
+ "tagged_vlans", # M2M field, excluded by default
2176
+ ]
2171
2177
 
2172
2178
  @classmethod
2173
2179
  def setUpTestData(cls):
@@ -3588,6 +3594,7 @@ class DeviceTypeToSoftwareImageFileTestCase(
3588
3594
 
3589
3595
  class ControllerTestCase(APIViewTestCases.APIViewTestCase):
3590
3596
  model = Controller
3597
+ choices_fields = ("capabilities",)
3591
3598
 
3592
3599
  def get_deletable_object(self):
3593
3600
  # This method is used in `test_recreate_object_csv`,
@@ -3647,6 +3654,7 @@ class ControllerTestCase(APIViewTestCases.APIViewTestCase):
3647
3654
 
3648
3655
  class ControllerManagedDeviceGroupTestCase(APIViewTestCases.APIViewTestCase):
3649
3656
  model = ControllerManagedDeviceGroup
3657
+ choices_fields = ("capabilities",)
3650
3658
 
3651
3659
  def get_deletable_object(self):
3652
3660
  # This method is used in `test_recreate_object_csv`,
@@ -0,0 +1,229 @@
1
+ from django.contrib.contenttypes.models import ContentType
2
+ from django.core.exceptions import ValidationError
3
+ from django.test import override_settings, TestCase
4
+
5
+ from nautobot.core.testing.mixins import NautobotTestCaseMixin
6
+ from nautobot.data_validation.models import RequiredValidationRule
7
+ from nautobot.dcim.choices import DeviceUniquenessChoices
8
+ from nautobot.dcim.models import Device, DeviceType, Location
9
+ from nautobot.extras.models import Role, Status
10
+ from nautobot.tenancy.models import Tenant
11
+
12
+
13
+ class DeviceUniquenessValidatorTest(NautobotTestCaseMixin, TestCase):
14
+ """Tests for the DeviceUniquenessValidator custom validator."""
15
+
16
+ def setUp(self):
17
+ super().setUp()
18
+ self.device_status = Status.objects.get_for_model(Device).first()
19
+ self.device_type = DeviceType.objects.first()
20
+ self.device_role = Role.objects.get_for_model(Device).first()
21
+ self.location = Location.objects.first()
22
+ self.tenant = Tenant.objects.create(name="Tenant")
23
+ self.device_name = "Device"
24
+ self.device = Device.objects.create(
25
+ name=self.device_name,
26
+ device_type=self.device_type,
27
+ role=self.device_role,
28
+ location=self.location,
29
+ status=self.device_status,
30
+ tenant=self.tenant,
31
+ )
32
+
33
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.LOCATION_TENANT_NAME)
34
+ def test_duplicate_same_location_tenant_name_fails(self):
35
+ """Same name, tenant, and location should raise ValidationError."""
36
+ dup_device = Device(
37
+ name=self.device_name,
38
+ device_type=self.device_type,
39
+ role=self.device_role,
40
+ location=self.location,
41
+ status=self.device_status,
42
+ tenant=self.tenant,
43
+ )
44
+ with self.assertRaises(ValidationError) as contextmanager:
45
+ dup_device.full_clean()
46
+ self.assertIn(
47
+ f"A device named '{self.device_name}' already exists in this location: {self.location} and tenant: {self.tenant}. ",
48
+ str(contextmanager.exception),
49
+ )
50
+
51
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.LOCATION_TENANT_NAME)
52
+ def test_different_tenant_allows_duplicate_name(self):
53
+ """Same name and location, different tenant should be allowed."""
54
+ tenant = Tenant.objects.create(name="Tenant2")
55
+ non_dup_device = Device(
56
+ name=self.device_name,
57
+ device_type=self.device_type,
58
+ role=self.device_role,
59
+ location=self.location,
60
+ status=self.device_status,
61
+ tenant=tenant,
62
+ )
63
+ non_dup_device.full_clean() # should not raise
64
+
65
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.LOCATION_TENANT_NAME)
66
+ def test_different_location_allows_duplicate_name(self):
67
+ """Same name and tenant, different location should be allowed."""
68
+ location = Location.objects.last()
69
+ non_dup_device = Device(
70
+ name=self.device_name,
71
+ device_type=self.device_type,
72
+ role=self.device_role,
73
+ location=location,
74
+ status=self.device_status,
75
+ tenant=self.tenant,
76
+ )
77
+ non_dup_device.full_clean() # should not raise
78
+
79
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.LOCATION_TENANT_NAME)
80
+ def test_duplicate_name_with_null_tenant_fails(self):
81
+ """Duplicate name with tenant=None should raise ValidationError."""
82
+ Device.objects.create(
83
+ name="Device-2",
84
+ location=self.location,
85
+ tenant=None,
86
+ device_type=self.device_type,
87
+ role=self.device_role,
88
+ status=self.device_status,
89
+ )
90
+ dup = Device(
91
+ name="Device-2",
92
+ location=self.location,
93
+ tenant=None,
94
+ device_type=self.device_type,
95
+ role=self.device_role,
96
+ status=self.device_status,
97
+ )
98
+ with self.assertRaises(ValidationError) as contextmanager:
99
+ dup.full_clean()
100
+ self.assertIn(
101
+ f"A device named '{dup.name}' with no tenant already exists in this location: {self.location}. ",
102
+ str(contextmanager.exception),
103
+ )
104
+
105
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.NAME)
106
+ def test_duplicate_name_globally_fails(self):
107
+ """Duplicate name should raise ValidationError."""
108
+ tenant = Tenant.objects.create(name="Tenant2")
109
+ location = Location.objects.last()
110
+ dup_device = Device(
111
+ name=self.device_name,
112
+ device_type=self.device_type,
113
+ role=self.device_role,
114
+ location=location,
115
+ status=self.device_status,
116
+ tenant=tenant,
117
+ )
118
+ with self.assertRaises(ValidationError) as contextmanager:
119
+ dup_device.full_clean()
120
+ self.assertIn(
121
+ f"At least one other device named '{dup_device.name}' already exists. ", str(contextmanager.exception)
122
+ )
123
+
124
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.NAME)
125
+ def test_different_name_succeeds(self):
126
+ """Different name should be allowed globally."""
127
+ non_dup_device = Device(
128
+ name="Device-2",
129
+ device_type=self.device_type,
130
+ role=self.device_role,
131
+ location=self.location,
132
+ status=self.device_status,
133
+ tenant=self.tenant,
134
+ )
135
+ non_dup_device.full_clean() # should not raise
136
+
137
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.NAME)
138
+ def test_unnamed_device_allowed_if_name_not_required(self):
139
+ """Unnamed device allowed if DEVICE_NAME_REQUIRED is False."""
140
+ Device.objects.create(
141
+ name=None,
142
+ location=self.location,
143
+ tenant=self.tenant,
144
+ device_type=self.device_type,
145
+ role=self.device_role,
146
+ status=self.device_status,
147
+ )
148
+ unnamed2 = Device(
149
+ name=None,
150
+ location=self.location,
151
+ tenant=self.tenant,
152
+ device_type=self.device_type,
153
+ role=self.device_role,
154
+ status=self.device_status,
155
+ )
156
+ self.assertFalse(
157
+ RequiredValidationRule.objects.filter(
158
+ content_type=ContentType.objects.get_for_model(Device), field="name"
159
+ ).exists()
160
+ )
161
+ unnamed2.full_clean() # should not raise
162
+
163
+ def test_unnamed_device_fails_if_name_is_required(self):
164
+ """Unnamed device should raise a ValidationError if DEVICE_NAME_REQUIRED is True."""
165
+ unnamed = Device(
166
+ name=None,
167
+ location=self.location,
168
+ tenant=self.tenant,
169
+ device_type=self.device_type,
170
+ role=self.device_role,
171
+ status=self.device_status,
172
+ )
173
+ RequiredValidationRule.objects.create(content_type=ContentType.objects.get_for_model(Device), field="name")
174
+ with self.assertRaises(ValidationError) as contextmanager:
175
+ unnamed.full_clean()
176
+ # This error is from RequiredValidationRule
177
+ self.assertIn("{'name': ['This field cannot be blank.']}", str(contextmanager.exception))
178
+
179
+ def test_empty_device_fails_if_name_is_required(self):
180
+ """Empty name device should raise a ValidationError if DEVICE_NAME_REQUIRED is True."""
181
+ unnamed = Device(
182
+ name="",
183
+ location=self.location,
184
+ tenant=self.tenant,
185
+ device_type=self.device_type,
186
+ role=self.device_role,
187
+ status=self.device_status,
188
+ )
189
+ RequiredValidationRule.objects.create(content_type=ContentType.objects.get_for_model(Device), field="name")
190
+ with self.assertRaises(ValidationError) as contextmanager:
191
+ unnamed.full_clean()
192
+ # This error is from RequiredValidationRule
193
+ self.assertIn("{'name': ['This field cannot be blank.']}", str(contextmanager.exception))
194
+
195
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.NONE)
196
+ def test_no_uniqueness_enforced(self):
197
+ """Devices should not trigger validation errors when uniqueness is disabled."""
198
+ dup_device = Device(
199
+ name=self.device.name,
200
+ location=self.location,
201
+ tenant=self.tenant,
202
+ role=self.device_role,
203
+ device_type=self.device_type,
204
+ status=self.device_status,
205
+ )
206
+
207
+ # Should NOT raise any error since uniqueness enforcement is off
208
+ dup_device.full_clean()
209
+
210
+ @override_settings(DEVICE_UNIQUENESS=DeviceUniquenessChoices.NONE)
211
+ def test_allow_duplicate_devices_with_empty_name_when_uniqueness_is_none(self):
212
+ """Allow duplicate devices with empty name when DEVICE_UNIQUENESS="none"."""
213
+ Device.objects.create(
214
+ name="",
215
+ location=self.location,
216
+ tenant=self.tenant,
217
+ device_type=self.device_type,
218
+ role=self.device_role,
219
+ status=self.device_status,
220
+ )
221
+ empty_name = Device(
222
+ name="",
223
+ location=self.location,
224
+ tenant=self.tenant,
225
+ device_type=self.device_type,
226
+ role=self.device_role,
227
+ status=self.device_status,
228
+ )
229
+ empty_name.full_clean()
@@ -124,8 +124,9 @@ from nautobot.dcim.models import (
124
124
  VirtualChassis,
125
125
  VirtualDeviceContext,
126
126
  )
127
- from nautobot.extras.filters.mixins import RoleFilter, StatusFilter
127
+ from nautobot.extras.filter_mixins import RoleFilter, StatusFilter
128
128
  from nautobot.extras.models import ExternalIntegration, Role, SecretsGroup, Status, Tag
129
+ from nautobot.extras.tests.test_customfields_filters import CustomFieldsFilters
129
130
  from nautobot.ipam.models import IPAddress, Namespace, Prefix, Service, VLAN, VLANGroup
130
131
  from nautobot.tenancy.models import Tenant
131
132
  from nautobot.virtualization.models import Cluster, ClusterType, VirtualMachine
@@ -1039,7 +1040,9 @@ class PathEndpointModelTestMixin:
1039
1040
  )
1040
1041
 
1041
1042
 
1042
- class LocationTypeFilterSetTestCase(FilterTestCases.FilterTestCase):
1043
+ class LocationTypeFilterSetTestCase(
1044
+ FilterTestCases.FilterTestCase, CustomFieldsFilters.CustomFieldsFilterSetTestCaseMixin
1045
+ ):
1043
1046
  queryset = LocationType.objects.all()
1044
1047
  filterset = LocationTypeFilterSet
1045
1048
  generic_filter_tests = [
@@ -1076,7 +1079,10 @@ class LocationTypeFilterSetTestCase(FilterTestCases.FilterTestCase):
1076
1079
  )
1077
1080
 
1078
1081
 
1079
- class LocationFilterSetTestCase(FilterTestCases.FilterTestCase, FilterTestCases.TenancyFilterTestCaseMixin):
1082
+ class LocationFilterSetTestCase(
1083
+ FilterTestCases.FilterTestCase,
1084
+ FilterTestCases.TenancyFilterTestCaseMixin,
1085
+ ):
1080
1086
  queryset = Location.objects.all()
1081
1087
  filterset = LocationFilterSet
1082
1088
  tenancy_related_name = "locations"
@@ -1149,7 +1155,7 @@ class LocationFilterSetTestCase(FilterTestCases.FilterTestCase, FilterTestCases.
1149
1155
  )
1150
1156
 
1151
1157
 
1152
- class RackGroupTestCase(FilterTestCases.FilterTestCase):
1158
+ class RackGroupTestCase(FilterTestCases.FilterTestCase, CustomFieldsFilters.CustomFieldsFilterSetTestCaseMixin):
1153
1159
  queryset = RackGroup.objects.all()
1154
1160
  filterset = RackGroupFilterSet
1155
1161
  generic_filter_tests = [
@@ -1357,7 +1363,7 @@ class RackReservationTestCase(FilterTestCases.FilterTestCase, FilterTestCases.Te
1357
1363
  common_test_data(cls)
1358
1364
 
1359
1365
 
1360
- class ManufacturerTestCase(FilterTestCases.FilterTestCase):
1366
+ class ManufacturerTestCase(FilterTestCases.FilterTestCase, CustomFieldsFilters.CustomFieldsFilterSetTestCaseMixin):
1361
1367
  queryset = Manufacturer.objects.all()
1362
1368
  filterset = ManufacturerFilterSet
1363
1369
  generic_filter_tests = [
@@ -1393,7 +1399,7 @@ class DeviceFamilyTestCase(FilterTestCases.FilterTestCase):
1393
1399
  ]
1394
1400
 
1395
1401
 
1396
- class DeviceTypeTestCase(FilterTestCases.FilterTestCase):
1402
+ class DeviceTypeTestCase(FilterTestCases.FilterTestCase, CustomFieldsFilters.CustomFieldsFilterSetTestCaseMixin):
1397
1403
  queryset = DeviceType.objects.all()
1398
1404
  filterset = DeviceTypeFilterSet
1399
1405
  generic_filter_tests = [
@@ -2,6 +2,7 @@ from decimal import Decimal
2
2
 
3
3
  from constance.test import override_config
4
4
  from django.contrib.contenttypes.models import ContentType
5
+ from django.core.cache import caches
5
6
  from django.core.exceptions import ValidationError
6
7
  from django.db import IntegrityError
7
8
  from django.db.models import Model
@@ -16,6 +17,7 @@ from nautobot.dcim.choices import (
16
17
  CableTypeChoices,
17
18
  ConsolePortTypeChoices,
18
19
  DeviceFaceChoices,
20
+ DeviceUniquenessChoices,
19
21
  InterfaceModeChoices,
20
22
  InterfaceTypeChoices,
21
23
  PortTypeChoices,
@@ -1423,6 +1425,10 @@ class DeviceTestCase(ModelTestCases.BaseModelTestCase):
1423
1425
  model = Device
1424
1426
 
1425
1427
  def setUp(self):
1428
+ # clear Constance cache
1429
+ cache = caches[settings.CONSTANCE_DATABASE_CACHE_BACKEND]
1430
+ cache.clear()
1431
+
1426
1432
  manufacturer = Manufacturer.objects.first()
1427
1433
  self.device_type = DeviceType.objects.create(
1428
1434
  manufacturer=manufacturer,
@@ -1532,12 +1538,25 @@ class DeviceTestCase(ModelTestCases.BaseModelTestCase):
1532
1538
 
1533
1539
  def test_natural_key_overrides(self):
1534
1540
  """Ensure that the natural-key for Device is affected by settings/Constance."""
1535
- with override_config(DEVICE_NAME_AS_NATURAL_KEY=True):
1541
+ with override_config(DEVICE_UNIQUENESS=DeviceUniquenessChoices.NAME):
1536
1542
  self.assertEqual([self.device.name], self.device.natural_key())
1537
1543
  # self.assertEqual(construct_composite_key([self.device.name]), self.device.composite_key) # TODO: Revist this if we reintroduce composite keys
1538
1544
  self.assertEqual(self.device, Device.objects.get_by_natural_key([self.device.name]))
1539
1545
  # self.assertEqual(self.device, Device.objects.get(composite_key=self.device.composite_key)) # TODO: Revist this if we reintroduce composite keys
1540
1546
 
1547
+ with override_config(DEVICE_UNIQUENESS=DeviceUniquenessChoices.LOCATION_TENANT_NAME):
1548
+ self.assertEqual(
1549
+ [self.device.name, self.device.tenant, self.device.location.name], self.device.natural_key()
1550
+ )
1551
+ self.assertEqual(
1552
+ self.device,
1553
+ Device.objects.get_by_natural_key([self.device.name, self.device.tenant, self.device.location]),
1554
+ )
1555
+
1556
+ with override_config(DEVICE_UNIQUENESS=DeviceUniquenessChoices.NONE):
1557
+ self.assertEqual([str(self.device.pk)], self.device.natural_key())
1558
+ self.assertEqual(self.device, Device.objects.get_by_natural_key([self.device.pk]))
1559
+
1541
1560
  with override_config(LOCATION_NAME_AS_NATURAL_KEY=True):
1542
1561
  self.assertEqual([self.device.name, None, self.device.location.name], self.device.natural_key())
1543
1562
  # self.assertEqual(
@@ -2899,6 +2918,11 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2899
2918
  self.assertEqual(count, 1)
2900
2919
  self.assertEqual(IPAddressToInterface.objects.filter(ip_address=ips[-1], interface=interface).count(), 1)
2901
2920
 
2921
+ # add a single instance which is already there
2922
+ count = interface.add_ip_addresses(ips[-1])
2923
+ self.assertEqual(count, 0)
2924
+ self.assertEqual(IPAddressToInterface.objects.filter(ip_address=ips[-1], interface=interface).count(), 1)
2925
+
2902
2926
  # add multiple instances
2903
2927
  count = interface.add_ip_addresses(ips[:5])
2904
2928
  self.assertEqual(count, 5)
@@ -2906,6 +2930,20 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2906
2930
  for ip in ips[:5]:
2907
2931
  self.assertEqual(IPAddressToInterface.objects.filter(ip_address=ip, interface=interface).count(), 1)
2908
2932
 
2933
+ # add multiple instances all of which are already there
2934
+ count = interface.add_ip_addresses(ips[:5])
2935
+ self.assertEqual(count, 0)
2936
+ self.assertEqual(IPAddressToInterface.objects.filter(interface=interface).count(), 6)
2937
+ for ip in ips[:5]:
2938
+ self.assertEqual(IPAddressToInterface.objects.filter(ip_address=ip, interface=interface).count(), 1)
2939
+
2940
+ # add multiple IPs some of which are there
2941
+ count = interface.add_ip_addresses(ips[3:7])
2942
+ self.assertEqual(count, 2)
2943
+ self.assertEqual(IPAddressToInterface.objects.filter(interface=interface).count(), 8)
2944
+ for ip in ips[3:7]:
2945
+ self.assertEqual(IPAddressToInterface.objects.filter(ip_address=ip, interface=interface).count(), 1)
2946
+
2909
2947
  def test_remove_ip_addresses(self):
2910
2948
  """Test the `remove_ip_addresses` helper method on `Interface`"""
2911
2949
  interface = Interface.objects.create(
@@ -2928,13 +2966,28 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2928
2966
  self.assertEqual(count, 1)
2929
2967
  self.assertEqual(IPAddressToInterface.objects.filter(interface=interface).count(), 9)
2930
2968
 
2969
+ # remove a single instance which has already been removed
2970
+ count = interface.remove_ip_addresses(ips[-1])
2971
+ self.assertEqual(count, 0)
2972
+ self.assertEqual(IPAddressToInterface.objects.filter(interface=interface).count(), 9)
2973
+
2931
2974
  # remove multiple instances
2932
2975
  count = interface.remove_ip_addresses(ips[:5])
2933
2976
  self.assertEqual(count, 5)
2934
2977
  self.assertEqual(IPAddressToInterface.objects.filter(interface=interface).count(), 4)
2935
2978
 
2979
+ # remove multiple instances all which have already been removed
2980
+ count = interface.remove_ip_addresses(ips[:5])
2981
+ self.assertEqual(count, 0)
2982
+ self.assertEqual(IPAddressToInterface.objects.filter(interface=interface).count(), 4)
2983
+
2984
+ # remove multiple instances some of which have already been removed
2985
+ count = interface.remove_ip_addresses(ips[3:7])
2986
+ self.assertEqual(count, 2)
2987
+ self.assertEqual(IPAddressToInterface.objects.filter(interface=interface).count(), 2)
2988
+
2936
2989
  count = interface.remove_ip_addresses(ips)
2937
- self.assertEqual(count, 4)
2990
+ self.assertEqual(count, 2)
2938
2991
  self.assertEqual(IPAddressToInterface.objects.filter(interface=interface).count(), 0)
2939
2992
 
2940
2993
  # Test the pre_delete signal for IPAddressToInterface instances
@@ -2942,10 +2995,16 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2942
2995
  self.device.primary_ip4 = interface.ip_addresses.all().filter(ip_version=4).first()
2943
2996
  self.device.primary_ip6 = interface.ip_addresses.all().filter(ip_version=6).first()
2944
2997
  self.device.save()
2945
- interface.remove_ip_addresses(self.device.primary_ip4)
2998
+
2999
+ count = interface.remove_ip_addresses(self.device.primary_ip4)
3000
+ self.assertEqual(count, 1)
2946
3001
  self.device.refresh_from_db()
2947
3002
  self.assertEqual(self.device.primary_ip4, None)
2948
- interface.remove_ip_addresses(self.device.primary_ip6)
3003
+ # NOTE: This effectively tests what happens when you pass remove_ip_addresses None; it
3004
+ # NOTE: does not remove a v6 address, because there are no v6 IPs created in this test
3005
+ # NOTE: class.
3006
+ count = interface.remove_ip_addresses(self.device.primary_ip6)
3007
+ self.assertEqual(count, 0)
2949
3008
  self.device.refresh_from_db()
2950
3009
  self.assertEqual(self.device.primary_ip6, None)
2951
3010