nautobot 2.3.6__py3-none-any.whl → 2.3.8__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 (336) hide show
  1. nautobot/__init__.py +4 -2
  2. nautobot/circuits/tests/test_views.py +4 -5
  3. nautobot/core/api/views.py +15 -3
  4. nautobot/core/templates/inc/javascript.html +2 -0
  5. nautobot/core/templates/inc/nav_menu.html +0 -251
  6. nautobot/core/templates/inc/paginator.html +3 -0
  7. nautobot/core/templates/utilities/obj_table.html +1 -1
  8. nautobot/core/testing/mixins.py +59 -2
  9. nautobot/core/testing/views.py +45 -61
  10. nautobot/core/tests/runner.py +6 -3
  11. nautobot/core/tests/test_paginator.py +4 -3
  12. nautobot/core/tests/test_views.py +39 -56
  13. nautobot/core/views/__init__.py +27 -11
  14. nautobot/dcim/api/serializers.py +10 -5
  15. nautobot/dcim/forms.py +11 -7
  16. nautobot/dcim/models/device_components.py +7 -4
  17. nautobot/dcim/tests/test_api.py +28 -0
  18. nautobot/dcim/tests/test_forms.py +17 -1
  19. nautobot/dcim/tests/test_models.py +42 -4
  20. nautobot/dcim/tests/test_views.py +26 -67
  21. nautobot/dcim/utils.py +9 -6
  22. nautobot/extras/datasources/git.py +6 -1
  23. nautobot/extras/migrations/0112_dynamic_group_group_type_data_migration.py +3 -0
  24. nautobot/extras/migrations/0116_fix_dynamic_group_group_type_data_migration.py +16 -0
  25. nautobot/extras/plugins/views.py +18 -3
  26. nautobot/extras/tests/test_customfields.py +9 -16
  27. nautobot/extras/tests/test_dynamicgroups.py +116 -0
  28. nautobot/extras/tests/test_plugins.py +4 -6
  29. nautobot/extras/tests/test_utils.py +5 -0
  30. nautobot/extras/tests/test_views.py +61 -159
  31. nautobot/extras/utils.py +50 -11
  32. nautobot/ipam/filters.py +2 -2
  33. nautobot/ipam/models.py +29 -2
  34. nautobot/ipam/templates/ipam/ipaddress.html +2 -2
  35. nautobot/ipam/templates/ipam/ipaddress_interfaces.html +3 -0
  36. nautobot/ipam/templates/ipam/ipaddress_vm_interfaces.html +3 -0
  37. nautobot/ipam/templates/ipam/prefix.html +3 -3
  38. nautobot/ipam/templates/ipam/routetarget.html +2 -2
  39. nautobot/ipam/templates/ipam/vlan.html +3 -0
  40. nautobot/ipam/templates/ipam/vrf.html +7 -4
  41. nautobot/ipam/tests/test_api.py +18 -12
  42. nautobot/ipam/tests/test_models.py +68 -12
  43. nautobot/ipam/tests/test_views.py +6 -15
  44. nautobot/ipam/views.py +43 -0
  45. nautobot/project-static/docs/404.html +3 -3
  46. nautobot/project-static/docs/apps/index.html +3 -3
  47. nautobot/project-static/docs/apps/nautobot-apps.html +3 -3
  48. nautobot/project-static/docs/assets/javascripts/bundle.525ec568.min.js +16 -0
  49. nautobot/project-static/docs/assets/javascripts/{bundle.56dfad97.min.js.map → bundle.525ec568.min.js.map} +4 -4
  50. nautobot/project-static/docs/assets/stylesheets/main.8c3ca2c6.min.css +1 -0
  51. nautobot/project-static/docs/assets/stylesheets/main.8c3ca2c6.min.css.map +1 -0
  52. nautobot/project-static/docs/code-reference/nautobot/apps/__init__.html +3 -3
  53. nautobot/project-static/docs/code-reference/nautobot/apps/admin.html +3 -3
  54. nautobot/project-static/docs/code-reference/nautobot/apps/api.html +3 -3
  55. nautobot/project-static/docs/code-reference/nautobot/apps/change_logging.html +3 -3
  56. nautobot/project-static/docs/code-reference/nautobot/apps/choices.html +3 -3
  57. nautobot/project-static/docs/code-reference/nautobot/apps/config.html +3 -3
  58. nautobot/project-static/docs/code-reference/nautobot/apps/constants.html +3 -3
  59. nautobot/project-static/docs/code-reference/nautobot/apps/datasources.html +3 -3
  60. nautobot/project-static/docs/code-reference/nautobot/apps/exceptions.html +3 -3
  61. nautobot/project-static/docs/code-reference/nautobot/apps/factory.html +3 -3
  62. nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +3 -3
  63. nautobot/project-static/docs/code-reference/nautobot/apps/forms.html +3 -3
  64. nautobot/project-static/docs/code-reference/nautobot/apps/graphql.html +3 -3
  65. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +3 -3
  66. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +3 -3
  67. nautobot/project-static/docs/code-reference/nautobot/apps/querysets.html +3 -3
  68. nautobot/project-static/docs/code-reference/nautobot/apps/secrets.html +3 -3
  69. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +3 -3
  70. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +124 -3
  71. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +3 -3
  72. nautobot/project-static/docs/code-reference/nautobot/apps/urls.html +3 -3
  73. nautobot/project-static/docs/code-reference/nautobot/apps/utils.html +3 -3
  74. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +3 -3
  75. nautobot/project-static/docs/development/apps/api/configuration-view.html +3 -3
  76. nautobot/project-static/docs/development/apps/api/database-backend-config.html +3 -3
  77. nautobot/project-static/docs/development/apps/api/models/django-admin.html +3 -3
  78. nautobot/project-static/docs/development/apps/api/models/global-search.html +3 -3
  79. nautobot/project-static/docs/development/apps/api/models/graphql.html +3 -3
  80. nautobot/project-static/docs/development/apps/api/models/index.html +3 -3
  81. nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +3 -3
  82. nautobot/project-static/docs/development/apps/api/platform-features/custom-validators.html +3 -3
  83. nautobot/project-static/docs/development/apps/api/platform-features/filter-extensions.html +3 -3
  84. nautobot/project-static/docs/development/apps/api/platform-features/git-repository-content.html +3 -3
  85. nautobot/project-static/docs/development/apps/api/platform-features/index.html +3 -3
  86. nautobot/project-static/docs/development/apps/api/platform-features/jinja2-filters.html +3 -3
  87. nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +3 -3
  88. nautobot/project-static/docs/development/apps/api/platform-features/populating-extensibility-features.html +3 -3
  89. nautobot/project-static/docs/development/apps/api/platform-features/secrets-providers.html +3 -3
  90. nautobot/project-static/docs/development/apps/api/platform-features/uniquely-identify-objects.html +3 -3
  91. nautobot/project-static/docs/development/apps/api/prometheus.html +3 -3
  92. nautobot/project-static/docs/development/apps/api/setup.html +3 -3
  93. nautobot/project-static/docs/development/apps/api/testing.html +3 -3
  94. nautobot/project-static/docs/development/apps/api/ui-extensions/banners.html +3 -3
  95. nautobot/project-static/docs/development/apps/api/ui-extensions/home-page.html +3 -3
  96. nautobot/project-static/docs/development/apps/api/ui-extensions/index.html +3 -3
  97. nautobot/project-static/docs/development/apps/api/ui-extensions/navigation.html +3 -3
  98. nautobot/project-static/docs/development/apps/api/ui-extensions/object-views.html +3 -3
  99. nautobot/project-static/docs/development/apps/api/views/base-template.html +3 -3
  100. nautobot/project-static/docs/development/apps/api/views/core-view-overrides.html +3 -3
  101. nautobot/project-static/docs/development/apps/api/views/django-generic-views.html +3 -3
  102. nautobot/project-static/docs/development/apps/api/views/help-documentation.html +3 -3
  103. nautobot/project-static/docs/development/apps/api/views/index.html +3 -3
  104. nautobot/project-static/docs/development/apps/api/views/nautobot-generic-views.html +3 -3
  105. nautobot/project-static/docs/development/apps/api/views/nautobotuiviewset.html +3 -3
  106. nautobot/project-static/docs/development/apps/api/views/nautobotuiviewsetrouter.html +3 -3
  107. nautobot/project-static/docs/development/apps/api/views/notes.html +3 -3
  108. nautobot/project-static/docs/development/apps/api/views/rest-api.html +3 -3
  109. nautobot/project-static/docs/development/apps/api/views/urls.html +3 -3
  110. nautobot/project-static/docs/development/apps/index.html +3 -3
  111. nautobot/project-static/docs/development/apps/migration/code-updates.html +3 -3
  112. nautobot/project-static/docs/development/apps/migration/dependency-updates.html +3 -3
  113. nautobot/project-static/docs/development/apps/migration/from-v1.html +3 -3
  114. nautobot/project-static/docs/development/apps/migration/model-updates/dcim.html +3 -3
  115. nautobot/project-static/docs/development/apps/migration/model-updates/extras.html +3 -3
  116. nautobot/project-static/docs/development/apps/migration/model-updates/global.html +3 -3
  117. nautobot/project-static/docs/development/apps/migration/model-updates/ipam.html +3 -3
  118. nautobot/project-static/docs/development/apps/porting-from-netbox.html +3 -3
  119. nautobot/project-static/docs/development/core/application-registry.html +3 -3
  120. nautobot/project-static/docs/development/core/best-practices.html +3 -3
  121. nautobot/project-static/docs/development/core/bootstrap-ui.html +3 -3
  122. nautobot/project-static/docs/development/core/caching.html +3 -3
  123. nautobot/project-static/docs/development/core/controllers.html +3 -3
  124. nautobot/project-static/docs/development/core/docker-compose-advanced-use-cases.html +3 -3
  125. nautobot/project-static/docs/development/core/generic-views.html +3 -3
  126. nautobot/project-static/docs/development/core/getting-started.html +3 -3
  127. nautobot/project-static/docs/development/core/homepage.html +3 -3
  128. nautobot/project-static/docs/development/core/index.html +3 -3
  129. nautobot/project-static/docs/development/core/model-checklist.html +3 -3
  130. nautobot/project-static/docs/development/core/model-features.html +3 -3
  131. nautobot/project-static/docs/development/core/natural-keys.html +3 -3
  132. nautobot/project-static/docs/development/core/navigation-menu.html +3 -3
  133. nautobot/project-static/docs/development/core/release-checklist.html +3 -3
  134. nautobot/project-static/docs/development/core/role-internals.html +3 -3
  135. nautobot/project-static/docs/development/core/settings.html +3 -3
  136. nautobot/project-static/docs/development/core/style-guide.html +3 -3
  137. nautobot/project-static/docs/development/core/templates.html +3 -3
  138. nautobot/project-static/docs/development/core/testing.html +3 -3
  139. nautobot/project-static/docs/development/core/user-preferences.html +3 -3
  140. nautobot/project-static/docs/development/index.html +3 -3
  141. nautobot/project-static/docs/development/jobs/index.html +3 -3
  142. nautobot/project-static/docs/development/jobs/migration/from-v1.html +3 -3
  143. nautobot/project-static/docs/index.html +3 -3
  144. nautobot/project-static/docs/objects.inv +0 -0
  145. nautobot/project-static/docs/overview/application_stack.html +3 -3
  146. nautobot/project-static/docs/overview/design_philosophy.html +3 -3
  147. nautobot/project-static/docs/release-notes/index.html +3 -3
  148. nautobot/project-static/docs/release-notes/version-1.0.html +3 -3
  149. nautobot/project-static/docs/release-notes/version-1.1.html +3 -3
  150. nautobot/project-static/docs/release-notes/version-1.2.html +3 -3
  151. nautobot/project-static/docs/release-notes/version-1.3.html +3 -3
  152. nautobot/project-static/docs/release-notes/version-1.4.html +3 -3
  153. nautobot/project-static/docs/release-notes/version-1.5.html +3 -3
  154. nautobot/project-static/docs/release-notes/version-1.6.html +3 -3
  155. nautobot/project-static/docs/release-notes/version-2.0.html +3 -3
  156. nautobot/project-static/docs/release-notes/version-2.1.html +3 -3
  157. nautobot/project-static/docs/release-notes/version-2.2.html +3 -3
  158. nautobot/project-static/docs/release-notes/version-2.3.html +304 -96
  159. nautobot/project-static/docs/requirements.txt +1 -1
  160. nautobot/project-static/docs/search/search_index.json +1 -1
  161. nautobot/project-static/docs/sitemap.xml +269 -269
  162. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  163. nautobot/project-static/docs/user-guide/administration/configuration/authentication/ldap.html +3 -3
  164. nautobot/project-static/docs/user-guide/administration/configuration/authentication/remote.html +3 -3
  165. nautobot/project-static/docs/user-guide/administration/configuration/authentication/sso.html +3 -3
  166. nautobot/project-static/docs/user-guide/administration/configuration/index.html +3 -3
  167. nautobot/project-static/docs/user-guide/administration/configuration/redis.html +3 -3
  168. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +3 -3
  169. nautobot/project-static/docs/user-guide/administration/configuration/time-zones.html +3 -3
  170. nautobot/project-static/docs/user-guide/administration/guides/celery-queues.html +3 -3
  171. nautobot/project-static/docs/user-guide/administration/guides/docker.html +3 -3
  172. nautobot/project-static/docs/user-guide/administration/guides/health-checks.html +3 -3
  173. nautobot/project-static/docs/user-guide/administration/guides/permissions.html +3 -3
  174. nautobot/project-static/docs/user-guide/administration/guides/prometheus-metrics.html +3 -3
  175. nautobot/project-static/docs/user-guide/administration/guides/replicating-nautobot.html +3 -3
  176. nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +3 -3
  177. nautobot/project-static/docs/user-guide/administration/guides/s3-django-storage.html +3 -3
  178. nautobot/project-static/docs/user-guide/administration/guides/selinux-troubleshooting.html +3 -3
  179. nautobot/project-static/docs/user-guide/administration/installation/app-install.html +3 -3
  180. nautobot/project-static/docs/user-guide/administration/installation/external-authentication.html +3 -3
  181. nautobot/project-static/docs/user-guide/administration/installation/http-server.html +3 -3
  182. nautobot/project-static/docs/user-guide/administration/installation/index.html +3 -3
  183. nautobot/project-static/docs/user-guide/administration/installation/install_system.html +3 -3
  184. nautobot/project-static/docs/user-guide/administration/installation/nautobot.html +3 -3
  185. nautobot/project-static/docs/user-guide/administration/installation/services.html +3 -3
  186. nautobot/project-static/docs/user-guide/administration/migration/migrating-from-netbox.html +3 -3
  187. nautobot/project-static/docs/user-guide/administration/migration/migrating-from-postgresql.html +3 -3
  188. nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +3 -3
  189. nautobot/project-static/docs/user-guide/administration/tools/nautobot-shell.html +3 -3
  190. nautobot/project-static/docs/user-guide/administration/upgrading/database-backup.html +3 -3
  191. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/after-you-upgrade.html +3 -3
  192. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/before-you-upgrade.html +3 -3
  193. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/for-developers.html +3 -3
  194. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/index.html +3 -3
  195. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/whats-changed.html +3 -3
  196. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/region-and-site-data-migration-guide.html +3 -3
  197. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +3 -3
  198. nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +3 -3
  199. nautobot/project-static/docs/user-guide/core-data-model/circuits/circuit.html +3 -3
  200. nautobot/project-static/docs/user-guide/core-data-model/circuits/circuittermination.html +3 -3
  201. nautobot/project-static/docs/user-guide/core-data-model/circuits/circuittype.html +3 -3
  202. nautobot/project-static/docs/user-guide/core-data-model/circuits/provider.html +3 -3
  203. nautobot/project-static/docs/user-guide/core-data-model/circuits/providernetwork.html +3 -3
  204. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloud.html +3 -3
  205. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudaccount.html +3 -3
  206. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudnetwork.html +3 -3
  207. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudnetworkprefixassignment.html +3 -3
  208. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudresourcetype.html +3 -3
  209. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudservice.html +3 -3
  210. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudservicenetworkassignment.html +3 -3
  211. nautobot/project-static/docs/user-guide/core-data-model/dcim/cable.html +3 -3
  212. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleport.html +3 -3
  213. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleporttemplate.html +3 -3
  214. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverport.html +3 -3
  215. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverporttemplate.html +3 -3
  216. nautobot/project-static/docs/user-guide/core-data-model/dcim/controller.html +3 -3
  217. nautobot/project-static/docs/user-guide/core-data-model/dcim/controllermanageddevicegroup.html +3 -3
  218. nautobot/project-static/docs/user-guide/core-data-model/dcim/device.html +3 -3
  219. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebay.html +3 -3
  220. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebaytemplate.html +3 -3
  221. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicefamily.html +3 -3
  222. nautobot/project-static/docs/user-guide/core-data-model/dcim/deviceredundancygroup.html +3 -3
  223. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicetype.html +3 -3
  224. nautobot/project-static/docs/user-guide/core-data-model/dcim/frontport.html +3 -3
  225. nautobot/project-static/docs/user-guide/core-data-model/dcim/frontporttemplate.html +3 -3
  226. nautobot/project-static/docs/user-guide/core-data-model/dcim/interface.html +3 -3
  227. nautobot/project-static/docs/user-guide/core-data-model/dcim/interfaceredundancygroup.html +3 -3
  228. nautobot/project-static/docs/user-guide/core-data-model/dcim/interfacetemplate.html +3 -3
  229. nautobot/project-static/docs/user-guide/core-data-model/dcim/inventoryitem.html +3 -3
  230. nautobot/project-static/docs/user-guide/core-data-model/dcim/location.html +3 -3
  231. nautobot/project-static/docs/user-guide/core-data-model/dcim/locationtype.html +3 -3
  232. nautobot/project-static/docs/user-guide/core-data-model/dcim/manufacturer.html +3 -3
  233. nautobot/project-static/docs/user-guide/core-data-model/dcim/module.html +3 -3
  234. nautobot/project-static/docs/user-guide/core-data-model/dcim/modulebay.html +3 -3
  235. nautobot/project-static/docs/user-guide/core-data-model/dcim/modulebaytemplate.html +3 -3
  236. nautobot/project-static/docs/user-guide/core-data-model/dcim/moduletype.html +3 -3
  237. nautobot/project-static/docs/user-guide/core-data-model/dcim/platform.html +3 -3
  238. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerfeed.html +3 -3
  239. nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlet.html +3 -3
  240. nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlettemplate.html +3 -3
  241. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerpanel.html +3 -3
  242. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerport.html +3 -3
  243. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerporttemplate.html +3 -3
  244. nautobot/project-static/docs/user-guide/core-data-model/dcim/rack.html +3 -3
  245. nautobot/project-static/docs/user-guide/core-data-model/dcim/rackgroup.html +3 -3
  246. nautobot/project-static/docs/user-guide/core-data-model/dcim/rackreservation.html +3 -3
  247. nautobot/project-static/docs/user-guide/core-data-model/dcim/rearport.html +3 -3
  248. nautobot/project-static/docs/user-guide/core-data-model/dcim/rearporttemplate.html +3 -3
  249. nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +3 -3
  250. nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareversion.html +3 -3
  251. nautobot/project-static/docs/user-guide/core-data-model/dcim/virtualchassis.html +3 -3
  252. nautobot/project-static/docs/user-guide/core-data-model/extras/configcontext.html +3 -3
  253. nautobot/project-static/docs/user-guide/core-data-model/extras/configcontextschema.html +3 -3
  254. nautobot/project-static/docs/user-guide/core-data-model/extras/contact.html +3 -3
  255. nautobot/project-static/docs/user-guide/core-data-model/extras/team.html +3 -3
  256. nautobot/project-static/docs/user-guide/core-data-model/ipam/ipaddress.html +3 -3
  257. nautobot/project-static/docs/user-guide/core-data-model/ipam/namespace.html +3 -3
  258. nautobot/project-static/docs/user-guide/core-data-model/ipam/prefix.html +3 -3
  259. nautobot/project-static/docs/user-guide/core-data-model/ipam/rir.html +3 -3
  260. nautobot/project-static/docs/user-guide/core-data-model/ipam/routetarget.html +3 -3
  261. nautobot/project-static/docs/user-guide/core-data-model/ipam/service.html +3 -3
  262. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +3 -3
  263. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlangroup.html +3 -3
  264. nautobot/project-static/docs/user-guide/core-data-model/ipam/vrf.html +3 -3
  265. nautobot/project-static/docs/user-guide/core-data-model/overview/introduction.html +3 -3
  266. nautobot/project-static/docs/user-guide/core-data-model/tenancy/tenant.html +3 -3
  267. nautobot/project-static/docs/user-guide/core-data-model/tenancy/tenantgroup.html +3 -3
  268. nautobot/project-static/docs/user-guide/core-data-model/virtualization/cluster.html +3 -3
  269. nautobot/project-static/docs/user-guide/core-data-model/virtualization/clustergroup.html +3 -3
  270. nautobot/project-static/docs/user-guide/core-data-model/virtualization/clustertype.html +3 -3
  271. nautobot/project-static/docs/user-guide/core-data-model/virtualization/virtualmachine.html +3 -3
  272. nautobot/project-static/docs/user-guide/core-data-model/virtualization/vminterface.html +3 -3
  273. nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +3 -3
  274. nautobot/project-static/docs/user-guide/feature-guides/custom-fields.html +3 -3
  275. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +3 -3
  276. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +3 -3
  277. nautobot/project-static/docs/user-guide/feature-guides/getting-started/index.html +3 -3
  278. nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +3 -3
  279. nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +3 -3
  280. nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +3 -3
  281. nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +3 -3
  282. nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +3 -3
  283. nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +3 -3
  284. nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +3 -3
  285. nautobot/project-static/docs/user-guide/feature-guides/graphql.html +3 -3
  286. nautobot/project-static/docs/user-guide/feature-guides/ip-address-merge-tool.html +3 -3
  287. nautobot/project-static/docs/user-guide/feature-guides/relationships.html +3 -3
  288. nautobot/project-static/docs/user-guide/feature-guides/software-image-files-and-versions.html +3 -3
  289. nautobot/project-static/docs/user-guide/index.html +3 -3
  290. nautobot/project-static/docs/user-guide/platform-functionality/change-logging.html +3 -3
  291. nautobot/project-static/docs/user-guide/platform-functionality/computedfield.html +3 -3
  292. nautobot/project-static/docs/user-guide/platform-functionality/customfield.html +3 -3
  293. nautobot/project-static/docs/user-guide/platform-functionality/customlink.html +3 -3
  294. nautobot/project-static/docs/user-guide/platform-functionality/dynamicgroup.html +3 -3
  295. nautobot/project-static/docs/user-guide/platform-functionality/exporttemplate.html +3 -3
  296. nautobot/project-static/docs/user-guide/platform-functionality/externalintegration.html +3 -3
  297. nautobot/project-static/docs/user-guide/platform-functionality/gitrepository.html +3 -3
  298. nautobot/project-static/docs/user-guide/platform-functionality/graphql.html +3 -3
  299. nautobot/project-static/docs/user-guide/platform-functionality/graphqlquery.html +3 -3
  300. nautobot/project-static/docs/user-guide/platform-functionality/imageattachment.html +3 -3
  301. nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +3 -3
  302. nautobot/project-static/docs/user-guide/platform-functionality/jobs/job-scheduling-and-approvals.html +3 -3
  303. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobbutton.html +3 -3
  304. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobhook.html +3 -3
  305. nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +3 -3
  306. nautobot/project-static/docs/user-guide/platform-functionality/napalm.html +3 -3
  307. nautobot/project-static/docs/user-guide/platform-functionality/note.html +3 -3
  308. nautobot/project-static/docs/user-guide/platform-functionality/objectmetadata.html +3 -3
  309. nautobot/project-static/docs/user-guide/platform-functionality/relationship.html +3 -3
  310. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/authentication.html +3 -3
  311. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/filtering.html +3 -3
  312. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/overview.html +3 -3
  313. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/ui-related-endpoints.html +3 -3
  314. nautobot/project-static/docs/user-guide/platform-functionality/role.html +3 -3
  315. nautobot/project-static/docs/user-guide/platform-functionality/savedview.html +3 -3
  316. nautobot/project-static/docs/user-guide/platform-functionality/secret.html +3 -3
  317. nautobot/project-static/docs/user-guide/platform-functionality/staticgroupassociation.html +3 -3
  318. nautobot/project-static/docs/user-guide/platform-functionality/status.html +3 -3
  319. nautobot/project-static/docs/user-guide/platform-functionality/tag.html +3 -3
  320. nautobot/project-static/docs/user-guide/platform-functionality/template-filters.html +3 -3
  321. nautobot/project-static/docs/user-guide/platform-functionality/users/objectpermission.html +3 -3
  322. nautobot/project-static/docs/user-guide/platform-functionality/users/token.html +3 -3
  323. nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +3 -3
  324. nautobot/project-static/js/nav_menu.js +249 -0
  325. nautobot/tenancy/templates/tenancy/tenant.html +1 -1
  326. nautobot/users/tests/test_views.py +9 -11
  327. nautobot/virtualization/tests/test_views.py +3 -5
  328. {nautobot-2.3.6.dist-info → nautobot-2.3.8.dist-info}/METADATA +2 -1
  329. {nautobot-2.3.6.dist-info → nautobot-2.3.8.dist-info}/RECORD +333 -331
  330. {nautobot-2.3.6.dist-info → nautobot-2.3.8.dist-info}/WHEEL +1 -1
  331. nautobot/project-static/docs/assets/javascripts/bundle.56dfad97.min.js +0 -16
  332. nautobot/project-static/docs/assets/stylesheets/main.35f28582.min.css +0 -1
  333. nautobot/project-static/docs/assets/stylesheets/main.35f28582.min.css.map +0 -1
  334. {nautobot-2.3.6.dist-info → nautobot-2.3.8.dist-info}/LICENSE.txt +0 -0
  335. {nautobot-2.3.6.dist-info → nautobot-2.3.8.dist-info}/NOTICE +0 -0
  336. {nautobot-2.3.6.dist-info → nautobot-2.3.8.dist-info}/entry_points.txt +0 -0
nautobot/dcim/forms.py CHANGED
@@ -213,23 +213,27 @@ class InterfaceCommonForm(forms.Form):
213
213
  elif mode == InterfaceModeChoices.MODE_TAGGED_ALL:
214
214
  self.cleaned_data["tagged_vlans"] = []
215
215
 
216
- # Validate tagged VLANs; must be a global VLAN or in the same location
217
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to add a VLAN
218
- # belongs to the parent Location or the child location of the parent device to the `tagged_vlan` field of the interface?
216
+ # Validate tagged VLANs; must be a global VLAN or in the same location as the
217
+ # parent device/VM or any of that location's parent locations
219
218
  elif mode == InterfaceModeChoices.MODE_TAGGED:
220
- valid_location = self.cleaned_data[parent_field].location
219
+ location = self.cleaned_data[parent_field].location
220
+ if location:
221
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
222
+ else:
223
+ location_ids = []
221
224
  invalid_vlans = [
222
225
  str(v)
223
226
  for v in tagged_vlans
224
227
  if v.locations.without_tree_fields().exists()
225
- and not VLANLocationAssignment.objects.filter(location=valid_location, vlan=v).exists()
228
+ and not VLANLocationAssignment.objects.filter(location__in=location_ids, vlan=v).exists()
226
229
  ]
227
230
 
228
231
  if invalid_vlans:
229
232
  raise forms.ValidationError(
230
233
  {
231
- "tagged_vlans": f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same location as "
232
- f"the interface's parent device/VM, or they must be global"
234
+ "tagged_vlans": f"The tagged VLANs ({', '.join(invalid_vlans)}) must have the same location as the "
235
+ "interface's parent device, or is in one of the parents of the interface's parent device's location, "
236
+ "or it must be global."
233
237
  }
234
238
  )
235
239
 
@@ -727,19 +727,22 @@ class Interface(ModularComponentModel, CableTermination, PathEndpoint, BaseInter
727
727
  )
728
728
 
729
729
  # Validate untagged VLAN
730
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to assign a VLAN belongs to
731
- # the parent Locations or the child locations of `device.location`?
730
+ location = self.parent.location if self.parent is not None else None
731
+ if location:
732
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
733
+ else:
734
+ location_ids = []
732
735
  if (
733
736
  self.untagged_vlan
734
737
  and self.untagged_vlan.locations.exists()
735
738
  and self.parent
736
- and not self.untagged_vlan.locations.filter(id=self.parent.location_id).exists()
739
+ and not self.untagged_vlan.locations.filter(pk__in=location_ids).exists()
737
740
  ):
738
741
  raise ValidationError(
739
742
  {
740
743
  "untagged_vlan": (
741
744
  f"The untagged VLAN ({self.untagged_vlan}) must have a common location as the interface's parent "
742
- f"device, or it must be global."
745
+ f"device, or is in one of the parents of the interface's parent device's location, or it must be global."
743
746
  )
744
747
  }
745
748
  )
@@ -2198,6 +2198,34 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
2198
2198
  self.client.post(url, self.untagged_vlan_data, format="json", **self.header), status.HTTP_201_CREATED
2199
2199
  )
2200
2200
 
2201
+ def test_tagged_vlan_must_be_in_the_location_or_parent_locations_of_the_parent_device(self):
2202
+ self.add_permissions("dcim.add_interface")
2203
+
2204
+ interface_status = Status.objects.get_for_model(Interface).first()
2205
+ location = self.devices[0].location
2206
+ location_ids = [ancestor.id for ancestor in location.ancestors()]
2207
+ non_valid_locations = Location.objects.exclude(pk__in=location_ids)
2208
+ faulty_vlan = self.vlans[0]
2209
+ faulty_vlan.locations.set([non_valid_locations.first().pk])
2210
+ faulty_vlan.validated_save()
2211
+ faulty_data = {
2212
+ "device": self.devices[0].pk,
2213
+ "name": "Test Vlans Interface",
2214
+ "type": "virtual",
2215
+ "status": interface_status.pk,
2216
+ "mode": InterfaceModeChoices.MODE_TAGGED,
2217
+ "parent_interface": self.interfaces[1].pk,
2218
+ "tagged_vlans": [faulty_vlan.pk, self.vlans[1].pk],
2219
+ "untagged_vlan": self.vlans[2].pk,
2220
+ }
2221
+ response = self.client.post(self._get_list_url(), data=faulty_data, format="json", **self.header)
2222
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
2223
+ self.assertIn(
2224
+ b"must have the same location as the interface's parent device, or is in one of the parents of the interface's parent device's location, or "
2225
+ b"it must be global.",
2226
+ response.content,
2227
+ )
2228
+
2201
2229
  def test_interface_belonging_to_common_device_or_vc_allowed(self):
2202
2230
  """Test parent, bridge, and LAG interfaces belonging to common device or VC is valid"""
2203
2231
  self.add_permissions("dcim.add_interface")
@@ -341,9 +341,25 @@ class InterfaceTestCase(TestCase):
341
341
  "tagged_vlans": [cls.vlan.pk],
342
342
  }
343
343
 
344
+ def test_interface_form_clean_vlan_location_success(self):
345
+ """Assert that form validation succeeds when matching locations/parent locations are associated to tagged VLAN"""
346
+ location = self.device.location
347
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
348
+ self.vlan.locations.set([location.id])
349
+ self.data["tagged_vlans"] = [self.vlan]
350
+ form = InterfaceForm(data=self.data, instance=self.interface)
351
+ self.assertTrue(form.is_valid())
352
+ self.vlan.locations.set(location_ids[:2])
353
+ self.data["tagged_vlans"] = [self.vlan]
354
+ form = InterfaceForm(data=self.data, instance=self.interface)
355
+ self.assertTrue(form.is_valid())
356
+
344
357
  def test_interface_form_clean_vlan_location_fail(self):
345
358
  """Assert that form validation fails when no matching locations are associated to tagged VLAN"""
346
- self.vlan.locations.set(list(Location.objects.exclude(pk=self.device.location.pk))[:2])
359
+ location = self.device.location
360
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
361
+ self.vlan.locations.set(list(Location.objects.exclude(pk__in=location_ids))[:2])
362
+ self.data["tagged_vlans"] = [self.vlan]
347
363
  form = InterfaceForm(data=self.data, instance=self.interface)
348
364
  self.assertFalse(form.is_valid())
349
365
 
@@ -2376,19 +2376,57 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2376
2376
  )
2377
2377
 
2378
2378
  def test_error_raised_when_adding_tagged_vlan_with_different_location_from_interface_parent_location(self):
2379
+ intf_status = Status.objects.get_for_model(Interface).first()
2380
+ intf_role = Role.objects.get_for_model(Interface).first()
2381
+ location_type = LocationType.objects.get(name="Campus")
2382
+ child_location = Location.objects.filter(parent__isnull=False, location_type=location_type).first()
2383
+ self.device.location = child_location
2384
+ self.device.validated_save()
2385
+ # Same location as the device
2386
+ interface = Interface.objects.create(
2387
+ name="Test Interface 2",
2388
+ mode=InterfaceModeChoices.MODE_TAGGED,
2389
+ device=self.device,
2390
+ status=intf_status,
2391
+ role=intf_role,
2392
+ )
2393
+ self.other_location_vlan.locations.set([self.device.location.pk])
2394
+ interface.tagged_vlans.set([self.other_location_vlan.pk])
2395
+
2396
+ # One of the parent locations of the device's location
2397
+ interface = Interface.objects.create(
2398
+ name="Test Interface 3",
2399
+ mode=InterfaceModeChoices.MODE_TAGGED,
2400
+ device=self.device,
2401
+ status=intf_status,
2402
+ role=intf_role,
2403
+ )
2404
+ self.other_location_vlan.locations.set([self.device.location.ancestors().first().pk])
2405
+ interface.tagged_vlans.set([self.other_location_vlan.pk])
2406
+
2379
2407
  with self.assertRaises(ValidationError) as err:
2380
2408
  interface = Interface.objects.create(
2381
- name="Test Interface",
2409
+ name="Test Interface 1",
2382
2410
  mode=InterfaceModeChoices.MODE_TAGGED,
2383
2411
  device=self.device,
2384
- status=Status.objects.get_for_model(Interface).first(),
2385
- role=Role.objects.get_for_model(Interface).first(),
2412
+ status=intf_status,
2413
+ role=intf_role,
2414
+ )
2415
+ location_3 = Location.objects.create(
2416
+ name="Invalid VLAN Location",
2417
+ location_type=LocationType.objects.get(name="Campus"),
2418
+ status=Status.objects.get_for_model(Location).first(),
2386
2419
  )
2420
+ # clear the valid locations
2421
+ self.other_location_vlan.locations.set([])
2422
+ # assign the invalid location
2423
+ self.other_location_vlan.location = location_3
2424
+ self.other_location_vlan.validated_save()
2387
2425
  interface.tagged_vlans.add(self.other_location_vlan)
2388
2426
  self.assertEqual(
2389
2427
  err.exception.message_dict["tagged_vlans"][0],
2390
2428
  f"Tagged VLAN with names {[self.other_location_vlan.name]} must all belong to the "
2391
- f"same location as the interface's parent device, or it must be global.",
2429
+ f"same location as the interface's parent device, one of the parent locations of the interface's parent device's location, or it must be global.",
2392
2430
  )
2393
2431
 
2394
2432
  def test_add_ip_addresses(self):
@@ -284,9 +284,7 @@ class LocationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
284
284
  "data": post_data(test_form_data),
285
285
  }
286
286
  response = self.client.post(**request)
287
- self.assertHttpStatus(response, 200)
288
- response_body = response.content.decode(response.charset)
289
- self.assertIn("“Generic Site” is not a valid UUID.", response_body)
287
+ self.assertBodyContains(response, "“Generic Site” is not a valid UUID.")
290
288
  test_form_data["parent"] = site_1.pk
291
289
  request["data"] = post_data(test_form_data)
292
290
  self.assertHttpStatus(self.client.post(**request), 302)
@@ -1172,8 +1170,7 @@ module-bays:
1172
1170
  }
1173
1171
 
1174
1172
  response = self.client.post(url, data)
1175
- self.assertHttpStatus(response, 200)
1176
- self.assertIn("failed validation", response.content.decode(response.charset))
1173
+ self.assertBodyContains(response, "failed validation")
1177
1174
 
1178
1175
 
1179
1176
  class ModuleTypeTestCase(
@@ -2279,12 +2276,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
2279
2276
 
2280
2277
  url = reverse("dcim:device_interfaces", kwargs={"pk": device.pk})
2281
2278
  response = self.client.get(url)
2282
- self.assertHttpStatus(response, 200)
2283
- response_body = response.content.decode(response.charset)
2284
- # Count the number of occurrences of "Add IP address" in the response_body
2285
- count = response_body.count("Add IP address")
2286
2279
  # Assert that "Add IP address" appears for each of the three interfaces
2287
- self.assertEqual(count, 3)
2280
+ self.assertBodyContains(response, "Add IP address", count=3)
2288
2281
 
2289
2282
  def test_device_interface_assign_ipaddress(self):
2290
2283
  device = Device.objects.first()
@@ -2322,29 +2315,19 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
2322
2315
  "data": post_data(assign_ip_form_data),
2323
2316
  }
2324
2317
 
2325
- with self.subTest("Assert Cannnot assign IPAddress('Add New') without permission"):
2318
+ with self.subTest("Assert Cannot assign IPAddress('Add New') without permission"):
2326
2319
  # Assert Add new IPAddress
2327
2320
  response = self.client.post(**add_new_ip_request, follow=True)
2328
- response_body = response.content.decode(response.charset)
2329
- self.assertHttpStatus(response, 200)
2321
+ self.assertBodyContains(response, f"Interface with id "{self.interfaces[0].pk}" not found")
2330
2322
  self.interfaces[0].refresh_from_db()
2331
2323
  self.assertEqual(self.interfaces[0].ip_addresses.all().count(), 0)
2332
- self.assertIn(
2333
- f"Interface with id "{self.interfaces[0].pk}" not found",
2334
- response_body,
2335
- )
2336
2324
 
2337
- with self.subTest("Assert Cannnot assign IPAddress(Exsisting IP) without permission"):
2325
+ with self.subTest("Assert Cannot assign IPAddress(Existing IP) without permission"):
2338
2326
  # Assert Assign Exsisting IPAddress
2339
2327
  response = self.client.post(**assign_ip_request, follow=True)
2340
- response_body = response.content.decode(response.charset)
2341
- self.assertHttpStatus(response, 200)
2328
+ self.assertBodyContains(response, f"Interface with id "{self.interfaces[1].pk}" not found")
2342
2329
  self.interfaces[1].refresh_from_db()
2343
2330
  self.assertEqual(self.interfaces[1].ip_addresses.all().count(), 0)
2344
- self.assertIn(
2345
- f"Interface with id "{self.interfaces[1].pk}" not found",
2346
- response_body,
2347
- )
2348
2331
 
2349
2332
  self.add_permissions("dcim.change_interface", "ipam.view_ipaddress")
2350
2333
 
@@ -2375,10 +2358,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
2375
2358
  "data": post_data(assign_ip_form_data),
2376
2359
  }
2377
2360
  response = self.client.post(**assign_ip_request, follow=True)
2378
- self.assertHttpStatus(response, 200)
2379
- self.assertIn(
2380
- "Please select at least one IP Address from the table.", response.content.decode(response.charset)
2381
- )
2361
+ self.assertBodyContains(response, "Please select at least one IP Address from the table.")
2382
2362
 
2383
2363
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2384
2364
  def test_device_rearports(self):
@@ -2683,12 +2663,8 @@ class ModuleTestCase(ViewTestCases.PrimaryObjectViewTestCase):
2683
2663
 
2684
2664
  url = reverse("dcim:module_interfaces", kwargs={"pk": module.pk})
2685
2665
  response = self.client.get(url)
2686
- self.assertHttpStatus(response, 200)
2687
- response_body = response.content.decode(response.charset)
2688
- # Count the number of occurrences of "Add IP address" in the response_body
2689
- count = response_body.count("Add IP address")
2690
2666
  # Assert that "Add IP address" appears for each of the three interfaces
2691
- self.assertEqual(count, 3)
2667
+ self.assertBodyContains(response, "Add IP address", count=3)
2692
2668
 
2693
2669
  def test_module_interface_assign_ipaddress(self):
2694
2670
  module = Module.objects.first()
@@ -2726,29 +2702,19 @@ class ModuleTestCase(ViewTestCases.PrimaryObjectViewTestCase):
2726
2702
  "data": post_data(assign_ip_form_data),
2727
2703
  }
2728
2704
 
2729
- with self.subTest("Assert Cannnot assign IPAddress('Add New') without permission"):
2705
+ with self.subTest("Assert Cannot assign IPAddress('Add New') without permission"):
2730
2706
  # Assert Add new IPAddress
2731
2707
  response = self.client.post(**add_new_ip_request, follow=True)
2732
- response_body = response.content.decode(response.charset)
2733
- self.assertHttpStatus(response, 200)
2708
+ self.assertBodyContains(response, f"Interface with id "{self.interfaces[0].pk}" not found")
2734
2709
  self.interfaces[0].refresh_from_db()
2735
2710
  self.assertEqual(self.interfaces[0].ip_addresses.all().count(), 0)
2736
- self.assertIn(
2737
- f"Interface with id "{self.interfaces[0].pk}" not found",
2738
- response_body,
2739
- )
2740
2711
 
2741
- with self.subTest("Assert Cannnot assign IPAddress(Exsisting IP) without permission"):
2712
+ with self.subTest("Assert Cannot assign IPAddress(Existing IP) without permission"):
2742
2713
  # Assert Assign Exsisting IPAddress
2743
2714
  response = self.client.post(**assign_ip_request, follow=True)
2744
- response_body = response.content.decode(response.charset)
2745
- self.assertHttpStatus(response, 200)
2715
+ self.assertBodyContains(response, f"Interface with id "{self.interfaces[1].pk}" not found")
2746
2716
  self.interfaces[1].refresh_from_db()
2747
2717
  self.assertEqual(self.interfaces[1].ip_addresses.all().count(), 0)
2748
- self.assertIn(
2749
- f"Interface with id "{self.interfaces[1].pk}" not found",
2750
- response_body,
2751
- )
2752
2718
 
2753
2719
  self.add_permissions("dcim.change_interface", "ipam.view_ipaddress")
2754
2720
 
@@ -3228,8 +3194,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
3228
3194
  invalid_ipaddress_link = reverse("ipam:ipaddress_edit", args=(ipaddress.pk,))
3229
3195
  valid_ipaddress_link = ipaddress.get_absolute_url()
3230
3196
  response = self.client.get(interface.get_absolute_url() + "?tab=main")
3231
- response_content = response.content.decode(response.charset)
3232
- self.assertIn(valid_ipaddress_link, response_content)
3197
+ self.assertBodyContains(response, valid_ipaddress_link)
3198
+ response_content = extract_page_body(response.content.decode(response.charset))
3233
3199
  self.assertNotIn(invalid_ipaddress_link, response_content)
3234
3200
 
3235
3201
 
@@ -4113,7 +4079,6 @@ class InterfaceConnectionsTestCase(ViewTestCases.ListObjectsViewTestCase):
4113
4079
  response = self.client.get(f"{self._get_url('list')}?id={instance1.pk}")
4114
4080
  self.assertHttpStatus(response, 200)
4115
4081
  content = extract_page_body(response.content.decode(response.charset))
4116
- # TODO: it'd make test failures more readable if we strip the page headers/footers from the content
4117
4082
  if hasattr(self.model, "name"):
4118
4083
  self.assertIn(instance1.name, content, msg=content)
4119
4084
  self.assertNotIn(instance2.name, content, msg=content)
@@ -4209,11 +4174,11 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4209
4174
  Interface.objects.create(device=self.devices[2], name="device 2 interface 1", status=interface_status)
4210
4175
  Interface.objects.create(device=self.devices[2], name="device 2 interface 2", status=interface_status)
4211
4176
  response = self.client.get(reverse("dcim:device_interfaces", kwargs={"pk": self.devices[0].pk}))
4212
- self.assertIn('Interfaces <span class="badge">6</span>', str(response.content))
4213
- self.assertIn("device 1 interface 1", str(response.content))
4214
- self.assertIn("device 1 interface 2", str(response.content))
4215
- self.assertIn("device 2 interface 1", str(response.content))
4216
- self.assertIn("device 2 interface 2", str(response.content))
4177
+ self.assertBodyContains(response, 'Interfaces <span class="badge">6</span>')
4178
+ self.assertBodyContains(response, "device 1 interface 1")
4179
+ self.assertBodyContains(response, "device 1 interface 2")
4180
+ self.assertBodyContains(response, "device 2 interface 1")
4181
+ self.assertBodyContains(response, "device 2 interface 2")
4217
4182
 
4218
4183
  def test_device_column_visible(self):
4219
4184
  """
@@ -4226,7 +4191,7 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4226
4191
  Interface.objects.create(device=self.devices[0], name="eth0", status=interface_status)
4227
4192
  Interface.objects.create(device=self.devices[0], name="eth1", status=interface_status)
4228
4193
  response = self.client.get(reverse("dcim:device_interfaces", kwargs={"pk": self.devices[0].pk}))
4229
- self.assertIn("<th >Device</th>", str(response.content))
4194
+ self.assertBodyContains(response, "<th>Device</th>", html=True)
4230
4195
 
4231
4196
  def test_device_column_not_visible(self):
4232
4197
  """
@@ -4239,9 +4204,9 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4239
4204
  Interface.objects.create(device=self.devices[1], name="eth2", status=interface_status)
4240
4205
  Interface.objects.create(device=self.devices[1], name="eth3", status=interface_status)
4241
4206
  response = self.client.get(reverse("dcim:device_interfaces", kwargs={"pk": self.devices[1].pk}))
4242
- self.assertNotIn("<th >Device</th>", str(response.content))
4207
+ self.assertNotIn("<th >Device</th>", extract_page_body(response.content.decode(response.charset)))
4243
4208
  # Sanity check:
4244
- self.assertIn("<th >Name</th>", str(response.content))
4209
+ self.assertBodyContains(response, "<th>Name</th>", html=True)
4245
4210
 
4246
4211
 
4247
4212
  class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -4408,9 +4373,7 @@ class PathTraceViewTestCase(ModelViewTestCase):
4408
4373
  url = reverse("dcim:rearport_trace", args=[obj.pk])
4409
4374
  cablepath_id = CablePath.objects.first().id
4410
4375
  response = self.client.get(url + f"?cablepath_id={cablepath_id}")
4411
- self.assertHttpStatus(response, 200)
4412
- content = extract_page_body(response.content.decode(response.charset))
4413
- self.assertInHTML("<h1>Cable Trace for Rear Port Rear Port 1</h1>", content)
4376
+ self.assertBodyContains(response, "<h1>Cable Trace for Rear Port Rear Port 1</h1>", html=True)
4414
4377
 
4415
4378
 
4416
4379
  class DeviceRedundancyGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -4621,10 +4584,8 @@ class SoftwareImageFileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4621
4584
  "_confirm": True, # Form button
4622
4585
  }
4623
4586
  response = self.client.post(self._get_url("bulk_delete"), data, follow=True)
4624
- self.assertHttpStatus(response, 200)
4625
- response_body = response.content.decode(response.charset)
4626
4587
  # Assert protected error message included in the response body
4627
- self.assertInHTML(f"<span>{device_type_to_software_image_file}</span>", response_body)
4588
+ self.assertBodyContains(response, f"<span>{device_type_to_software_image_file}</span>", html=True)
4628
4589
 
4629
4590
 
4630
4591
  class SoftwareVersionTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -4701,10 +4662,8 @@ class SoftwareVersionTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4701
4662
  "_confirm": True, # Form button
4702
4663
  }
4703
4664
  response = self.client.post(self._get_url("bulk_delete"), data, follow=True)
4704
- self.assertHttpStatus(response, 200)
4705
- response_body = response.content.decode(response.charset)
4706
4665
  # Assert protected error message included in the response body
4707
- self.assertInHTML(f"<span>{device_type_to_software_image_file}</span>", response_body)
4666
+ self.assertBodyContains(response, f"<span>{device_type_to_software_image_file}</span>", html=True)
4708
4667
 
4709
4668
 
4710
4669
  class ControllerTestCase(ViewTestCases.PrimaryObjectViewTestCase):
nautobot/dcim/utils.py CHANGED
@@ -139,12 +139,14 @@ def validate_interface_tagged_vlans(instance, model, pk_set):
139
139
  )
140
140
 
141
141
  # Filter the model objects based on the primary keys passed in kwargs and exclude the ones that have
142
- # a location that is not the parent's location or None
143
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to add a VLAN
144
- # belongs to the parent Location or the child location of the parent device to the `tagged_vlan` field of the interface?
145
- device_location = getattr(instance.parent, "location", None)
142
+ # a location that is not the parent's location, or parent's location's ancestors, or None
143
+ location = getattr(instance.parent, "location", None)
144
+ if location:
145
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
146
+ else:
147
+ location_ids = []
146
148
  tagged_vlans = (
147
- model.objects.filter(pk__in=pk_set).exclude(locations__isnull=True).exclude(locations__in=[device_location])
149
+ model.objects.filter(pk__in=pk_set).exclude(locations__isnull=True).exclude(locations__in=location_ids)
148
150
  )
149
151
 
150
152
  if tagged_vlans.count():
@@ -152,7 +154,8 @@ def validate_interface_tagged_vlans(instance, model, pk_set):
152
154
  {
153
155
  "tagged_vlans": (
154
156
  f"Tagged VLAN with names {list(tagged_vlans.values_list('name', flat=True))} must all belong to the "
155
- f"same location as the interface's parent device, or it must be global."
157
+ "same location as the interface's parent device, "
158
+ "one of the parent locations of the interface's parent device's location, or it must be global."
156
159
  )
157
160
  }
158
161
  )
@@ -177,7 +177,12 @@ def ensure_git_repository(repository_record, logger=None, head=None): # pylint:
177
177
  if logger:
178
178
  if changed:
179
179
  logger.info("Repository successfully refreshed")
180
- logger.info(f'The current Git repository hash is "{repository_record.current_head}"')
180
+ logger.info(
181
+ '%s: the current Git repository hash is "%s"',
182
+ repository_record.name,
183
+ repository_record.current_head,
184
+ extra={"object": repository_record},
185
+ )
181
186
 
182
187
  return changed
183
188
 
@@ -10,6 +10,9 @@ def set_dynamic_group_group_types(apps, schema_editor):
10
10
  # The group_type field defaults to TYPE_DYNAMIC_FILTER
11
11
  # There are no preexisting TYPE_STATIC groups as that's a new feature
12
12
  # Any group that has children should be converted to TYPE_DYNAMIC_SET
13
+ # NOTE: The below is actually incorrect (see migration 0116) as for some reason, during migrations ONLY,
14
+ # Django somehow swaps the `parent` and `children` relations on DynamicGroup such that the below actually detects
15
+ # the opposite set of groups from what would be expected.
13
16
  DynamicGroup.objects.filter(children__isnull=False).distinct().update(
14
17
  group_type=DynamicGroupTypeChoices.TYPE_DYNAMIC_SET
15
18
  )
@@ -0,0 +1,16 @@
1
+ from django.db import migrations
2
+
3
+ from nautobot.extras.utils import fixup_dynamic_group_group_types
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("extras", "0115_scheduledjob_time_zone"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.RunPython(
13
+ code=fixup_dynamic_group_group_types,
14
+ reverse_code=migrations.operations.special.RunPython.noop,
15
+ ),
16
+ ]
@@ -30,6 +30,21 @@ class InstalledAppsView(GenericView):
30
30
  data = []
31
31
  for app in apps.get_app_configs():
32
32
  if app.name in settings.PLUGINS:
33
+ try:
34
+ reverse(app.home_view_name)
35
+ home_url = app.home_view_name
36
+ except NoReverseMatch:
37
+ home_url = None
38
+ try:
39
+ reverse(app.config_view_name)
40
+ config_url = app.config_view_name
41
+ except NoReverseMatch:
42
+ config_url = None
43
+ try:
44
+ reverse(app.docs_view_name)
45
+ docs_url = app.docs_view_name
46
+ except NoReverseMatch:
47
+ docs_url = None
33
48
  data.append(
34
49
  {
35
50
  "name": app.verbose_name,
@@ -40,9 +55,9 @@ class InstalledAppsView(GenericView):
40
55
  "description": app.description,
41
56
  "version": app.version,
42
57
  "actions": {
43
- "home": app.home_view_name,
44
- "configure": app.config_view_name,
45
- "docs": app.docs_view_name,
58
+ "home": home_url,
59
+ "configure": config_url,
60
+ "docs": docs_url,
46
61
  },
47
62
  }
48
63
  )
@@ -15,7 +15,7 @@ from nautobot.core.models.fields import slugify_dashes_to_underscores
15
15
  from nautobot.core.tables import CustomFieldColumn
16
16
  from nautobot.core.testing import APITestCase, TestCase, TransactionTestCase
17
17
  from nautobot.core.testing.models import ModelTestCases
18
- from nautobot.core.testing.utils import post_data
18
+ from nautobot.core.testing.utils import extract_page_body, post_data
19
19
  from nautobot.core.utils.lookup import get_changes_for_model
20
20
  from nautobot.dcim.filters import LocationFilterSet
21
21
  from nautobot.dcim.forms import RackFilterForm
@@ -995,18 +995,15 @@ class CustomFieldDataAPITest(APITestCase):
995
995
  },
996
996
  }
997
997
  response = self.client.post(self.list_url, data, format="json", **self.header)
998
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
999
- self.assertIn("Value must be a string", str(response.content))
998
+ self.assertContains(response, "Value must be a string", status_code=status.HTTP_400_BAD_REQUEST)
1000
999
 
1001
1000
  data["custom_fields"].update({self.cf_text.key: 2})
1002
1001
  response = self.client.post(self.list_url, data, format="json", **self.header)
1003
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1004
- self.assertIn("Value must be a string", str(response.content))
1002
+ self.assertContains(response, "Value must be a string", status_code=status.HTTP_400_BAD_REQUEST)
1005
1003
 
1006
1004
  data["custom_fields"].update({self.cf_text.key: True})
1007
1005
  response = self.client.post(self.list_url, data, format="json", **self.header)
1008
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1009
- self.assertIn("Value must be a string", str(response.content))
1006
+ self.assertContains(response, "Value must be a string", status_code=status.HTTP_400_BAD_REQUEST)
1010
1007
 
1011
1008
  def test_create_without_required_field(self):
1012
1009
  self.cf_text.default = None
@@ -1019,8 +1016,7 @@ class CustomFieldDataAPITest(APITestCase):
1019
1016
  "status": self.statuses[0].pk,
1020
1017
  }
1021
1018
  response = self.client.post(self.list_url, data, format="json", **self.header)
1022
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1023
- self.assertIn("Required field cannot be empty", str(response.content))
1019
+ self.assertContains(response, "Required field cannot be empty", status_code=status.HTTP_400_BAD_REQUEST)
1024
1020
 
1025
1021
  # Try in CSV format too
1026
1022
  csvdata = "\n".join(
@@ -1030,8 +1026,7 @@ class CustomFieldDataAPITest(APITestCase):
1030
1026
  ]
1031
1027
  )
1032
1028
  response = self.client.post(self.list_url, csvdata, content_type="text/csv", **self.header)
1033
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1034
- self.assertIn("Required field cannot be empty", str(response.content))
1029
+ self.assertContains(response, "Required field cannot be empty", status_code=status.HTTP_400_BAD_REQUEST)
1035
1030
 
1036
1031
  def test_create_invalid_select_choice(self):
1037
1032
  data = {
@@ -1043,8 +1038,7 @@ class CustomFieldDataAPITest(APITestCase):
1043
1038
  },
1044
1039
  }
1045
1040
  response = self.client.post(self.list_url, data, format="json", **self.header)
1046
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1047
- self.assertIn("Invalid choice", str(response.content))
1041
+ self.assertContains(response, "Invalid choice", status_code=status.HTTP_400_BAD_REQUEST)
1048
1042
 
1049
1043
  # Try in CSV format too
1050
1044
  csvdata = "\n".join(
@@ -1054,8 +1048,7 @@ class CustomFieldDataAPITest(APITestCase):
1054
1048
  ]
1055
1049
  )
1056
1050
  response = self.client.post(self.list_url, csvdata, content_type="text/csv", **self.header)
1057
- self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1058
- self.assertIn("Invalid choice", str(response.content))
1051
+ self.assertContains(response, "Invalid choice", status_code=status.HTTP_400_BAD_REQUEST)
1059
1052
 
1060
1053
 
1061
1054
  class CustomFieldImportTest(TestCase):
@@ -1157,7 +1150,7 @@ class CustomFieldImportTest(TestCase):
1157
1150
  try:
1158
1151
  location1 = Location.objects.get(name="Location 1")
1159
1152
  except Location.DoesNotExist:
1160
- self.fail(str(response.content))
1153
+ self.fail(extract_page_body(response.content.decode(response.charset)))
1161
1154
  self.assertEqual(len(location1.cf), 8)
1162
1155
  self.assertEqual(location1.cf["text"], "ABC")
1163
1156
  self.assertEqual(location1.cf["integer"], 123)