nautobot 2.3.16__py3-none-any.whl → 2.4.0__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 (721) hide show
  1. nautobot/__init__.py +15 -0
  2. nautobot/apps/__init__.py +1 -1
  3. nautobot/apps/api.py +8 -10
  4. nautobot/apps/change_logging.py +2 -2
  5. nautobot/apps/choices.py +4 -4
  6. nautobot/apps/config.py +32 -3
  7. nautobot/apps/events.py +19 -0
  8. nautobot/apps/exceptions.py +0 -2
  9. nautobot/apps/factory.py +2 -2
  10. nautobot/apps/filters.py +1 -1
  11. nautobot/apps/forms.py +20 -20
  12. nautobot/apps/graphql.py +2 -2
  13. nautobot/apps/jobs.py +8 -8
  14. nautobot/apps/models.py +19 -19
  15. nautobot/apps/tables.py +1 -1
  16. nautobot/apps/testing.py +10 -10
  17. nautobot/apps/ui.py +44 -9
  18. nautobot/apps/utils.py +7 -15
  19. nautobot/apps/views.py +8 -6
  20. nautobot/circuits/api/serializers.py +1 -0
  21. nautobot/circuits/api/views.py +4 -8
  22. nautobot/circuits/navigation.py +0 -57
  23. nautobot/circuits/templates/circuits/circuit_create.html +1 -7
  24. nautobot/circuits/templates/circuits/circuit_retrieve.html +0 -71
  25. nautobot/circuits/templates/circuits/inc/circuit_termination.html +6 -64
  26. nautobot/circuits/templates/circuits/inc/circuit_termination_cable_fragment.html +40 -0
  27. nautobot/circuits/templates/circuits/inc/circuit_termination_header_extra_content.html +26 -0
  28. nautobot/circuits/templates/circuits/provider_retrieve.html +0 -76
  29. nautobot/circuits/tests/integration/test_relationships.py +33 -24
  30. nautobot/circuits/tests/test_filters.py +4 -8
  31. nautobot/circuits/views.py +140 -23
  32. nautobot/cloud/api/views.py +6 -10
  33. nautobot/cloud/factory.py +4 -1
  34. nautobot/cloud/tests/test_filters.py +5 -4
  35. nautobot/cloud/views.py +0 -16
  36. nautobot/core/api/constants.py +11 -0
  37. nautobot/core/api/filter_backends.py +3 -9
  38. nautobot/core/api/metadata.py +28 -256
  39. nautobot/core/api/pagination.py +3 -2
  40. nautobot/core/api/renderers.py +3 -0
  41. nautobot/core/api/schema.py +13 -2
  42. nautobot/core/api/serializers.py +45 -259
  43. nautobot/core/api/urls.py +3 -4
  44. nautobot/core/api/utils.py +0 -62
  45. nautobot/core/api/views.py +99 -157
  46. nautobot/core/apps/__init__.py +22 -578
  47. nautobot/core/celery/__init__.py +13 -0
  48. nautobot/core/celery/schedulers.py +47 -2
  49. nautobot/core/choices.py +2 -2
  50. nautobot/core/cli/__init__.py +8 -0
  51. nautobot/core/constants.py +7 -0
  52. nautobot/core/events/__init__.py +116 -0
  53. nautobot/core/events/base.py +27 -0
  54. nautobot/core/events/exceptions.py +10 -0
  55. nautobot/core/events/redis_broker.py +48 -0
  56. nautobot/core/events/syslog_broker.py +19 -0
  57. nautobot/core/exceptions.py +0 -6
  58. nautobot/core/forms/__init__.py +19 -19
  59. nautobot/core/forms/fields.py +57 -9
  60. nautobot/core/forms/forms.py +33 -2
  61. nautobot/core/forms/utils.py +2 -1
  62. nautobot/core/graphql/schema.py +3 -1
  63. nautobot/core/jobs/__init__.py +24 -3
  64. nautobot/core/jobs/bulk_actions.py +248 -0
  65. nautobot/core/jobs/cleanup.py +1 -1
  66. nautobot/core/management/commands/generate_test_data.py +21 -0
  67. nautobot/core/middleware.py +16 -0
  68. nautobot/core/models/fields.py +11 -7
  69. nautobot/core/settings.py +68 -4
  70. nautobot/core/settings.yaml +99 -0
  71. nautobot/core/tables.py +10 -46
  72. nautobot/core/tasks.py +1 -1
  73. nautobot/core/templates/about.html +67 -0
  74. nautobot/core/templates/components/button/default.html +7 -0
  75. nautobot/core/templates/components/button/dropdown.html +20 -0
  76. nautobot/core/templates/components/layout/one_over_two.html +19 -0
  77. nautobot/core/templates/components/layout/two_over_one.html +19 -0
  78. nautobot/core/templates/components/panel/body_content_data_table.html +27 -0
  79. nautobot/core/templates/components/panel/body_content_objects_table.html +4 -0
  80. nautobot/core/templates/components/panel/body_content_tags.html +6 -0
  81. nautobot/core/templates/components/panel/body_content_text.html +12 -0
  82. nautobot/core/templates/components/panel/body_wrapper_generic.html +3 -0
  83. nautobot/core/templates/components/panel/body_wrapper_key_value_table.html +3 -0
  84. nautobot/core/templates/components/panel/body_wrapper_table.html +3 -0
  85. nautobot/core/templates/components/panel/footer_contacts_table.html +20 -0
  86. nautobot/core/templates/components/panel/footer_content_table.html +14 -0
  87. nautobot/core/templates/components/panel/grouping_toggle.html +14 -0
  88. nautobot/core/templates/components/panel/header_extra_content_table.html +3 -0
  89. nautobot/core/templates/components/panel/panel.html +16 -0
  90. nautobot/core/templates/components/panel/stats_panel_body.html +8 -0
  91. nautobot/core/templates/components/tab/content_wrapper.html +3 -0
  92. nautobot/core/templates/components/tab/label_wrapper.html +5 -0
  93. nautobot/core/templates/components/tab/label_wrapper_distinct_view.html +3 -0
  94. nautobot/core/templates/generic/object_retrieve.html +28 -17
  95. nautobot/core/templates/inc/computed_fields/panel_data.html +4 -7
  96. nautobot/core/templates/inc/custom_fields/panel.html +2 -2
  97. nautobot/core/templates/inc/custom_fields/panel_data.html +4 -7
  98. nautobot/core/templates/inc/footer.html +1 -0
  99. nautobot/core/templates/inc/nav_menu.html +2 -1
  100. nautobot/core/templates/inc/relationships_panel.html +1 -1
  101. nautobot/core/templates/inc/tenancy_form_panel.html +9 -0
  102. nautobot/core/templates/inc/tenant_table_row.html +11 -0
  103. nautobot/core/templates/nautobot_config.py.j2 +13 -0
  104. nautobot/core/templates/panel_table.html +12 -0
  105. nautobot/core/templates/utilities/render_jinja2.html +117 -0
  106. nautobot/core/templates/utilities/templatetags/tag.html +1 -1
  107. nautobot/core/templates/utilities/theme_preview.html +7 -0
  108. nautobot/core/templatetags/helpers.py +104 -6
  109. nautobot/core/templatetags/ui_framework.py +40 -0
  110. nautobot/core/testing/__init__.py +8 -8
  111. nautobot/core/testing/api.py +187 -137
  112. nautobot/core/testing/context.py +18 -0
  113. nautobot/core/testing/filters.py +41 -35
  114. nautobot/core/testing/forms.py +2 -0
  115. nautobot/core/testing/views.py +65 -148
  116. nautobot/core/tests/integration/test_view_authentication.py +1 -1
  117. nautobot/core/tests/nautobot_config.py +198 -0
  118. nautobot/core/tests/runner.py +2 -2
  119. nautobot/core/tests/test_api.py +154 -176
  120. nautobot/core/tests/test_events.py +214 -0
  121. nautobot/core/tests/test_forms.py +1 -0
  122. nautobot/core/tests/test_jinja_filters.py +1 -0
  123. nautobot/core/tests/test_jobs.py +387 -14
  124. nautobot/core/tests/test_navigations.py +7 -241
  125. nautobot/core/tests/test_settings_schema.py +7 -0
  126. nautobot/core/tests/test_tables.py +100 -0
  127. nautobot/core/tests/test_templatetags_helpers.py +16 -0
  128. nautobot/core/tests/test_ui.py +150 -0
  129. nautobot/core/tests/test_utils.py +55 -18
  130. nautobot/core/tests/test_views.py +124 -5
  131. nautobot/core/ui/__init__.py +0 -0
  132. nautobot/core/ui/base.py +11 -0
  133. nautobot/core/ui/choices.py +44 -0
  134. nautobot/core/ui/homepage.py +167 -0
  135. nautobot/core/ui/nav.py +280 -0
  136. nautobot/core/ui/object_detail.py +1855 -0
  137. nautobot/core/ui/utils.py +36 -0
  138. nautobot/core/urls.py +6 -0
  139. nautobot/core/utils/config.py +30 -3
  140. nautobot/core/utils/lookup.py +12 -2
  141. nautobot/core/utils/querysets.py +64 -0
  142. nautobot/core/utils/requests.py +24 -9
  143. nautobot/core/views/__init__.py +48 -1
  144. nautobot/core/views/generic.py +37 -140
  145. nautobot/core/views/mixins.py +82 -32
  146. nautobot/core/views/paginator.py +8 -5
  147. nautobot/core/views/renderers.py +9 -9
  148. nautobot/core/views/utils.py +11 -0
  149. nautobot/core/wsgi.py +3 -3
  150. nautobot/dcim/api/serializers.py +34 -141
  151. nautobot/dcim/api/urls.py +5 -0
  152. nautobot/dcim/api/views.py +57 -110
  153. nautobot/dcim/apps.py +1 -0
  154. nautobot/dcim/choices.py +28 -0
  155. nautobot/dcim/factory.py +58 -0
  156. nautobot/dcim/filters/__init__.py +204 -2
  157. nautobot/dcim/forms.py +219 -9
  158. nautobot/dcim/migrations/0063_interfacevdcassignment_virtualdevicecontext_and_more.py +165 -0
  159. nautobot/dcim/migrations/0064_virtualdevicecontext_status_data_migration.py +28 -0
  160. nautobot/dcim/migrations/0065_controller_capabilities_and_more.py +29 -0
  161. nautobot/dcim/migrations/0066_controllermanageddevicegroup_radio_profiles_and_more.py +33 -0
  162. nautobot/dcim/migrations/0067_controllermanageddevicegroup_tenant.py +25 -0
  163. nautobot/dcim/models/__init__.py +5 -1
  164. nautobot/dcim/models/devices.py +180 -2
  165. nautobot/dcim/models/racks.py +2 -2
  166. nautobot/dcim/navigation.py +25 -224
  167. nautobot/dcim/signals.py +44 -0
  168. nautobot/dcim/tables/__init__.py +2 -0
  169. nautobot/dcim/tables/devices.py +103 -7
  170. nautobot/dcim/tables/racks.py +1 -1
  171. nautobot/dcim/templates/dcim/controller/base.html +10 -0
  172. nautobot/dcim/templates/dcim/controller_create.html +2 -7
  173. nautobot/dcim/templates/dcim/controller_retrieve.html +6 -10
  174. nautobot/dcim/templates/dcim/controller_wirelessnetworks.html +25 -0
  175. nautobot/dcim/templates/dcim/controllermanageddevicegroup_create.html +68 -0
  176. nautobot/dcim/templates/dcim/controllermanageddevicegroup_retrieve.html +51 -0
  177. nautobot/dcim/templates/dcim/device/base.html +6 -42
  178. nautobot/dcim/templates/dcim/device/wireless.html +73 -0
  179. nautobot/dcim/templates/dcim/device.html +4 -10
  180. nautobot/dcim/templates/dcim/device_edit.html +36 -37
  181. nautobot/dcim/templates/dcim/interface.html +1 -0
  182. nautobot/dcim/templates/dcim/interface_edit.html +1 -0
  183. nautobot/dcim/templates/dcim/location.html +1 -9
  184. nautobot/dcim/templates/dcim/location_edit.html +1 -7
  185. nautobot/dcim/templates/dcim/locationtype.html +0 -107
  186. nautobot/dcim/templates/dcim/locationtype_retrieve.html +8 -0
  187. nautobot/dcim/templates/dcim/rack.html +1 -9
  188. nautobot/dcim/templates/dcim/rack_edit.html +1 -7
  189. nautobot/dcim/templates/dcim/rackreservation.html +1 -9
  190. nautobot/dcim/templates/dcim/virtualdevicecontext_retrieve.html +68 -0
  191. nautobot/dcim/templates/dcim/virtualdevicecontext_update.html +28 -0
  192. nautobot/dcim/tests/integration/test_controller.py +62 -0
  193. nautobot/dcim/tests/integration/test_controller_managed_device_group.py +71 -0
  194. nautobot/dcim/tests/test_api.py +188 -64
  195. nautobot/dcim/tests/test_filters.py +171 -76
  196. nautobot/dcim/tests/test_jobs.py +118 -0
  197. nautobot/dcim/tests/test_models.py +157 -6
  198. nautobot/dcim/tests/test_signals.py +1 -0
  199. nautobot/dcim/tests/test_views.py +118 -88
  200. nautobot/dcim/urls.py +72 -27
  201. nautobot/dcim/utils.py +2 -2
  202. nautobot/dcim/views.py +356 -61
  203. nautobot/extras/api/serializers.py +39 -18
  204. nautobot/extras/api/urls.py +4 -0
  205. nautobot/extras/api/views.py +89 -31
  206. nautobot/extras/choices.py +13 -0
  207. nautobot/extras/constants.py +2 -1
  208. nautobot/extras/context_managers.py +23 -6
  209. nautobot/extras/datasources/git.py +4 -1
  210. nautobot/extras/factory.py +27 -0
  211. nautobot/extras/filters/__init__.py +66 -5
  212. nautobot/extras/forms/base.py +2 -2
  213. nautobot/extras/forms/forms.py +262 -59
  214. nautobot/extras/forms/mixins.py +2 -2
  215. nautobot/extras/graphql/types.py +25 -1
  216. nautobot/extras/jobs.py +109 -15
  217. nautobot/extras/management/__init__.py +1 -0
  218. nautobot/extras/management/commands/runjob.py +7 -79
  219. nautobot/extras/management/commands/runjob_with_job_result.py +46 -0
  220. nautobot/extras/management/utils.py +87 -0
  221. nautobot/extras/migrations/0117_create_job_queue_model.py +129 -0
  222. nautobot/extras/migrations/0118_task_queue_to_job_queue_migration.py +78 -0
  223. nautobot/extras/migrations/0119_remove_task_queues_from_job_and_queue_from_scheduled_job.py +28 -0
  224. nautobot/extras/migrations/0120_job_is_singleton_job_is_singleton_override.py +22 -0
  225. nautobot/extras/migrations/0121_alter_team_contacts.py +17 -0
  226. nautobot/extras/models/__init__.py +5 -1
  227. nautobot/extras/models/change_logging.py +7 -3
  228. nautobot/extras/models/contacts.py +1 -1
  229. nautobot/extras/models/groups.py +0 -2
  230. nautobot/extras/models/jobs.py +233 -33
  231. nautobot/extras/models/relationships.py +69 -1
  232. nautobot/extras/models/secrets.py +5 -0
  233. nautobot/extras/navigation.py +20 -262
  234. nautobot/extras/plugins/__init__.py +54 -19
  235. nautobot/extras/plugins/marketplace_manifest.yml +455 -0
  236. nautobot/extras/plugins/tables.py +16 -14
  237. nautobot/extras/plugins/urls.py +1 -0
  238. nautobot/extras/plugins/views.py +103 -60
  239. nautobot/extras/registry.py +1 -1
  240. nautobot/extras/secrets/__init__.py +2 -2
  241. nautobot/extras/signals.py +39 -1
  242. nautobot/extras/tables.py +37 -1
  243. nautobot/extras/templates/extras/dynamicgroup.html +1 -9
  244. nautobot/extras/templates/extras/externalintegration_retrieve.html +0 -47
  245. nautobot/extras/templates/extras/inc/tags_panel.html +1 -5
  246. nautobot/extras/templates/extras/job_bulk_edit.html +2 -1
  247. nautobot/extras/templates/extras/job_detail.html +52 -6
  248. nautobot/extras/templates/extras/job_edit.html +6 -2
  249. nautobot/extras/templates/extras/job_list.html +2 -7
  250. nautobot/extras/templates/extras/jobqueue_retrieve.html +36 -0
  251. nautobot/extras/templates/extras/marketplace.html +296 -0
  252. nautobot/extras/templates/extras/plugin_detail.html +32 -15
  253. nautobot/extras/templates/extras/plugins_list.html +35 -1
  254. nautobot/extras/templates/extras/plugins_tiles.html +90 -0
  255. nautobot/extras/templates/extras/role_retrieve.html +16 -0
  256. nautobot/extras/templates/extras/secret.html +0 -65
  257. nautobot/extras/templates/extras/secret_check.js +16 -0
  258. nautobot/extras/templates/extras/secret_create.html +114 -0
  259. nautobot/extras/templates/extras/secret_edit.html +1 -114
  260. nautobot/extras/templates/extras/secretsgroup_edit.html +1 -1
  261. nautobot/extras/templates/extras/templatetags/plugin_object_detail_tabs.html +2 -0
  262. nautobot/extras/templatetags/job_buttons.py +5 -4
  263. nautobot/extras/templatetags/plugins.py +69 -6
  264. nautobot/extras/test_jobs/singleton.py +16 -0
  265. nautobot/extras/tests/test_api.py +145 -43
  266. nautobot/extras/tests/test_context_managers.py +4 -1
  267. nautobot/extras/tests/test_filters.py +213 -529
  268. nautobot/extras/tests/test_job_variables.py +73 -152
  269. nautobot/extras/tests/test_jobs.py +181 -51
  270. nautobot/extras/tests/test_models.py +61 -6
  271. nautobot/extras/tests/test_plugins.py +62 -9
  272. nautobot/extras/tests/test_relationships.py +123 -9
  273. nautobot/extras/tests/test_utils.py +23 -2
  274. nautobot/extras/tests/test_views.py +146 -145
  275. nautobot/extras/tests/test_webhooks.py +2 -1
  276. nautobot/extras/urls.py +2 -20
  277. nautobot/extras/utils.py +119 -4
  278. nautobot/extras/views.py +168 -125
  279. nautobot/extras/webhooks.py +5 -2
  280. nautobot/ipam/api/serializers.py +10 -103
  281. nautobot/ipam/api/views.py +31 -49
  282. nautobot/ipam/factory.py +1 -1
  283. nautobot/ipam/filters.py +3 -2
  284. nautobot/ipam/models.py +10 -12
  285. nautobot/ipam/navigation.py +0 -90
  286. nautobot/ipam/tables.py +3 -1
  287. nautobot/ipam/templates/ipam/ipaddress.html +1 -9
  288. nautobot/ipam/templates/ipam/ipaddress_bulk_add.html +1 -7
  289. nautobot/ipam/templates/ipam/ipaddress_edit.html +1 -7
  290. nautobot/ipam/templates/ipam/prefix.html +1 -9
  291. nautobot/ipam/templates/ipam/prefix_edit.html +1 -7
  292. nautobot/ipam/templates/ipam/routetarget.html +0 -28
  293. nautobot/ipam/templates/ipam/vlan.html +1 -9
  294. nautobot/ipam/templates/ipam/vlan_edit.html +1 -7
  295. nautobot/ipam/templates/ipam/vrf.html +0 -47
  296. nautobot/ipam/templates/ipam/vrf_edit.html +1 -7
  297. nautobot/ipam/tests/test_api.py +7 -5
  298. nautobot/ipam/tests/test_filters.py +39 -119
  299. nautobot/ipam/tests/test_forms.py +0 -2
  300. nautobot/ipam/tests/test_models.py +56 -36
  301. nautobot/ipam/tests/test_views.py +3 -0
  302. nautobot/ipam/urls.py +3 -69
  303. nautobot/ipam/utils/__init__.py +16 -10
  304. nautobot/ipam/views.py +91 -162
  305. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css.map +1 -1
  306. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css.map +1 -1
  307. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.css +40 -2
  308. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.css.map +1 -1
  309. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css +1 -1
  310. nautobot/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css.map +1 -1
  311. nautobot/project-static/css/base.css +38 -3
  312. nautobot/project-static/docs/404.html +461 -17
  313. nautobot/project-static/docs/apps/index.html +461 -17
  314. nautobot/project-static/docs/apps/nautobot-apps.html +462 -19
  315. nautobot/project-static/docs/assets/_mkdocstrings.css +25 -1
  316. nautobot/project-static/docs/assets/extra.css +5 -1
  317. nautobot/project-static/docs/code-reference/nautobot/apps/__init__.html +477 -23
  318. nautobot/project-static/docs/code-reference/nautobot/apps/admin.html +474 -20
  319. nautobot/project-static/docs/code-reference/nautobot/apps/api.html +790 -289
  320. nautobot/project-static/docs/code-reference/nautobot/apps/change_logging.html +505 -31
  321. nautobot/project-static/docs/code-reference/nautobot/apps/choices.html +510 -34
  322. nautobot/project-static/docs/code-reference/nautobot/apps/config.html +471 -20
  323. nautobot/project-static/docs/code-reference/nautobot/apps/constants.html +467 -18
  324. nautobot/project-static/docs/code-reference/nautobot/apps/datasources.html +497 -33
  325. nautobot/project-static/docs/code-reference/nautobot/apps/events.html +9883 -0
  326. nautobot/project-static/docs/code-reference/nautobot/apps/exceptions.html +523 -75
  327. nautobot/project-static/docs/code-reference/nautobot/apps/factory.html +546 -51
  328. nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +670 -94
  329. nautobot/project-static/docs/code-reference/nautobot/apps/forms.html +1030 -177
  330. nautobot/project-static/docs/code-reference/nautobot/apps/graphql.html +524 -49
  331. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +874 -188
  332. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +955 -235
  333. nautobot/project-static/docs/code-reference/nautobot/apps/querysets.html +475 -21
  334. nautobot/project-static/docs/code-reference/nautobot/apps/secrets.html +486 -28
  335. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +661 -99
  336. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +947 -479
  337. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +6425 -1234
  338. nautobot/project-static/docs/code-reference/nautobot/apps/urls.html +474 -20
  339. nautobot/project-static/docs/code-reference/nautobot/apps/utils.html +877 -344
  340. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +828 -171
  341. nautobot/project-static/docs/development/apps/api/configuration-view.html +461 -17
  342. nautobot/project-static/docs/development/apps/api/database-backend-config.html +461 -17
  343. nautobot/project-static/docs/development/apps/api/models/django-admin.html +461 -17
  344. nautobot/project-static/docs/development/apps/api/models/global-search.html +461 -17
  345. nautobot/project-static/docs/development/apps/api/models/graphql.html +461 -17
  346. nautobot/project-static/docs/development/apps/api/models/index.html +461 -17
  347. nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +461 -17
  348. nautobot/project-static/docs/development/apps/api/platform-features/custom-validators.html +461 -17
  349. nautobot/project-static/docs/development/apps/api/platform-features/filter-extensions.html +461 -17
  350. nautobot/project-static/docs/development/apps/api/platform-features/git-repository-content.html +461 -17
  351. nautobot/project-static/docs/development/apps/api/platform-features/index.html +461 -17
  352. nautobot/project-static/docs/development/apps/api/platform-features/jinja2-filters.html +461 -17
  353. nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +461 -17
  354. nautobot/project-static/docs/development/apps/api/platform-features/populating-extensibility-features.html +461 -17
  355. nautobot/project-static/docs/development/apps/api/platform-features/secrets-providers.html +461 -17
  356. nautobot/project-static/docs/development/apps/api/platform-features/table-extensions.html +461 -17
  357. nautobot/project-static/docs/development/apps/api/platform-features/uniquely-identify-objects.html +461 -17
  358. nautobot/project-static/docs/development/apps/api/prometheus.html +461 -17
  359. nautobot/project-static/docs/development/apps/api/setup.html +465 -153
  360. nautobot/project-static/docs/development/apps/api/testing.html +461 -17
  361. nautobot/project-static/docs/development/apps/api/ui-extensions/banners.html +461 -17
  362. nautobot/project-static/docs/development/apps/api/ui-extensions/home-page.html +461 -17
  363. nautobot/project-static/docs/development/apps/api/ui-extensions/index.html +461 -17
  364. nautobot/project-static/docs/development/apps/api/ui-extensions/navigation.html +461 -17
  365. nautobot/project-static/docs/development/apps/api/ui-extensions/object-views.html +741 -128
  366. nautobot/project-static/docs/development/apps/api/views/base-template.html +461 -17
  367. nautobot/project-static/docs/development/apps/api/views/core-view-overrides.html +461 -17
  368. nautobot/project-static/docs/development/apps/api/views/django-generic-views.html +461 -17
  369. nautobot/project-static/docs/development/apps/api/views/help-documentation.html +461 -17
  370. nautobot/project-static/docs/development/apps/api/views/index.html +463 -18
  371. nautobot/project-static/docs/development/apps/api/views/nautobot-generic-views.html +465 -17
  372. nautobot/project-static/docs/development/apps/api/views/nautobotuiviewset.html +491 -17
  373. nautobot/project-static/docs/development/apps/api/views/nautobotuiviewsetrouter.html +461 -17
  374. nautobot/project-static/docs/development/apps/api/views/notes.html +461 -17
  375. nautobot/project-static/docs/development/apps/api/views/rest-api.html +467 -19
  376. nautobot/project-static/docs/development/apps/api/views/urls.html +461 -17
  377. nautobot/project-static/docs/development/apps/index.html +461 -17
  378. nautobot/project-static/docs/development/apps/migration/code-updates.html +462 -50
  379. nautobot/project-static/docs/development/apps/migration/dependency-updates.html +462 -18
  380. nautobot/project-static/docs/development/apps/migration/from-v1.html +461 -17
  381. nautobot/project-static/docs/development/apps/migration/model-updates/dcim.html +461 -17
  382. nautobot/project-static/docs/development/apps/migration/model-updates/extras.html +461 -17
  383. nautobot/project-static/docs/development/apps/migration/model-updates/global.html +461 -17
  384. nautobot/project-static/docs/development/apps/migration/model-updates/ipam.html +464 -20
  385. nautobot/project-static/docs/development/apps/migration/ui-component-framework/best-practices.html +9261 -0
  386. nautobot/project-static/docs/development/apps/migration/ui-component-framework/custom-content.html +9375 -0
  387. nautobot/project-static/docs/development/apps/migration/ui-component-framework/index.html +9671 -0
  388. nautobot/project-static/docs/development/apps/migration/ui-component-framework/migration-steps.html +9559 -0
  389. nautobot/project-static/docs/development/apps/porting-from-netbox.html +464 -20
  390. nautobot/project-static/docs/development/core/application-registry.html +461 -17
  391. nautobot/project-static/docs/development/core/best-practices.html +461 -17
  392. nautobot/project-static/docs/development/core/bootstrap-ui.html +461 -17
  393. nautobot/project-static/docs/development/core/caching.html +461 -17
  394. nautobot/project-static/docs/development/core/controllers.html +463 -17
  395. nautobot/project-static/docs/development/core/docker-compose-advanced-use-cases.html +464 -20
  396. nautobot/project-static/docs/development/core/generic-views.html +461 -17
  397. nautobot/project-static/docs/development/core/getting-started.html +539 -127
  398. nautobot/project-static/docs/development/core/homepage.html +472 -28
  399. nautobot/project-static/docs/development/core/index.html +461 -17
  400. nautobot/project-static/docs/development/core/minikube-dev-environment-for-k8s-jobs.html +9754 -0
  401. nautobot/project-static/docs/development/core/model-checklist.html +471 -25
  402. nautobot/project-static/docs/development/core/model-features.html +461 -17
  403. nautobot/project-static/docs/development/core/natural-keys.html +461 -17
  404. nautobot/project-static/docs/development/core/navigation-menu.html +478 -24
  405. nautobot/project-static/docs/development/core/release-checklist.html +478 -46
  406. nautobot/project-static/docs/development/core/role-internals.html +461 -17
  407. nautobot/project-static/docs/development/core/settings.html +461 -17
  408. nautobot/project-static/docs/development/core/style-guide.html +464 -20
  409. nautobot/project-static/docs/development/core/templates.html +471 -20
  410. nautobot/project-static/docs/development/core/testing.html +461 -17
  411. nautobot/project-static/docs/development/core/ui-component-framework.html +11116 -0
  412. nautobot/project-static/docs/development/core/user-preferences.html +464 -20
  413. nautobot/project-static/docs/development/index.html +461 -17
  414. nautobot/project-static/docs/development/jobs/index.html +499 -19
  415. nautobot/project-static/docs/development/jobs/migration/from-v1.html +461 -17
  416. nautobot/project-static/docs/index.html +469 -36
  417. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_edit.png +0 -0
  418. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_edit_button.png +0 -0
  419. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_list_nav.png +0 -0
  420. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_list_view.png +0 -0
  421. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_queue.png +0 -0
  422. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_queue_add.png +0 -0
  423. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_queue_config.png +0 -0
  424. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_result_completed.png +0 -0
  425. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_result_nav.png +0 -0
  426. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_result_pending.png +0 -0
  427. nautobot/project-static/docs/media/development/core/kubernetes/k8s_job_run_form.png +0 -0
  428. nautobot/project-static/docs/media/development/core/kubernetes/k8s_nautobot_login.png +0 -0
  429. nautobot/project-static/docs/media/development/core/kubernetes/k8s_run_job.png +0 -0
  430. nautobot/project-static/docs/media/development/core/kubernetes/k8s_run_scheduled_job_form.png +0 -0
  431. nautobot/project-static/docs/media/development/core/kubernetes/k8s_scheduled_job_result.png +0 -0
  432. nautobot/project-static/docs/media/development/core/ui-component-framework/basic-panel-layout.png +0 -0
  433. nautobot/project-static/docs/media/development/core/ui-component-framework/button-example.png +0 -0
  434. nautobot/project-static/docs/media/development/core/ui-component-framework/buttons-example.png +0 -0
  435. nautobot/project-static/docs/media/development/core/ui-component-framework/cluster-type-before-after-example.png +0 -0
  436. nautobot/project-static/docs/media/development/core/ui-component-framework/dropdown-button-example.png +0 -0
  437. nautobot/project-static/docs/media/development/core/ui-component-framework/grouped-key-value-table-panel-example-1.png +0 -0
  438. nautobot/project-static/docs/media/development/core/ui-component-framework/grouped-key-value-table-panel-example-2.png +0 -0
  439. nautobot/project-static/docs/media/development/core/ui-component-framework/object-fields-panel-example.png +0 -0
  440. nautobot/project-static/docs/media/development/core/ui-component-framework/object-fields-panel-example_2.png +0 -0
  441. nautobot/project-static/docs/media/development/core/ui-component-framework/stats-panel-example-code.png +0 -0
  442. nautobot/project-static/docs/media/development/core/ui-component-framework/stats-panel-example.png +0 -0
  443. nautobot/project-static/docs/media/development/core/ui-component-framework/table-panels-family.png +0 -0
  444. nautobot/project-static/docs/media/development/core/ui-component-framework/text-panels-family.png +0 -0
  445. nautobot/project-static/docs/media/development/core/ui-component-framework/ui-framework-example.png +0 -0
  446. nautobot/project-static/docs/media/models/virtual_device_context_overview.drawio +73 -0
  447. nautobot/project-static/docs/media/models/virtual_device_context_overview.png +0 -0
  448. nautobot/project-static/docs/models/dcim/virtualdevicecontext.html +14 -0
  449. nautobot/project-static/docs/models/extras/jobqueue.html +14 -0
  450. nautobot/project-static/docs/models/wireless/radioprofile.html +14 -0
  451. nautobot/project-static/docs/models/wireless/supporteddatarate.html +14 -0
  452. nautobot/project-static/docs/models/wireless/wirelessnetwork.html +14 -0
  453. nautobot/project-static/docs/objects.inv +0 -0
  454. nautobot/project-static/docs/overview/application_stack.html +467 -21
  455. nautobot/project-static/docs/overview/design_philosophy.html +461 -17
  456. nautobot/project-static/docs/release-notes/index.html +483 -20
  457. nautobot/project-static/docs/release-notes/version-1.0.html +649 -206
  458. nautobot/project-static/docs/release-notes/version-1.1.html +646 -203
  459. nautobot/project-static/docs/release-notes/version-1.2.html +721 -278
  460. nautobot/project-static/docs/release-notes/version-1.3.html +747 -304
  461. nautobot/project-static/docs/release-notes/version-1.4.html +832 -390
  462. nautobot/project-static/docs/release-notes/version-1.5.html +1020 -579
  463. nautobot/project-static/docs/release-notes/version-1.6.html +940 -516
  464. nautobot/project-static/docs/release-notes/version-2.0.html +943 -502
  465. nautobot/project-static/docs/release-notes/version-2.1.html +778 -337
  466. nautobot/project-static/docs/release-notes/version-2.2.html +771 -330
  467. nautobot/project-static/docs/release-notes/version-2.3.html +914 -471
  468. nautobot/project-static/docs/release-notes/version-2.4.html +10323 -0
  469. nautobot/project-static/docs/search/search_index.json +1 -1
  470. nautobot/project-static/docs/sitemap.xml +342 -270
  471. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  472. nautobot/project-static/docs/user-guide/administration/configuration/authentication/ldap.html +461 -17
  473. nautobot/project-static/docs/user-guide/administration/configuration/authentication/remote.html +461 -17
  474. nautobot/project-static/docs/user-guide/administration/configuration/authentication/sso.html +461 -17
  475. nautobot/project-static/docs/user-guide/administration/configuration/index.html +473 -30
  476. nautobot/project-static/docs/user-guide/administration/configuration/redis.html +461 -17
  477. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +842 -155
  478. nautobot/project-static/docs/user-guide/administration/configuration/time-zones.html +461 -17
  479. nautobot/project-static/docs/user-guide/administration/guides/celery-queues.html +461 -17
  480. nautobot/project-static/docs/user-guide/administration/guides/docker.html +474 -27
  481. nautobot/project-static/docs/user-guide/administration/guides/health-checks.html +461 -17
  482. nautobot/project-static/docs/user-guide/administration/guides/permissions.html +461 -17
  483. nautobot/project-static/docs/user-guide/administration/guides/prometheus-metrics.html +461 -17
  484. nautobot/project-static/docs/user-guide/administration/guides/replicating-nautobot.html +461 -17
  485. nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +461 -17
  486. nautobot/project-static/docs/user-guide/administration/guides/s3-django-storage.html +463 -19
  487. nautobot/project-static/docs/user-guide/administration/guides/selinux-troubleshooting.html +461 -17
  488. nautobot/project-static/docs/user-guide/administration/installation/app-install.html +461 -17
  489. nautobot/project-static/docs/user-guide/administration/installation/external-authentication.html +461 -17
  490. nautobot/project-static/docs/user-guide/administration/installation/http-server.html +481 -21
  491. nautobot/project-static/docs/user-guide/administration/installation/index.html +466 -18
  492. nautobot/project-static/docs/user-guide/administration/installation/install_system.html +462 -18
  493. nautobot/project-static/docs/user-guide/administration/installation/nautobot.html +461 -17
  494. nautobot/project-static/docs/user-guide/administration/installation/services.html +461 -17
  495. nautobot/project-static/docs/user-guide/administration/migration/migrating-from-netbox.html +461 -17
  496. nautobot/project-static/docs/user-guide/administration/migration/migrating-from-postgresql.html +482 -39
  497. nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +475 -64
  498. nautobot/project-static/docs/user-guide/administration/tools/nautobot-shell.html +475 -64
  499. nautobot/project-static/docs/user-guide/administration/upgrading/database-backup.html +461 -17
  500. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/after-you-upgrade.html +461 -17
  501. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/before-you-upgrade.html +461 -17
  502. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/for-developers.html +461 -17
  503. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/index.html +461 -17
  504. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/whats-changed.html +464 -21
  505. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/region-and-site-data-migration-guide.html +461 -17
  506. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/tables/v2-code-nautobot-app-location.yaml +0 -16
  507. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +461 -17
  508. nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +467 -19
  509. nautobot/project-static/docs/user-guide/core-data-model/circuits/circuit.html +461 -17
  510. nautobot/project-static/docs/user-guide/core-data-model/circuits/circuittermination.html +461 -17
  511. nautobot/project-static/docs/user-guide/core-data-model/circuits/circuittype.html +461 -17
  512. nautobot/project-static/docs/user-guide/core-data-model/circuits/provider.html +461 -17
  513. nautobot/project-static/docs/user-guide/core-data-model/circuits/providernetwork.html +461 -17
  514. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloud.html +461 -17
  515. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudaccount.html +461 -17
  516. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudnetwork.html +461 -17
  517. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudnetworkprefixassignment.html +461 -17
  518. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudresourcetype.html +461 -17
  519. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudservice.html +461 -17
  520. nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudservicenetworkassignment.html +461 -17
  521. nautobot/project-static/docs/user-guide/core-data-model/dcim/cable.html +461 -17
  522. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleport.html +461 -17
  523. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleporttemplate.html +461 -17
  524. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverport.html +461 -17
  525. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverporttemplate.html +461 -17
  526. nautobot/project-static/docs/user-guide/core-data-model/dcim/controller.html +497 -18
  527. nautobot/project-static/docs/user-guide/core-data-model/dcim/controllermanageddevicegroup.html +487 -20
  528. nautobot/project-static/docs/user-guide/core-data-model/dcim/device.html +461 -17
  529. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebay.html +461 -17
  530. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebaytemplate.html +461 -17
  531. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicefamily.html +461 -17
  532. nautobot/project-static/docs/user-guide/core-data-model/dcim/deviceredundancygroup.html +461 -17
  533. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicetype.html +461 -17
  534. nautobot/project-static/docs/user-guide/core-data-model/dcim/frontport.html +461 -17
  535. nautobot/project-static/docs/user-guide/core-data-model/dcim/frontporttemplate.html +461 -17
  536. nautobot/project-static/docs/user-guide/core-data-model/dcim/interface.html +461 -17
  537. nautobot/project-static/docs/user-guide/core-data-model/dcim/interfaceredundancygroup.html +461 -17
  538. nautobot/project-static/docs/user-guide/core-data-model/dcim/interfacetemplate.html +461 -17
  539. nautobot/project-static/docs/user-guide/core-data-model/dcim/inventoryitem.html +461 -17
  540. nautobot/project-static/docs/user-guide/core-data-model/dcim/location.html +461 -17
  541. nautobot/project-static/docs/user-guide/core-data-model/dcim/locationtype.html +461 -17
  542. nautobot/project-static/docs/user-guide/core-data-model/dcim/manufacturer.html +461 -17
  543. nautobot/project-static/docs/user-guide/core-data-model/dcim/module.html +461 -17
  544. nautobot/project-static/docs/user-guide/core-data-model/dcim/modulebay.html +461 -17
  545. nautobot/project-static/docs/user-guide/core-data-model/dcim/modulebaytemplate.html +461 -17
  546. nautobot/project-static/docs/user-guide/core-data-model/dcim/moduletype.html +461 -17
  547. nautobot/project-static/docs/user-guide/core-data-model/dcim/platform.html +461 -17
  548. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerfeed.html +461 -17
  549. nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlet.html +461 -17
  550. nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlettemplate.html +461 -17
  551. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerpanel.html +461 -17
  552. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerport.html +461 -17
  553. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerporttemplate.html +461 -17
  554. nautobot/project-static/docs/user-guide/core-data-model/dcim/rack.html +461 -17
  555. nautobot/project-static/docs/user-guide/core-data-model/dcim/rackgroup.html +461 -17
  556. nautobot/project-static/docs/user-guide/core-data-model/dcim/rackreservation.html +461 -17
  557. nautobot/project-static/docs/user-guide/core-data-model/dcim/rearport.html +461 -17
  558. nautobot/project-static/docs/user-guide/core-data-model/dcim/rearporttemplate.html +461 -17
  559. nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +461 -17
  560. nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareversion.html +461 -17
  561. nautobot/project-static/docs/user-guide/core-data-model/dcim/virtualchassis.html +461 -17
  562. nautobot/project-static/docs/user-guide/core-data-model/dcim/virtualdevicecontext.html +9375 -0
  563. nautobot/project-static/docs/user-guide/core-data-model/extras/configcontext.html +468 -28
  564. nautobot/project-static/docs/user-guide/core-data-model/extras/configcontextschema.html +461 -17
  565. nautobot/project-static/docs/user-guide/core-data-model/extras/contact.html +461 -17
  566. nautobot/project-static/docs/user-guide/core-data-model/extras/team.html +461 -17
  567. nautobot/project-static/docs/user-guide/core-data-model/ipam/ipaddress.html +461 -17
  568. nautobot/project-static/docs/user-guide/core-data-model/ipam/namespace.html +461 -17
  569. nautobot/project-static/docs/user-guide/core-data-model/ipam/prefix.html +461 -17
  570. nautobot/project-static/docs/user-guide/core-data-model/ipam/rir.html +461 -17
  571. nautobot/project-static/docs/user-guide/core-data-model/ipam/routetarget.html +461 -17
  572. nautobot/project-static/docs/user-guide/core-data-model/ipam/service.html +461 -17
  573. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +461 -17
  574. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlangroup.html +461 -17
  575. nautobot/project-static/docs/user-guide/core-data-model/ipam/vrf.html +461 -17
  576. nautobot/project-static/docs/user-guide/core-data-model/overview/introduction.html +464 -20
  577. nautobot/project-static/docs/user-guide/core-data-model/tenancy/tenant.html +461 -17
  578. nautobot/project-static/docs/user-guide/core-data-model/tenancy/tenantgroup.html +461 -17
  579. nautobot/project-static/docs/user-guide/core-data-model/virtualization/cluster.html +461 -17
  580. nautobot/project-static/docs/user-guide/core-data-model/virtualization/clustergroup.html +461 -17
  581. nautobot/project-static/docs/user-guide/core-data-model/virtualization/clustertype.html +461 -17
  582. nautobot/project-static/docs/user-guide/core-data-model/virtualization/virtualmachine.html +461 -17
  583. nautobot/project-static/docs/user-guide/core-data-model/virtualization/vminterface.html +464 -20
  584. nautobot/project-static/docs/user-guide/core-data-model/wireless/index.html +9313 -0
  585. nautobot/project-static/docs/user-guide/core-data-model/wireless/radioprofile.html +9217 -0
  586. nautobot/project-static/docs/user-guide/core-data-model/wireless/supporteddatarate.html +9211 -0
  587. nautobot/project-static/docs/user-guide/core-data-model/wireless/wirelessnetwork.html +9277 -0
  588. nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +461 -17
  589. nautobot/project-static/docs/user-guide/feature-guides/custom-fields.html +461 -17
  590. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +461 -17
  591. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +461 -17
  592. nautobot/project-static/docs/user-guide/feature-guides/getting-started/index.html +461 -17
  593. nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +461 -17
  594. nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +461 -17
  595. nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +461 -17
  596. nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +461 -17
  597. nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +461 -17
  598. nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +461 -17
  599. nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +466 -20
  600. nautobot/project-static/docs/user-guide/feature-guides/graphql.html +461 -17
  601. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/central-mode.png +0 -0
  602. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/device-group-add.png +0 -0
  603. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/device-group-create-1.png +0 -0
  604. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/device-group-create-2.png +0 -0
  605. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/radio-profile-add.png +0 -0
  606. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/radio-profile-create.png +0 -0
  607. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/supported-data-rate-add.png +0 -0
  608. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/supported-data-rate-create.png +0 -0
  609. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/wireless-controller-add.png +0 -0
  610. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/wireless-controller-create-1.png +0 -0
  611. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/wireless-controller-create-2.png +0 -0
  612. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/wireless-network-add.png +0 -0
  613. nautobot/project-static/docs/user-guide/feature-guides/images/wireless/wireless-network-create.png +0 -0
  614. nautobot/project-static/docs/user-guide/feature-guides/ip-address-merge-tool.html +461 -17
  615. nautobot/project-static/docs/user-guide/feature-guides/relationships.html +461 -17
  616. nautobot/project-static/docs/user-guide/feature-guides/software-image-files-and-versions.html +464 -20
  617. nautobot/project-static/docs/user-guide/feature-guides/wireless-networks-and-controllers.html +9444 -0
  618. nautobot/project-static/docs/user-guide/index.html +461 -17
  619. nautobot/project-static/docs/user-guide/platform-functionality/change-logging.html +464 -20
  620. nautobot/project-static/docs/user-guide/platform-functionality/computedfield.html +465 -21
  621. nautobot/project-static/docs/user-guide/platform-functionality/customfield.html +461 -17
  622. nautobot/project-static/docs/user-guide/platform-functionality/customlink.html +461 -17
  623. nautobot/project-static/docs/user-guide/platform-functionality/dynamicgroup.html +461 -17
  624. nautobot/project-static/docs/user-guide/platform-functionality/events.html +9617 -0
  625. nautobot/project-static/docs/user-guide/platform-functionality/exporttemplate.html +464 -20
  626. nautobot/project-static/docs/user-guide/platform-functionality/externalintegration.html +461 -17
  627. nautobot/project-static/docs/user-guide/platform-functionality/gitrepository.html +461 -17
  628. nautobot/project-static/docs/user-guide/platform-functionality/graphql.html +461 -17
  629. nautobot/project-static/docs/user-guide/platform-functionality/graphqlquery.html +461 -17
  630. nautobot/project-static/docs/user-guide/platform-functionality/imageattachment.html +461 -17
  631. nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +470 -21
  632. nautobot/project-static/docs/user-guide/platform-functionality/jobs/job-scheduling-and-approvals.html +464 -20
  633. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobbutton.html +464 -20
  634. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobhook.html +461 -17
  635. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobqueue.html +9224 -0
  636. nautobot/project-static/docs/user-guide/platform-functionality/jobs/kubernetes-job-support.html +9722 -0
  637. nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +464 -20
  638. nautobot/project-static/docs/user-guide/platform-functionality/napalm.html +461 -17
  639. nautobot/project-static/docs/user-guide/platform-functionality/note.html +461 -17
  640. nautobot/project-static/docs/user-guide/platform-functionality/objectmetadata.html +461 -17
  641. nautobot/project-static/docs/user-guide/platform-functionality/relationship.html +465 -21
  642. nautobot/project-static/docs/user-guide/platform-functionality/rendering-jinja-templates.html +9292 -0
  643. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/authentication.html +461 -17
  644. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/filtering.html +509 -38
  645. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/overview.html +492 -21
  646. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/ui-related-endpoints.html +461 -17
  647. nautobot/project-static/docs/user-guide/platform-functionality/role.html +461 -17
  648. nautobot/project-static/docs/user-guide/platform-functionality/savedview.html +461 -17
  649. nautobot/project-static/docs/user-guide/platform-functionality/secret.html +461 -17
  650. nautobot/project-static/docs/user-guide/platform-functionality/staticgroupassociation.html +464 -20
  651. nautobot/project-static/docs/user-guide/platform-functionality/status.html +461 -17
  652. nautobot/project-static/docs/user-guide/platform-functionality/tag.html +461 -17
  653. nautobot/project-static/docs/user-guide/platform-functionality/template-filters.html +529 -54
  654. nautobot/project-static/docs/user-guide/platform-functionality/users/objectpermission.html +461 -17
  655. nautobot/project-static/docs/user-guide/platform-functionality/users/token.html +461 -17
  656. nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +461 -17
  657. nautobot/project-static/img/jinja_logo.svg +97 -0
  658. nautobot/project-static/js/forms.js +6 -1
  659. nautobot/project-static/js/nav_menu.js +2 -1
  660. nautobot/tenancy/api/serializers.py +0 -2
  661. nautobot/tenancy/api/views.py +9 -13
  662. nautobot/tenancy/factory.py +1 -1
  663. nautobot/tenancy/navigation.py +0 -29
  664. nautobot/tenancy/templates/tenancy/tenant.html +4 -91
  665. nautobot/tenancy/tests/test_filters.py +29 -134
  666. nautobot/tenancy/views.py +35 -24
  667. nautobot/users/admin.py +2 -0
  668. nautobot/users/api/views.py +2 -2
  669. nautobot/users/forms.py +19 -0
  670. nautobot/users/templates/users/preferences.html +22 -0
  671. nautobot/users/tests/test_filters.py +1 -19
  672. nautobot/users/tests/test_views.py +57 -0
  673. nautobot/users/utils.py +8 -0
  674. nautobot/users/views.py +48 -11
  675. nautobot/virtualization/api/views.py +5 -24
  676. nautobot/virtualization/filters.py +1 -2
  677. nautobot/virtualization/models.py +1 -1
  678. nautobot/virtualization/navigation.py +0 -48
  679. nautobot/virtualization/tables.py +2 -2
  680. nautobot/virtualization/templates/virtualization/cluster_edit.html +1 -7
  681. nautobot/virtualization/templates/virtualization/clustertype.html +0 -39
  682. nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -9
  683. nautobot/virtualization/templates/virtualization/virtualmachine_edit.html +2 -8
  684. nautobot/virtualization/tests/test_filters.py +57 -166
  685. nautobot/virtualization/views.py +18 -15
  686. nautobot/wireless/__init__.py +0 -0
  687. nautobot/wireless/api/__init__.py +0 -0
  688. nautobot/wireless/api/serializers.py +44 -0
  689. nautobot/wireless/api/urls.py +20 -0
  690. nautobot/wireless/api/views.py +34 -0
  691. nautobot/wireless/apps.py +8 -0
  692. nautobot/wireless/choices.py +345 -0
  693. nautobot/wireless/factory.py +138 -0
  694. nautobot/wireless/filters.py +167 -0
  695. nautobot/wireless/forms.py +283 -0
  696. nautobot/wireless/homepage.py +19 -0
  697. nautobot/wireless/migrations/0001_initial.py +223 -0
  698. nautobot/wireless/migrations/__init__.py +0 -0
  699. nautobot/wireless/models.py +207 -0
  700. nautobot/wireless/navigation.py +105 -0
  701. nautobot/wireless/tables.py +244 -0
  702. nautobot/wireless/templates/wireless/radioprofile_retrieve.html +81 -0
  703. nautobot/wireless/templates/wireless/supporteddatarate_retrieve.html +26 -0
  704. nautobot/wireless/templates/wireless/wirelessnetwork_create.html +88 -0
  705. nautobot/wireless/templates/wireless/wirelessnetwork_retrieve.html +56 -0
  706. nautobot/wireless/tests/__init__.py +0 -0
  707. nautobot/wireless/tests/integration/__init__.py +0 -0
  708. nautobot/wireless/tests/integration/test_radio_profile.py +42 -0
  709. nautobot/wireless/tests/test_api.py +247 -0
  710. nautobot/wireless/tests/test_filters.py +82 -0
  711. nautobot/wireless/tests/test_models.py +22 -0
  712. nautobot/wireless/tests/test_views.py +378 -0
  713. nautobot/wireless/urls.py +13 -0
  714. nautobot/wireless/views.py +119 -0
  715. {nautobot-2.3.16.dist-info → nautobot-2.4.0.dist-info}/METADATA +9 -12
  716. {nautobot-2.3.16.dist-info → nautobot-2.4.0.dist-info}/RECORD +720 -549
  717. nautobot/core/utils/navigation.py +0 -54
  718. {nautobot-2.3.16.dist-info → nautobot-2.4.0.dist-info}/LICENSE.txt +0 -0
  719. {nautobot-2.3.16.dist-info → nautobot-2.4.0.dist-info}/NOTICE +0 -0
  720. {nautobot-2.3.16.dist-info → nautobot-2.4.0.dist-info}/WHEEL +0 -0
  721. {nautobot-2.3.16.dist-info → nautobot-2.4.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1855 @@
1
+ """Classes and utilities for defining an object detail view through a NautobotUIViewSet."""
2
+
3
+ import contextlib
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ import logging
7
+
8
+ from django.contrib.contenttypes.models import ContentType
9
+ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
10
+ from django.db import models
11
+ from django.db.models import CharField, JSONField, URLField
12
+ from django.db.models.fields.related import ManyToManyField
13
+ from django.template import Context
14
+ from django.template.defaultfilters import truncatechars
15
+ from django.template.loader import render_to_string
16
+ from django.templatetags.l10n import localize
17
+ from django.urls import NoReverseMatch, reverse
18
+ from django.utils.html import format_html, format_html_join
19
+ from django_tables2 import RequestConfig
20
+
21
+ from nautobot.core.choices import ButtonColorChoices
22
+ from nautobot.core.models.tree_queries import TreeModel
23
+ from nautobot.core.templatetags.helpers import (
24
+ badge,
25
+ bettertitle,
26
+ HTML_NONE,
27
+ hyperlinked_field,
28
+ hyperlinked_object,
29
+ hyperlinked_object_with_color,
30
+ placeholder,
31
+ render_ancestor_hierarchy,
32
+ render_boolean,
33
+ render_content_types,
34
+ render_json,
35
+ render_markdown,
36
+ slugify,
37
+ validated_viewname,
38
+ )
39
+ from nautobot.core.ui.choices import LayoutChoices, SectionChoices
40
+ from nautobot.core.ui.utils import render_component_template
41
+ from nautobot.core.utils.lookup import get_filterset_for_model, get_route_for_model
42
+ from nautobot.core.utils.permissions import get_permission_for_model
43
+ from nautobot.core.views.paginator import EnhancedPaginator, get_paginate_count
44
+ from nautobot.core.views.utils import get_obj_from_context
45
+ from nautobot.extras.choices import CustomFieldTypeChoices
46
+ from nautobot.extras.tables import AssociatedContactsTable, DynamicGroupTable, ObjectMetadataTable
47
+ from nautobot.tenancy.models import Tenant
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ class ObjectDetailContent:
53
+ """
54
+ Base class for UI framework definition of the contents of an Object Detail (Object Retrieve) page.
55
+
56
+ This currently defines the tabs and their panel contents, but does NOT describe the page title, breadcrumbs, etc.
57
+
58
+ Basic usage for a `NautobotUIViewSet` looks like:
59
+
60
+ ```py
61
+ from nautobot.apps.ui import ObjectDetailContent, ObjectFieldsPanel, SectionChoices
62
+
63
+
64
+ class MyModelUIViewSet(NautobotUIViewSet):
65
+ queryset = MyModel.objects.all()
66
+ object_detail_content = ObjectDetailContent(
67
+ panels=[ObjectFieldsPanel(section=SectionChoices.LEFT_HALF, weight=100, fields="__all__")],
68
+ )
69
+ ```
70
+
71
+ A legacy `ObjectView` can similarly define its own `object_detail_content` attribute as well.
72
+ """
73
+
74
+ def __init__(self, *, panels=(), layout=LayoutChoices.DEFAULT, extra_buttons=None, extra_tabs=None):
75
+ """
76
+ Create an ObjectDetailContent with a "main" tab and all standard "extras" tabs (advanced, contacts, etc.).
77
+
78
+ Args:
79
+ panels (list): List of `Panel` instances to include in this layout by default. Standard `extras` Panels
80
+ (custom fields, relationships, etc.) do not need to be specified as they will be automatically included.
81
+ layout (str): One of the `LayoutChoices` values, indicating the layout of the "main" tab for this view.
82
+ extra_buttons (list): Optional list of `Button` instances. Standard detail-view "actions" dropdown
83
+ (clone, edit, delete) does not need to be specified as it will be automatically included.
84
+ extra_tabs (list): Optional list of `Tab` instances. Standard `extras` Tabs (advanced, contacts,
85
+ dynamic-groups, metadata, etc.) do not need to be specified as they will be automatically included.
86
+ """
87
+ tabs = [
88
+ _ObjectDetailMainTab(
89
+ layout=layout,
90
+ panels=panels,
91
+ ),
92
+ # Inject "standard" extra tabs
93
+ _ObjectDetailAdvancedTab(),
94
+ _ObjectDetailContactsTab(),
95
+ _ObjectDetailGroupsTab(),
96
+ _ObjectDetailMetadataTab(),
97
+ ]
98
+ if extra_tabs is not None:
99
+ tabs.extend(extra_tabs)
100
+ self.extra_buttons = extra_buttons or []
101
+ self.tabs = tabs
102
+
103
+ @property
104
+ def extra_buttons(self):
105
+ """The extra buttons defined for this detail view, ordered by their `weight`."""
106
+ return sorted(self._extra_buttons, key=lambda button: button.weight)
107
+
108
+ @extra_buttons.setter
109
+ def extra_buttons(self, value):
110
+ self._extra_buttons = value
111
+
112
+ @property
113
+ def tabs(self):
114
+ """The tabs defined for this detail view, ordered by their `weight`."""
115
+ return sorted(self._tabs, key=lambda tab: tab.weight)
116
+
117
+ @tabs.setter
118
+ def tabs(self, value):
119
+ self._tabs = value
120
+
121
+
122
+ class Component:
123
+ """Common base class for renderable components (tabs, panels, etc.)."""
124
+
125
+ def __init__(self, *, weight):
126
+ """Initialize common Component properties.
127
+
128
+ Args:
129
+ weight (int): A relative weighting of this Component relative to its peers. Typically lower weights will be
130
+ rendered "first", usually towards the top left of the page.
131
+ """
132
+ self.weight = weight
133
+
134
+ def should_render(self, context: dict):
135
+ """
136
+ Check whether this component should be rendered at all.
137
+
138
+ This API is designed to provide "short-circuit" logic for skipping what otherwise might be expensive rendering.
139
+ In general most Components may also return an empty string when actually rendered, which is typically also a
140
+ means to specify that they do not need to be rendered, but may be more expensive to derive.
141
+
142
+ Returns:
143
+ (bool): `True` (default) if this component should be rendered.
144
+ """
145
+ return True
146
+
147
+ def render(self, context: Context):
148
+ """
149
+ Render this component to HTML.
150
+
151
+ Note that not all Components are fully or solely rendered by their `render()` method alone, for example,
152
+ a Tab has a separate "label" that must be rendered by calling its `render_label_wrapper()` API instead.
153
+
154
+ Returns:
155
+ (str): HTML fragment, normally generated by a call(s) to `format_html()` or `format_html_join()`.
156
+ """
157
+ return ""
158
+
159
+ def get_extra_context(self, context: Context):
160
+ """
161
+ Provide additional data to include in the rendering context, based on the configuration of this component.
162
+
163
+ Returns:
164
+ (dict): Additional context data.
165
+ """
166
+ return {}
167
+
168
+
169
+ class Button(Component):
170
+ """Base class for UI framework definition of a single button within an Object Detail (Object Retrieve) page."""
171
+
172
+ def __init__(
173
+ self,
174
+ *,
175
+ label,
176
+ color=ButtonColorChoices.DEFAULT,
177
+ link_name=None,
178
+ icon=None,
179
+ template_path="components/button/default.html",
180
+ required_permissions=None,
181
+ javascript_template_path=None,
182
+ attributes=None,
183
+ **kwargs,
184
+ ):
185
+ """
186
+ Initialize a Button component.
187
+
188
+ Args:
189
+ label (str): The text of this button, not including any icon.
190
+ color (ButtonColorChoices): The color (class) of this button.
191
+ link_name (str, optional): View name to link to, for example "dcim:locationtype_retrieve".
192
+ This link will be reversed and will automatically include the current object's PK as a parameter to the
193
+ `reverse()` call when the button is rendered. For more complex link construction, you can subclass this
194
+ and override the `get_link()` method.
195
+ icon (str, optional): Material Design Icons icon, to include on the button, for example `"mdi-plus-bold"`.
196
+ template_path (str): Template to render for this button.
197
+ required_permissions (list, optional): Permissions such as `["dcim.add_consoleport"]`.
198
+ The button will only be rendered if the user has these permissions.
199
+ javascript_template_path (str, optional): JavaScript template to render and include with this button.
200
+ Does not need to include the wrapping `<script>...</script>` tags as those will be added automatically.
201
+ attributes (dict, optional): Additional HTML attributes and their values to attach to the button.
202
+ """
203
+ self.label = label
204
+ self.color = color
205
+ self.link_name = link_name
206
+ self.icon = icon
207
+ self.template_path = template_path
208
+ self.required_permissions = required_permissions or []
209
+ self.javascript_template_path = javascript_template_path
210
+ self.attributes = attributes
211
+ super().__init__(**kwargs)
212
+
213
+ def should_render(self, context: Context):
214
+ """Render if and only if the requesting user has appropriate permissions (if any)."""
215
+ return context["request"].user.has_perms(self.required_permissions)
216
+
217
+ def get_link(self, context: Context):
218
+ """
219
+ Get the hyperlink URL (if any) for this button.
220
+
221
+ Defaults to reversing `self.link_name` with `pk: obj.pk` as a kwarg, but subclasses may override this for
222
+ more advanced link construction.
223
+ """
224
+ if self.link_name:
225
+ obj = get_obj_from_context(context)
226
+ return reverse(self.link_name, kwargs={"pk": obj.pk})
227
+ return None
228
+
229
+ def get_extra_context(self, context: Context):
230
+ """Add the relevant attributes of this Button to the context."""
231
+ return {
232
+ "link": self.get_link(context),
233
+ "label": self.label,
234
+ "color": self.color,
235
+ "icon": self.icon,
236
+ "attributes": self.attributes,
237
+ }
238
+
239
+ def render(self, context: Context):
240
+ """Render this button to HTML, possibly including any associated JavaScript."""
241
+ if not self.should_render(context):
242
+ return ""
243
+
244
+ button = render_component_template(self.template_path, context, **self.get_extra_context(context))
245
+ if self.javascript_template_path:
246
+ button += format_html(
247
+ "<script>{}</script>", render_component_template(self.javascript_template_path, context)
248
+ )
249
+ return button
250
+
251
+
252
+ class DropdownButton(Button):
253
+ """A Button that has one or more other buttons as `children`, which it renders into a dropdown menu."""
254
+
255
+ def __init__(self, children: list[Button], template_path="components/button/dropdown.html", **kwargs):
256
+ """Initialize a DropdownButton component.
257
+
258
+ Args:
259
+ children (list[Button]): Elements of the dropdown menu associated to this DropdownButton.
260
+ template_path (str): Dropdown-specific template file.
261
+ """
262
+ self.children = children
263
+ super().__init__(template_path=template_path, **kwargs)
264
+
265
+ def get_extra_context(self, context: Context):
266
+ """Add the children of this DropdownButton to the other Button context."""
267
+ return {
268
+ **super().get_extra_context(context),
269
+ "children": [child.get_extra_context(context) for child in self.children if child.should_render(context)],
270
+ }
271
+
272
+
273
+ class Tab(Component):
274
+ """Base class for UI framework definition of a single tabbed pane within an Object Detail (Object Retrieve) page."""
275
+
276
+ def __init__(
277
+ self,
278
+ *,
279
+ tab_id,
280
+ label,
281
+ panels=(),
282
+ layout=LayoutChoices.DEFAULT,
283
+ label_wrapper_template_path="components/tab/label_wrapper.html",
284
+ content_wrapper_template_path="components/tab/content_wrapper.html",
285
+ **kwargs,
286
+ ):
287
+ """Initialize a Tab component.
288
+
289
+ Args:
290
+ tab_id (str): HTML ID for the tab content element, used to link the tab label and its content together.
291
+ label (str): User-facing label to display for this tab.
292
+ panels (tuple): Set of `Panel` components to potentially display within this tab.
293
+ layout (str): One of the [LayoutChoices](./ui.md#nautobot.apps.ui.LayoutChoices) values, describing the layout of panels within this tab.
294
+ label_wrapper_template_path (str): Template path to use for rendering the tab label to HTML.
295
+ content_wrapper_template_path (str): Template path to use for rendering the tab contents to HTML.
296
+ """
297
+ self.tab_id = tab_id
298
+ self.label = label
299
+ self.panels = panels
300
+ self.layout = layout
301
+ self.label_wrapper_template_path = label_wrapper_template_path
302
+ self.content_wrapper_template_path = content_wrapper_template_path
303
+ super().__init__(**kwargs)
304
+
305
+ LAYOUT_TEMPLATE_PATHS = {
306
+ LayoutChoices.TWO_OVER_ONE: "components/layout/two_over_one.html",
307
+ LayoutChoices.ONE_OVER_TWO: "components/layout/one_over_two.html",
308
+ }
309
+
310
+ WEIGHT_MAIN_TAB = 100
311
+ WEIGHT_ADVANCED_TAB = 200
312
+ WEIGHT_CONTACTS_TAB = 300
313
+ WEIGHT_GROUPS_TAB = 400
314
+ WEIGHT_METADATA_TAB = 500
315
+ WEIGHT_NOTES_TAB = 600 # reserved, not yet using this framework
316
+ WEIGHT_CHANGELOG_TAB = 700 # reserved, not yet using this framework
317
+
318
+ def panels_for_section(self, section):
319
+ """
320
+ Get the subset of this tab's panels that apply to the given layout section, ordered by their `weight`.
321
+
322
+ Args:
323
+ section (str): One of `SectionChoices`.
324
+
325
+ Returns:
326
+ (list[Panel]): Sorted list of Panel instances.
327
+ """
328
+ return sorted((panel for panel in self.panels if panel.section == section), key=lambda panel: panel.weight)
329
+
330
+ def render_label_wrapper(self, context: Context):
331
+ """
332
+ Render the tab's label (as opposed to its contents) and wrapping HTML elements.
333
+
334
+ In most cases you should not need to override this method; override `render_label()` instead.
335
+ """
336
+ if not self.should_render(context):
337
+ return ""
338
+
339
+ return render_component_template(
340
+ self.label_wrapper_template_path,
341
+ context,
342
+ tab_id=self.tab_id,
343
+ label=self.render_label(context),
344
+ **self.get_extra_context(context),
345
+ )
346
+
347
+ def render_label(self, context: Context):
348
+ """
349
+ Render the tab's label text in a form suitable for display to the user.
350
+
351
+ Defaults to just returning `self.label`, but may be overridden if context-specific formatting is needed.
352
+ """
353
+ return self.label
354
+
355
+ def render(self, context: Context):
356
+ """Render the tab's contents (layout and panels) to HTML."""
357
+ if not self.should_render(context):
358
+ return ""
359
+
360
+ with context.update(
361
+ {
362
+ "tab_id": self.tab_id,
363
+ "label": self.render_label(context),
364
+ "include_plugin_content": self.tab_id == "main",
365
+ "left_half_panels": self.panels_for_section(SectionChoices.LEFT_HALF),
366
+ "right_half_panels": self.panels_for_section(SectionChoices.RIGHT_HALF),
367
+ "full_width_panels": self.panels_for_section(SectionChoices.FULL_WIDTH),
368
+ **self.get_extra_context(context),
369
+ }
370
+ ):
371
+ tab_content = render_component_template(self.LAYOUT_TEMPLATE_PATHS[self.layout], context)
372
+ return render_component_template(self.content_wrapper_template_path, context, tab_content=tab_content)
373
+
374
+
375
+ class DistinctViewTab(Tab):
376
+ """
377
+ A Tab that doesn't render inline on the same page, but instead links to a distinct view of its own when clicked.
378
+ """
379
+
380
+ def __init__(
381
+ self,
382
+ *,
383
+ url_name,
384
+ label_wrapper_template_path="components/tab/label_wrapper_distinct_view.html",
385
+ **kwargs,
386
+ ):
387
+ self.url_name = url_name
388
+ super().__init__(label_wrapper_template_path=label_wrapper_template_path, **kwargs)
389
+
390
+ def get_extra_context(self, context: Context):
391
+ return {"url": reverse(self.url_name, kwargs={"pk": get_obj_from_context(context).pk})}
392
+
393
+ def render(self, context: Context):
394
+ return ""
395
+
396
+
397
+ class Panel(Component):
398
+ """Base class for defining an individual display panel within a Layout within a Tab."""
399
+
400
+ WEIGHT_COMMENTS_PANEL = 200
401
+ WEIGHT_CUSTOM_FIELDS_PANEL = 300
402
+ WEIGHT_COMPUTED_FIELDS_PANEL = 400
403
+ WEIGHT_RELATIONSHIPS_PANEL = 500
404
+ WEIGHT_TAGS_PANEL = 600
405
+
406
+ def __init__(
407
+ self,
408
+ *,
409
+ label="",
410
+ section=SectionChoices.FULL_WIDTH,
411
+ body_id=None,
412
+ body_content_template_path=None,
413
+ header_extra_content_template_path=None,
414
+ footer_content_template_path=None,
415
+ template_path="components/panel/panel.html",
416
+ body_wrapper_template_path="components/panel/body_wrapper_generic.html",
417
+ **kwargs,
418
+ ):
419
+ """
420
+ Initialize a Panel component that can be rendered as a self-contained HTML fragment.
421
+
422
+ Args:
423
+ label (str): Label to display for this panel. Optional; if an empty string, the panel will have no label.
424
+ section (str): One of the [`SectionChoices`](./ui.md#nautobot.apps.ui.SectionChoices) values, indicating the layout section this Panel belongs to.
425
+ body_id (str): HTML element `id` to attach to the rendered body wrapper of the panel.
426
+ body_content_template_path (str): Template path to render the content contained *within* the panel body.
427
+ header_extra_content_template_path (str): Template path to render extra content into the panel header,
428
+ if any, not including its label if any.
429
+ footer_content_template_path (str): Template path to render content into the panel footer, if any.
430
+ template_path (str): Template path to render the Panel as a whole. Generally you won't override this.
431
+ body_wrapper_template_path (str): Template path to render the panel body, including both its "wrapper"
432
+ (a `div` or `table`) as well as its contents. Generally you won't override this as a user.
433
+ """
434
+ self.label = label
435
+ self.section = section
436
+ self.body_id = body_id
437
+ self.body_content_template_path = body_content_template_path
438
+ self.header_extra_content_template_path = header_extra_content_template_path
439
+ self.footer_content_template_path = footer_content_template_path
440
+ self.template_path = template_path
441
+ self.body_wrapper_template_path = body_wrapper_template_path
442
+ super().__init__(**kwargs)
443
+
444
+ def render(self, context: Context):
445
+ """
446
+ Render the panel as a whole.
447
+
448
+ Default implementation calls `render_label()`, `render_header_extra_content()`, `render_body()`,
449
+ and `render_footer_extra_content()`, then wraps them all into the templated defined by `self.template_path`.
450
+
451
+ Typically you'll override one or more of the aforementioned methods in a subclass, rather than replacing this
452
+ entire method as a whole.
453
+ """
454
+ if not self.should_render(context):
455
+ return ""
456
+ with context.update(self.get_extra_context(context)):
457
+ return render_component_template(
458
+ self.template_path,
459
+ context,
460
+ label=self.render_label(context),
461
+ header_extra_content=self.render_header_extra_content(context),
462
+ body=self.render_body(context),
463
+ footer_content=self.render_footer_content(context),
464
+ )
465
+
466
+ def render_label(self, context: Context):
467
+ """Render the label of this panel, if any."""
468
+ return self.label
469
+
470
+ def render_header_extra_content(self, context: Context):
471
+ """
472
+ Render any additional (non-label) content to include in this panel's header.
473
+
474
+ Default implementation renders the template from `self.header_extra_content_template_path` if any.
475
+ """
476
+ if self.header_extra_content_template_path:
477
+ return render_component_template(self.header_extra_content_template_path, context)
478
+ return ""
479
+
480
+ def render_body(self, context: Context):
481
+ """
482
+ Render the panel body *including its HTML wrapper element(s)*.
483
+
484
+ Default implementation calls `render_body_content()` and wraps that in the template defined at
485
+ `self.body_wrapper_template_path`.
486
+
487
+ Normally you won't want to override this method in a subclass, instead overriding `render_body_content()`.
488
+ """
489
+ return render_component_template(
490
+ self.body_wrapper_template_path,
491
+ context,
492
+ body_id=self.body_id,
493
+ body_content=self.render_body_content(context),
494
+ )
495
+
496
+ def render_body_content(self, context: Context):
497
+ """
498
+ Render the content to include in this panel's body.
499
+
500
+ Default implementation renders the template from `self.body_content_template_path` if any.
501
+ """
502
+ if self.body_content_template_path:
503
+ return render_component_template(self.body_content_template_path, context)
504
+ return ""
505
+
506
+ def render_footer_content(self, context: Context):
507
+ """
508
+ Render any non-default content to include in this panel's footer.
509
+
510
+ Default implementation renders the template from `self.footer_content_template_path` if any.
511
+ """
512
+ if self.footer_content_template_path:
513
+ return render_component_template(self.footer_content_template_path, context)
514
+ return ""
515
+
516
+
517
+ class DataTablePanel(Panel):
518
+ """
519
+ A panel that renders a table generated directly from a list of dicts, without using a django_tables2 Table class.
520
+ """
521
+
522
+ def __init__(
523
+ self,
524
+ *,
525
+ context_data_key,
526
+ columns=None,
527
+ context_columns_key=None,
528
+ column_headers=None,
529
+ context_column_headers_key=None,
530
+ body_wrapper_template_path="components/panel/body_wrapper_table.html",
531
+ body_content_template_path="components/panel/body_content_data_table.html",
532
+ **kwargs,
533
+ ):
534
+ """
535
+ Instantiate a DataDictTablePanel.
536
+
537
+ Args:
538
+ context_data_key (str): The key in the render context that stores the data used to populate the table.
539
+ columns (list, optional): Ordered list of data keys used to order the columns of the rendered table.
540
+ Mutually exclusive with `context_columns_key`.
541
+ If neither are specified, the keys of the first dict in the data will be used.
542
+ context_columns_key (str, optional): The key in the render context that stores the columns list, if any.
543
+ Mutually exclusive with `columns`.
544
+ If neither are specified, the keys of the first dict in the data will be used.
545
+ column_headers (list, optional): List of column header labels, in the same order as `columns` data.
546
+ Mutually exclusive with `context_column_headers_key`.
547
+ context_column_headers_key (str, optional): The key in the render context that stores the column headers.
548
+ Mutually exclusive with `column_headers`.
549
+ """
550
+ self.context_data_key = context_data_key
551
+ if columns and context_columns_key:
552
+ raise ValueError("You can only specify one of `columns` or `context_columns_key`.")
553
+ self.columns = columns
554
+ self.context_columns_key = context_columns_key
555
+ if column_headers and context_column_headers_key:
556
+ raise ValueError("You can only specify one of `column_headers` or `context_column_headers_key`.")
557
+ self.column_headers = column_headers
558
+ self.context_column_headers_key = context_column_headers_key
559
+
560
+ super().__init__(
561
+ body_wrapper_template_path=body_wrapper_template_path,
562
+ body_content_template_path=body_content_template_path,
563
+ **kwargs,
564
+ )
565
+
566
+ def get_columns(self, context: Context):
567
+ if self.columns:
568
+ return self.columns
569
+ if self.context_columns_key:
570
+ return context.get(self.context_columns_key)
571
+ return list(context.get(self.context_data_key)[0].keys())
572
+
573
+ def get_column_headers(self, context: Context):
574
+ if self.column_headers:
575
+ return self.column_headers
576
+ if self.context_column_headers_key:
577
+ return context.get(self.context_column_headers_key)
578
+ return []
579
+
580
+ def get_extra_context(self, context: Context):
581
+ return {
582
+ "data": context.get(self.context_data_key),
583
+ "columns": self.get_columns(context),
584
+ "column_headers": self.get_column_headers(context),
585
+ }
586
+
587
+
588
+ class ObjectsTablePanel(Panel):
589
+ """A panel that renders a Table of objects (typically related objects, rather than the "main" object of a view).
590
+ Has built-in pagination support and "Add" button at bottom of the Table.
591
+
592
+ It renders the django-tables2 classes with Nautobot additions. You can pass the instance of Table Class
593
+ already configured in context and set the `context_table_key` or pass a Table Class to `__init__` via `table_class`.
594
+
595
+ When `table_class` is set, you need to pass `table_filter` or `table_attribute` for fetching data purpose.
596
+
597
+ Data fetching can be optimized by using `select_related_fields`, `prefetch_related_fields`.
598
+
599
+ How Table is displayed can be changed by using `include_columns`, `exclude_columns`, `table_title`,
600
+ `hide_hierarchy_ui`, `related_field_name` or `enable_bulk_actions`.
601
+
602
+ Please check the Args list for further details.
603
+ """
604
+
605
+ def __init__(
606
+ self,
607
+ *,
608
+ context_table_key=None,
609
+ table_class=None,
610
+ table_filter=None,
611
+ table_attribute=None,
612
+ select_related_fields=None,
613
+ prefetch_related_fields=None,
614
+ order_by_fields=None,
615
+ table_title=None,
616
+ max_display_count=None,
617
+ include_columns=None,
618
+ exclude_columns=None,
619
+ add_button_route="default",
620
+ add_permissions=None,
621
+ hide_hierarchy_ui=False,
622
+ related_field_name=None,
623
+ enable_bulk_actions=False,
624
+ body_wrapper_template_path="components/panel/body_wrapper_table.html",
625
+ body_content_template_path="components/panel/body_content_objects_table.html",
626
+ header_extra_content_template_path="components/panel/header_extra_content_table.html",
627
+ footer_content_template_path="components/panel/footer_content_table.html",
628
+ **kwargs,
629
+ ):
630
+ """Instantiate an ObjectsTable panel.
631
+
632
+ Args:
633
+ context_table_key (str): The key in the render context that will contain an already-populated-and-configured
634
+ Table (`BaseTable`) instance. Mutually exclusive with `table_class`, `table_filter`, `table_attribute`.
635
+ table_class (obj): The table class that will be instantiated and rendered e.g. CircuitTable, DeviceTable.
636
+ Mutually exclusive with `context_table_key`.
637
+ table_filter (str, optional): The name of the filter to apply to the queryset to initialize the table class.
638
+ For example, in a LocationType detail view, for an ObjectsTablePanel of related Locations, this would
639
+ be `location_type`, because `Location.objects.filter(location_type=obj)` gives the desired queryset.
640
+ Mutually exclusive with `table_attribute`.
641
+ table_attribute (str, optional): The attribute of the detail view instance that contains the queryset to
642
+ initialize the table class. e.g. `dynamic_groups`.
643
+ Mutually exclusive with `table_filter`.
644
+ select_related_fields (list, optional): list of fields to pass to table queryset's `select_related` method.
645
+ prefetch_related_fields (list, optional): list of fields to pass to table queryset's `prefetch_related`
646
+ method.
647
+ order_by_fields (list, optional): list of fields to order the table queryset by.
648
+ max_display_count (int, optional): Maximum number of items to display in the table.
649
+ If None, defaults to the `get_paginate_count()` (which is user's preference or a global setting).
650
+ table_title (str, optional): The title to display in the panel heading for the table.
651
+ If None, defaults to the plural verbose name of the table model.
652
+ include_columns (list, optional): A list of field names to include in the table display.
653
+ If provided, only these fields will be displayed in the table.
654
+ exclude_columns (list, optional): A list of field names to exclude from the table display.
655
+ Mutually exclusive with `include_columns`.
656
+ add_button_route (str, optional): The route used to generate the "add" button URL. Defaults to "default",
657
+ which uses the default table's model `add` route.
658
+ add_permissions (list, optional): A list of permissions required for the "add" button to be displayed.
659
+ If not provided, permissions are determined by default based on the model.
660
+ hide_hierarchy_ui (bool, optional): Don't display hierarchy-based indentation of tree models in this table
661
+ related_field_name (str, optional): The name of the filter/form field for the related model that links back
662
+ to the base model. Defaults to the same as `table_filter` if unset. Used to populate URLs.
663
+ enable_bulk_actions (bool, optional): Show the pk toggle columns on the table if the user has the
664
+ appropriate permissions.
665
+ """
666
+ if context_table_key and any(
667
+ [
668
+ table_class,
669
+ table_filter,
670
+ table_attribute,
671
+ select_related_fields,
672
+ prefetch_related_fields,
673
+ order_by_fields,
674
+ hide_hierarchy_ui,
675
+ ]
676
+ ):
677
+ raise ValueError(
678
+ "context_table_key cannot be combined with any of the args that are used to dynamically construct the "
679
+ "table (table_class, table_filter, table_attribute, select_related_fields, prefetch_related_fields, "
680
+ "order_by_fields, hide_hierarchy_ui)."
681
+ )
682
+ self.context_table_key = context_table_key
683
+ self.table_class = table_class
684
+ if table_filter and table_attribute:
685
+ raise ValueError("You can only specify either `table_filter` or `table_attribute`")
686
+ if table_class and not (table_filter or table_attribute):
687
+ raise ValueError("You must specify either `table_filter` or `table_attribute`")
688
+ self.table_filter = table_filter
689
+ self.table_attribute = table_attribute
690
+ self.select_related_fields = select_related_fields
691
+ self.prefetch_related_fields = prefetch_related_fields
692
+ self.order_by_fields = order_by_fields
693
+ self.table_title = table_title
694
+ self.max_display_count = max_display_count
695
+ if exclude_columns and include_columns:
696
+ raise ValueError("You can only specify either `exclude_columns` or `include_columns`")
697
+ self.include_columns = include_columns
698
+ self.exclude_columns = exclude_columns
699
+ self.add_button_route = add_button_route
700
+ self.add_permissions = add_permissions
701
+ self.hide_hierarchy_ui = hide_hierarchy_ui
702
+ self.related_field_name = related_field_name
703
+ self.enable_bulk_actions = enable_bulk_actions
704
+
705
+ super().__init__(
706
+ body_wrapper_template_path=body_wrapper_template_path,
707
+ body_content_template_path=body_content_template_path,
708
+ header_extra_content_template_path=header_extra_content_template_path,
709
+ footer_content_template_path=footer_content_template_path,
710
+ **kwargs,
711
+ )
712
+
713
+ def _get_table_add_url(self, context: Context):
714
+ """Generate the URL for the "Add" button in the table panel.
715
+
716
+ This method determines the URL for adding a new object to the table. It checks if the user has
717
+ the necessary permissions and creates the appropriate URL based on the specified add button route.
718
+ """
719
+ obj = get_obj_from_context(context)
720
+ body_content_table_add_url = None
721
+ request = context["request"]
722
+ related_field_name = self.related_field_name or self.table_filter or obj._meta.model_name
723
+ return_url = context.get("return_url", obj.get_absolute_url())
724
+
725
+ if self.add_button_route == "default":
726
+ body_content_table_class = self.table_class or context[self.context_table_key].__class__
727
+ body_content_table_model = body_content_table_class.Meta.model
728
+ permission_name = get_permission_for_model(body_content_table_model, "add")
729
+ if request.user.has_perms([permission_name]):
730
+ try:
731
+ add_route = reverse(get_route_for_model(body_content_table_model, "add"))
732
+ body_content_table_add_url = f"{add_route}?{related_field_name}={obj.pk}&return_url={return_url}"
733
+ except NoReverseMatch:
734
+ logger.warning("add route for `body_content_table_model` not found")
735
+
736
+ elif self.add_button_route is not None:
737
+ if request.user.has_perms(self.add_permissions or []):
738
+ add_route = reverse(self.add_button_route)
739
+ body_content_table_add_url = f"{add_route}?{related_field_name}={obj.pk}&return_url={return_url}"
740
+
741
+ return body_content_table_add_url
742
+
743
+ def get_extra_context(self, context: Context):
744
+ """Add additional context for rendering the table panel.
745
+
746
+ This method processes the table data, configures pagination, and generates URLs
747
+ for listing and adding objects. It also handles field inclusion/exclusion and
748
+ displays the appropriate table title if provided.
749
+ """
750
+ request = context["request"]
751
+ if self.context_table_key:
752
+ body_content_table = context.get(self.context_table_key)
753
+ else:
754
+ body_content_table_class = self.table_class
755
+ body_content_table_model = body_content_table_class.Meta.model
756
+ instance = get_obj_from_context(context)
757
+
758
+ if self.table_attribute:
759
+ body_content_table_queryset = getattr(instance, self.table_attribute)
760
+ else:
761
+ body_content_table_queryset = body_content_table_model.objects.filter(**{self.table_filter: instance})
762
+
763
+ body_content_table_queryset = body_content_table_queryset.restrict(request.user, "view")
764
+ if self.select_related_fields:
765
+ body_content_table_queryset = body_content_table_queryset.select_related(*self.select_related_fields)
766
+ if self.prefetch_related_fields:
767
+ body_content_table_queryset = body_content_table_queryset.prefetch_related(
768
+ *self.prefetch_related_fields
769
+ )
770
+ if self.order_by_fields:
771
+ body_content_table_queryset = body_content_table_queryset.order_by(*self.order_by_fields)
772
+ body_content_table_queryset = body_content_table_queryset.distinct()
773
+ body_content_table = body_content_table_class(
774
+ body_content_table_queryset, hide_hierarchy_ui=self.hide_hierarchy_ui
775
+ )
776
+
777
+ if self.exclude_columns or self.include_columns:
778
+ for column in body_content_table.columns:
779
+ if (self.exclude_columns and column.name in self.exclude_columns) or (
780
+ self.include_columns and column.name not in self.include_columns
781
+ ):
782
+ body_content_table.columns.hide(column.name)
783
+ else:
784
+ body_content_table.columns.show(column.name)
785
+ # Enable bulk action toggle if the user has appropriate permissions
786
+ user = request.user
787
+ if self.enable_bulk_actions and (
788
+ user.has_perm(get_permission_for_model(body_content_table_model, "delete"))
789
+ or user.has_perm(get_permission_for_model(body_content_table_model, "change"))
790
+ ):
791
+ body_content_table.columns.show("pk")
792
+
793
+ per_page = self.max_display_count if self.max_display_count is not None else get_paginate_count(request)
794
+ paginate = {"paginator_class": EnhancedPaginator, "per_page": per_page}
795
+ RequestConfig(request, paginate).configure(body_content_table)
796
+ more_queryset_count = max(body_content_table.data.data.count() - per_page, 0)
797
+
798
+ obj = get_obj_from_context(context)
799
+ body_content_table_model = body_content_table.Meta.model
800
+ related_field_name = self.related_field_name or self.table_filter or obj._meta.model_name
801
+
802
+ try:
803
+ list_route = reverse(get_route_for_model(body_content_table_model, "list"))
804
+ body_content_table_list_url = f"{list_route}?{related_field_name}={obj.pk}"
805
+ except NoReverseMatch:
806
+ body_content_table_list_url = None
807
+
808
+ body_content_table_add_url = self._get_table_add_url(context)
809
+ body_content_table_verbose_name_plural = self.table_title or body_content_table_model._meta.verbose_name_plural
810
+
811
+ return {
812
+ "body_content_table": body_content_table,
813
+ "body_content_table_add_url": body_content_table_add_url,
814
+ "body_content_table_list_url": body_content_table_list_url,
815
+ "body_content_table_verbose_name": body_content_table_model._meta.verbose_name,
816
+ "body_content_table_verbose_name_plural": body_content_table_verbose_name_plural,
817
+ "more_queryset_count": more_queryset_count,
818
+ }
819
+
820
+
821
+ class KeyValueTablePanel(Panel):
822
+ """A panel that displays a two-column table of keys and values, as seen in most object detail views."""
823
+
824
+ def __init__(
825
+ self,
826
+ *,
827
+ data=None,
828
+ context_data_key=None,
829
+ hide_if_unset=(),
830
+ value_transforms=None,
831
+ body_wrapper_template_path="components/panel/body_wrapper_key_value_table.html",
832
+ **kwargs,
833
+ ):
834
+ """
835
+ Instantiate a KeyValueTablePanel.
836
+
837
+ Args:
838
+ data (dict): The dictionary of key/value data to display in this panel.
839
+ May be `None` if it will be derived dynamically by `get_data()` or from `context_data_key` instead.
840
+ context_data_key (str): The render context key that will contain the data, if `data` wasn't provided.
841
+ hide_if_unset (list): Keys that should be omitted from the display entirely if they have a falsey value,
842
+ instead of displaying the usual em-dash placeholder text.
843
+ value_transforms (dict): Dictionary of `{key: [list of transform functions]}`, used to specify custom
844
+ rendering of specific key values without needing to implement a new subclass for this purpose.
845
+ Many of the `templatetags.helpers` functions are suitable for this purpose; examples:
846
+
847
+ - `[render_markdown, placeholder]` - render the given text as Markdown, or render a placeholder if blank
848
+ - `[humanize_speed, placeholder]` - convert the given kbps value to Mbps or Gbps for display
849
+ """
850
+ if data and context_data_key:
851
+ raise ValueError("The data and context_data_key parameters are mutually exclusive")
852
+ self.data = data
853
+ self.context_data_key = context_data_key or "data"
854
+ self.hide_if_unset = hide_if_unset
855
+ self.value_transforms = value_transforms or {}
856
+ super().__init__(body_wrapper_template_path=body_wrapper_template_path, **kwargs)
857
+
858
+ def should_render(self, context: Context):
859
+ return bool(self.get_data(context))
860
+
861
+ def get_data(self, context: Context):
862
+ """
863
+ Get the data for this panel, by default from `self.data` or the key `"data"` in the provided context.
864
+
865
+ Subclasses may need to override this method if the derivation of the data is more involved.
866
+
867
+ Returns:
868
+ (dict): Key/value dictionary to be rendered in this panel.
869
+ """
870
+ return self.data or context[self.context_data_key]
871
+
872
+ def render_key(self, key, value, context: Context):
873
+ """
874
+ Render the provided key in human-readable form.
875
+
876
+ The default implementation simply replaces underscores with spaces and title-cases it with `bettertitle()`.
877
+ """
878
+ return bettertitle(key.replace("_", " "))
879
+
880
+ def queryset_list_url_filter(self, key, value, context: Context):
881
+ """
882
+ Get a filter parameter to use when hyperlinking queryset data to an object list URL to provide filtering.
883
+
884
+ Returns:
885
+ (str): A URL parameter string of the form `"filter=value"`, or `""` if none is known.
886
+
887
+ The default implementation returns `""`, which means "no appropriate filter parameter is known,
888
+ do not hyperlink the queryset text." Subclasses may wish to override this to provide appropriate intelligence.
889
+
890
+ Examples:
891
+ - For a queryset of VRFs in a Location detail view for instance `aaf814ef-2ef6-463e-9440-54f6514afe0e`,
892
+ this might return the string `"locations=aaf814ef-2ef6-463e-9440-54f6514afe0e"`, resulting in the
893
+ hyperlinked URL `/ipam/vrfs/?locations=aaf814ef-2ef6-463e-9440-54f6514afe0e`
894
+ - For a queryset of Devices associated to Circuit Termination `4182ce87-0f90-450e-a682-9af5992b4bb7`
895
+ by a Relationship with key `termination_to_devices`, this might return the string
896
+ `"cr_termination_to_devices__source=4182ce87-0f90-450e-a682-9af5992b4bb7"`, resulting in the hyperlinked
897
+ URL `/dcim/devices/?cr_termination_to_device__source=4182ce87-0f90-450e-a682-9af5992b4bb7`
898
+ """
899
+ return ""
900
+
901
+ def render_value(self, key, value, context) -> str:
902
+ """
903
+ Render the provided value in human-readable form.
904
+
905
+ Returns:
906
+ (str): String or HTML representation of the given value. May return `""` to indicate that this value
907
+ should be skipped entirely, i.e. not displayed in the table at all.
908
+ May return `placeholder(value)` to display a consistent placeholder representation of any unset value.
909
+
910
+ Behavior is influenced by:
911
+
912
+ - `self.value_transforms` - if it has an entry for the given `key`, then the given functions provided there
913
+ will be used to render the `value`, in place of any default processing and rendering for this data type.
914
+ - `self.hide_if_unset` - any key in this list, if having a corresponding value of `None`, will be omitted from
915
+ the display (returning `""` instead of a placeholder).
916
+
917
+ There is a lot of "intelligence" built in to this method to handle various data types, including:
918
+
919
+ - Instances of `Status`, `Role` and similar models will be represented as an appropriately-colored hyperlinked
920
+ badge (using `hyperlinked_object_with_color()`)
921
+ - Instances of `Tenant` will be hyperlinked and will also display their hyperlinked `TenantGroup` if any
922
+ - Instances of other models will be hyperlinked (using `hyperlinked_object()`)
923
+ - Model QuerySets will render the first several objects in the QuerySet (as above), and if more objects are
924
+ present, and `self.queryset_list_url_filter()` returns an appropriate filter string, will render the link to
925
+ the filtered list view of that model.
926
+ - Etc.
927
+ """
928
+ display = value
929
+ if key in self.value_transforms:
930
+ for transform in self.value_transforms[key]:
931
+ display = transform(display)
932
+
933
+ elif value is None:
934
+ if key in self.hide_if_unset:
935
+ display = ""
936
+ else:
937
+ display = placeholder(value)
938
+
939
+ elif isinstance(value, bool):
940
+ return render_boolean(value)
941
+
942
+ elif isinstance(value, models.Model):
943
+ if hasattr(value, "color"):
944
+ display = hyperlinked_object_with_color(value)
945
+ elif isinstance(value, Tenant) and value.tenant_group is not None:
946
+ display = format_html("{} / {}", hyperlinked_object(value.tenant_group), hyperlinked_object(value))
947
+ # TODO: render location hierarchy for Location objects
948
+ else:
949
+ display = hyperlinked_object(value)
950
+
951
+ elif isinstance(value, models.QuerySet):
952
+ if not value.exists():
953
+ display = placeholder(None)
954
+ else:
955
+ # Link to the filtered list, and display up to 3 records individually as a list
956
+ count = value.count()
957
+ model = value.model
958
+
959
+ # If we can find the list URL and the appropriate filter parameter for this listing, wrap the above
960
+ # in an appropriate hyperlink:
961
+ list_url = None
962
+ list_url_filter = self.queryset_list_url_filter(key, value, context)
963
+ list_url_name = validated_viewname(model, "list")
964
+ if list_url_filter and list_url_name:
965
+ list_url = f"{reverse(list_url_name)}?{list_url_filter}"
966
+
967
+ display = format_html_join(
968
+ ", ", "{}", ([self.render_value(key, record, context)] for record in value[:3])
969
+ )
970
+ if count > 3:
971
+ if list_url:
972
+ display += format_html(
973
+ ', and <a href="{}">{} other {}</a>',
974
+ list_url,
975
+ count - 3,
976
+ model._meta.verbose_name if count - 3 == 1 else model._meta.verbose_name_plural,
977
+ )
978
+ else:
979
+ display += format_html(
980
+ ", and {} other {}",
981
+ count - 3,
982
+ model._meta.verbose_name if count - 3 == 1 else model._meta.verbose_name_plural,
983
+ )
984
+ else:
985
+ display = placeholder(localize(value))
986
+
987
+ # TODO: apply additional smart formatting such as JSON/Markdown rendering, etc.
988
+ return display
989
+
990
+ def render_body_content(self, context: Context):
991
+ """Render key-value pairs as table rows, using `render_key()` and `render_value()` methods as applicable."""
992
+ data = self.get_data(context)
993
+
994
+ if not data:
995
+ return format_html('<tr><td colspan="2">{}</td></tr>', placeholder(data))
996
+
997
+ result = format_html("")
998
+ panel_label = slugify(self.label or "")
999
+ for key, value in data.items():
1000
+ key_display = self.render_key(key, value, context)
1001
+
1002
+ if value_display := self.render_value(key, value, context):
1003
+ if value_display is HTML_NONE:
1004
+ value_tag = value_display
1005
+ else:
1006
+ value_tag = format_html(
1007
+ """
1008
+ <span class="hover_copy">
1009
+ <span id="{unique_id}_value_{key}">{value}</span>
1010
+ <button class="btn btn-inline btn-default hover_copy_button" data-clipboard-target="#{unique_id}_value_{key}">
1011
+ <span class="mdi mdi-content-copy"></span>
1012
+ </button>
1013
+ </span>
1014
+ """,
1015
+ # key might not be globally unique in a page, but is unique to a panel;
1016
+ # Hence we add the panel label to make it globally unique to the page
1017
+ unique_id=panel_label,
1018
+ key=slugify(key),
1019
+ value=value_display,
1020
+ )
1021
+ result += format_html("<tr><td>{key}</td><td>{value}</td></tr>", key=key_display, value=value_tag)
1022
+
1023
+ return result
1024
+
1025
+
1026
+ class ObjectFieldsPanel(KeyValueTablePanel):
1027
+ """A panel that renders a table of object instance attributes and their values."""
1028
+
1029
+ def __init__(
1030
+ self,
1031
+ *,
1032
+ fields="__all__",
1033
+ exclude_fields=(),
1034
+ context_object_key=None,
1035
+ ignore_nonexistent_fields=False,
1036
+ label=None,
1037
+ **kwargs,
1038
+ ):
1039
+ """
1040
+ Instantiate an ObjectFieldsPanel.
1041
+
1042
+ Args:
1043
+ fields (str, list): The ordered list of fields to display, or `"__all__"` to display fields automatically.
1044
+ Note that ManyToMany fields and reverse relations are **not** included in `"__all__"` at this time, nor
1045
+ are any hidden fields, nor the specially handled `id`, `created`, `last_updated` fields on most models.
1046
+ exclude_fields (list): Only relevant if `fields == "__all__"`, in which case it excludes the given fields.
1047
+ context_object_key (str): The key in the render context that will contain the object to derive fields from.
1048
+ ignore_nonexistent_fields (bool): If True, `fields` is permitted to include field names that don't actually
1049
+ exist on the provided object; otherwise an exception will be raised at render time.
1050
+ label (str): If omitted, the provided object's `verbose_name` will be rendered as the label
1051
+ (see `render_label()`).
1052
+ """
1053
+ self.fields = fields
1054
+ self.exclude_fields = exclude_fields
1055
+ self.context_object_key = context_object_key
1056
+ self.ignore_nonexistent_fields = ignore_nonexistent_fields
1057
+ super().__init__(data=None, label=label, **kwargs)
1058
+
1059
+ def render_label(self, context: Context):
1060
+ """Default to rendering the provided object's `verbose_name` if no more specific `label` was defined."""
1061
+ if self.label is None:
1062
+ return bettertitle(get_obj_from_context(context, self.context_object_key)._meta.verbose_name)
1063
+ return super().render_label(context)
1064
+
1065
+ def render_value(self, key, value, context: Context):
1066
+ obj = get_obj_from_context(context, self.context_object_key)
1067
+ try:
1068
+ field_instance = obj._meta.get_field(key)
1069
+ except FieldDoesNotExist:
1070
+ field_instance = None
1071
+
1072
+ if key == "_hierarchy":
1073
+ return render_ancestor_hierarchy(value)
1074
+
1075
+ if isinstance(field_instance, URLField):
1076
+ return hyperlinked_field(value)
1077
+
1078
+ if isinstance(field_instance, JSONField):
1079
+ return format_html("<pre>{}</pre>", render_json(value))
1080
+
1081
+ if isinstance(field_instance, ManyToManyField) and field_instance.related_model == ContentType:
1082
+ return render_content_types(value)
1083
+
1084
+ if isinstance(field_instance, CharField) and hasattr(obj, f"get_{key}_display"):
1085
+ # For example, Secret.provider -> Secret.get_provider_display()
1086
+ # Note that we *don't* want to do this for models with a StatusField and its `get_status_display()`
1087
+ return super().render_value(key, getattr(obj, f"get_{key}_display")(), context)
1088
+
1089
+ return super().render_value(key, value, context)
1090
+
1091
+ def get_data(self, context: Context):
1092
+ """
1093
+ Load data from the object provided in the render context based on the given set of `fields`.
1094
+
1095
+ Returns:
1096
+ (dict): Key-value pairs corresponding to the object's fields, or `{}` if no object is present.
1097
+ """
1098
+ fields = self.fields
1099
+ instance = get_obj_from_context(context, self.context_object_key)
1100
+
1101
+ if instance is None:
1102
+ return {}
1103
+
1104
+ if fields == "__all__":
1105
+ # Derive the list of fields from the instance, skipping certain fields by default.
1106
+ fields = []
1107
+ for field in instance._meta.get_fields():
1108
+ if field.hidden or field.name.startswith("_"):
1109
+ continue
1110
+ if field.name in ("id", "created", "last_updated", "tags", "comments"):
1111
+ # Handled elsewhere in the detail view
1112
+ continue
1113
+ if field.is_relation and field.one_to_many:
1114
+ # Reverse relations should be handled by ObjectsTablePanel
1115
+ continue
1116
+ if field.is_relation and field.many_to_many and field.related_model != ContentType:
1117
+ # Many-to-many relations should be handled by ObjectsTablePanel, *except* for ContentTypes, where
1118
+ # we keep the historic pattern of just rendering them as a list since there's no need for a table
1119
+ continue
1120
+ fields.append(field.name)
1121
+ # TODO: apply a default ordering "smarter" than declaration order? Alphabetical? By field type?
1122
+ # TODO: allow model to specify an alternative field ordering?
1123
+
1124
+ data = {}
1125
+
1126
+ if isinstance(instance, TreeModel) and (self.fields == "__all__" or "_hierarchy" in self.fields):
1127
+ # using `_hierarchy` with the prepended `_` to try to archive a unique name, in cases where a model might have hierarchy field.
1128
+ data["_hierarchy"] = instance
1129
+
1130
+ for field_name in fields:
1131
+ if field_name in self.exclude_fields:
1132
+ continue
1133
+ try:
1134
+ field_value = getattr(instance, field_name)
1135
+ except ObjectDoesNotExist:
1136
+ field_value = None
1137
+ except AttributeError:
1138
+ if self.ignore_nonexistent_fields:
1139
+ continue
1140
+ raise
1141
+
1142
+ data[field_name] = field_value
1143
+
1144
+ return data
1145
+
1146
+ def render_key(self, key, value, context: Context):
1147
+ """Render the `verbose_name` of the model field whose name corresponds to the given key, if applicable."""
1148
+ instance = get_obj_from_context(context, self.context_object_key)
1149
+
1150
+ if instance is not None:
1151
+ try:
1152
+ field = instance._meta.get_field(key)
1153
+ return bettertitle(field.verbose_name)
1154
+ # Not all fields have a verbose name, ManyToOneRel for example.
1155
+ except (FieldDoesNotExist, AttributeError):
1156
+ pass
1157
+
1158
+ return super().render_key(key, value, context)
1159
+
1160
+
1161
+ class GroupedKeyValueTablePanel(KeyValueTablePanel):
1162
+ """
1163
+ A KeyValueTablePanel that displays its data within collapsible accordion groupings, such as object custom fields.
1164
+
1165
+ Expects data in the form `{grouping1: {key1: value1, key2: value2, ...}, grouping2: {...}, ...}`.
1166
+
1167
+ The special grouping `""` may be used to indicate top-level key/value pairs that don't belong to a group.
1168
+ """
1169
+
1170
+ def __init__(self, *, body_id, **kwargs):
1171
+ super().__init__(body_id=body_id, **kwargs)
1172
+
1173
+ def render_header_extra_content(self, context: Context):
1174
+ """Add a "Collapse All" button to the header."""
1175
+ return format_html(
1176
+ '<button type="button" class="btn-xs btn-primary pull-right accordion-toggle-all" data-target="#{body_id}">'
1177
+ "Collapse All</button>",
1178
+ body_id=self.body_id,
1179
+ )
1180
+
1181
+ def render_body_content(self, context: Context):
1182
+ """Render groups of key-value pairs to HTML."""
1183
+ data = self.get_data(context)
1184
+
1185
+ if not data:
1186
+ return format_html('<tr><td colspan="2">{}</td></tr>', placeholder(data))
1187
+
1188
+ result = format_html("")
1189
+ counter = 0
1190
+ for grouping, entry in data.items():
1191
+ counter += 1
1192
+ if grouping:
1193
+ result += render_component_template(
1194
+ "components/panel/grouping_toggle.html",
1195
+ context,
1196
+ grouping=grouping,
1197
+ body_id=self.body_id,
1198
+ counter=counter,
1199
+ )
1200
+ for key, value in entry.items():
1201
+ key_display = self.render_key(key, value, context)
1202
+ value_display = self.render_value(key, value, context)
1203
+ if value_display:
1204
+ # TODO: add a copy button on hover to all display items
1205
+ result += format_html(
1206
+ '<tr class="collapseme-{body_id}-{counter} collapse in" data-parent="#{body_id}">'
1207
+ "<td>{key}</td><td>{value}</td></tr>",
1208
+ counter=counter,
1209
+ body_id=self.body_id,
1210
+ key=key_display,
1211
+ value=value_display,
1212
+ )
1213
+
1214
+ return result
1215
+
1216
+
1217
+ class BaseTextPanel(Panel):
1218
+ """A panel that renders a single value as text, Markdown, JSON, or YAML."""
1219
+
1220
+ class RenderOptions(Enum):
1221
+ """Options available for text panels for different type of rendering a given input.
1222
+
1223
+ Attributes:
1224
+ PLAINTEXT (str): Plain text format (value: "plaintext").
1225
+ JSON (str): Dict will be dumped into JSON and pretty-formatted (value: "json").
1226
+ YAML (str): Dict will be displayed as pretty-formatted yaml (value: "yaml")
1227
+ MARKDOWN (str): Markdown format (value: "markdown").
1228
+ CODE (str): Code format. Just wraps content within <pre> tags (value: "code").
1229
+ """
1230
+
1231
+ PLAINTEXT = "plaintext"
1232
+ JSON = "json"
1233
+ YAML = "yaml"
1234
+ MARKDOWN = "markdown"
1235
+ CODE = "code"
1236
+
1237
+ def __init__(
1238
+ self,
1239
+ *,
1240
+ render_as=RenderOptions.MARKDOWN,
1241
+ body_content_template_path="components/panel/body_content_text.html",
1242
+ render_placeholder=True,
1243
+ **kwargs,
1244
+ ):
1245
+ """
1246
+ Instantiate BaseTextPanel.
1247
+
1248
+ Args:
1249
+ render_as (RenderOptions): One of BaseTextPanel.RenderOptions to define rendering function.
1250
+ render_placeholder (bool): Whether to render placeholder text if given value is "falsy".
1251
+ body_content_template_path (str): The path of the template to use for the body content.
1252
+ Can be overridden for custom use cases.
1253
+ kwargs (dict): Additional keyword arguments passed to `Panel.__init__`.
1254
+ """
1255
+ self.render_as = render_as
1256
+ self.render_placeholder = render_placeholder
1257
+ super().__init__(body_content_template_path=body_content_template_path, **kwargs)
1258
+
1259
+ def render_body_content(self, context: Context):
1260
+ value = self.get_value(context)
1261
+
1262
+ if not value and self.render_placeholder:
1263
+ return HTML_NONE
1264
+
1265
+ if self.body_content_template_path:
1266
+ return render_component_template(
1267
+ self.body_content_template_path, context, render_as=self.render_as.value, value=value
1268
+ )
1269
+ return value
1270
+
1271
+ def get_value(self, context: Context):
1272
+ raise NotImplementedError
1273
+
1274
+
1275
+ class ObjectTextPanel(BaseTextPanel):
1276
+ """
1277
+ Panel that renders text, Markdown, JSON or YAML from the given field on the given object in the context.
1278
+
1279
+ Args:
1280
+ object_field (str): The name of the object field to be rendered. None by default.
1281
+ kwargs (dict): Additional keyword arguments passed to `BaseTextPanel.__init__`.
1282
+ """
1283
+
1284
+ def __init__(self, *, object_field=None, **kwargs):
1285
+ self.object_field = object_field
1286
+
1287
+ super().__init__(**kwargs)
1288
+
1289
+ def get_value(self, context: Context):
1290
+ obj = get_obj_from_context(context)
1291
+ if not obj:
1292
+ return ""
1293
+ return getattr(obj, self.object_field, "")
1294
+
1295
+
1296
+ class TextPanel(BaseTextPanel):
1297
+ """Panel that renders text, Markdown, JSON or YAML from the given value in the context.
1298
+
1299
+ Args:
1300
+ context_field (str): source field from context with value for `TextPanel`.
1301
+ kwargs (dict): Additional keyword arguments passed to `BaseTextPanel.__init__`.
1302
+ """
1303
+
1304
+ def __init__(self, *, context_field="text", **kwargs):
1305
+ self.context_field = context_field
1306
+ super().__init__(**kwargs)
1307
+
1308
+ def get_value(self, context: Context):
1309
+ return context.get(self.context_field, "")
1310
+
1311
+
1312
+ class StatsPanel(Panel):
1313
+ def __init__(
1314
+ self,
1315
+ *,
1316
+ filter_name,
1317
+ related_models=None,
1318
+ body_content_template_path="components/panel/stats_panel_body.html",
1319
+ **kwargs,
1320
+ ):
1321
+ """
1322
+ Instantiate a `StatsPanel`.
1323
+ filter_name (str) is a valid query filter append to the anchor tag for each stat button.
1324
+ e.g. the `tenant` query parameter in the url `/circuits/circuits/?tenant=f4b48e9d-56fc-4090-afa5-dcbe69775b13`.
1325
+ related_models is a list of model classes and/or tuples of (model_class, query_string).
1326
+ e.g. [Device, Prefix, (Circuit, "circuit_terminations__location__in"), (VirtualMachine, "cluster__location__in")]
1327
+ """
1328
+
1329
+ self.filter_name = filter_name
1330
+ self.related_models = related_models
1331
+ super().__init__(body_content_template_path=body_content_template_path, **kwargs)
1332
+
1333
+ def should_render(self, context: Context):
1334
+ """Always should render this panel as the permission is reinforced in python with .restrict(request.user, "view")"""
1335
+ return True
1336
+
1337
+ def render_body_content(self, context: Context):
1338
+ """
1339
+ Transform self.related_models to a dictionary with key, value pairs as follows:
1340
+ {
1341
+ <related_object_model_class_1>: [related_object_model_class_list_url_1, related_object_count_1, related_object_title_1],
1342
+ <related_object_model_class_2>: [related_object_model_class_list_url_2, related_object_count_2, related_object_title_2],
1343
+ <related_object_model_class_3>: [related_object_model_class_list_url_3, related_object_count_3, related_object_title_3],
1344
+ ...
1345
+ }
1346
+ """
1347
+ instance = get_obj_from_context(context)
1348
+ request = context["request"]
1349
+ if isinstance(instance, TreeModel):
1350
+ self.filter_pks = (
1351
+ instance.descendants(include_self=True).restrict(request.user, "view").values_list("pk", flat=True)
1352
+ )
1353
+ else:
1354
+ self.filter_pks = [instance.pk]
1355
+
1356
+ if self.body_content_template_path:
1357
+ stats = {}
1358
+ if not self.related_models:
1359
+ return ""
1360
+ for related_field in self.related_models:
1361
+ if isinstance(related_field, tuple):
1362
+ related_object_model_class, query = related_field
1363
+ else:
1364
+ related_object_model_class, query = related_field, f"{self.filter_name}__in"
1365
+ filter_dict = {query: self.filter_pks}
1366
+ related_object_count = (
1367
+ related_object_model_class.objects.restrict(request.user, "view").filter(**filter_dict).count()
1368
+ )
1369
+ related_object_model_class_meta = related_object_model_class._meta
1370
+ related_object_list_url = validated_viewname(related_object_model_class, "list")
1371
+ related_object_title = bettertitle(related_object_model_class_meta.verbose_name_plural)
1372
+ value = [related_object_list_url, related_object_count, related_object_title]
1373
+ stats[related_object_model_class] = value
1374
+ related_object_model_filterset = get_filterset_for_model(related_object_model_class)
1375
+ if self.filter_name not in related_object_model_filterset.declared_filters:
1376
+ raise FieldDoesNotExist(
1377
+ f"{self.filter_name} is not a valid filter field for {related_object_model_class_meta.verbose_name}"
1378
+ )
1379
+
1380
+ return render_component_template(
1381
+ self.body_content_template_path, context, stats=stats, filter_name=self.filter_name
1382
+ )
1383
+ return ""
1384
+
1385
+
1386
+ class _ObjectCustomFieldsPanel(GroupedKeyValueTablePanel):
1387
+ """A panel that renders a table of object custom fields."""
1388
+
1389
+ def __init__(
1390
+ self,
1391
+ *,
1392
+ advanced_ui=False,
1393
+ weight=Panel.WEIGHT_CUSTOM_FIELDS_PANEL,
1394
+ label="Custom Fields",
1395
+ section=SectionChoices.LEFT_HALF,
1396
+ **kwargs,
1397
+ ):
1398
+ """Instantiate an `_ObjectCustomFieldsPanel`.
1399
+
1400
+ Args:
1401
+ advanced_ui (bool): Whether this is on the "main" tab (False) or the "advanced" tab (True)
1402
+ """
1403
+ self.advanced_ui = advanced_ui
1404
+ super().__init__(
1405
+ data=None,
1406
+ body_id=f"custom_fields_{advanced_ui}",
1407
+ weight=weight,
1408
+ label=label,
1409
+ section=section,
1410
+ **kwargs,
1411
+ )
1412
+
1413
+ def should_render(self, context: Context):
1414
+ """Render only if any custom fields are present."""
1415
+ obj = get_obj_from_context(context)
1416
+ if not hasattr(obj, "get_custom_field_groupings"):
1417
+ return False
1418
+ self.custom_field_data = obj.get_custom_field_groupings(advanced_ui=self.advanced_ui)
1419
+ return bool(self.custom_field_data)
1420
+
1421
+ def get_data(self, context: Context):
1422
+ """Remap the response from `get_custom_field_groupings()` to a nested dict as expected by the parent class."""
1423
+ data = {}
1424
+ for grouping, entries in self.custom_field_data.items():
1425
+ data[grouping] = {entry[0]: entry[1] for entry in entries}
1426
+ return data
1427
+
1428
+ def render_key(self, key, value, context: Context):
1429
+ """Render the custom field's description as well as its label."""
1430
+ return format_html('<span title="{}">{}</span>', key.description, key)
1431
+
1432
+ def render_value(self, key, value, context: Context):
1433
+ """Render a given custom field value appropriately depending on what type of custom field it is."""
1434
+ cf = key
1435
+ if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
1436
+ return render_boolean(value)
1437
+ elif cf.type == CustomFieldTypeChoices.TYPE_URL and value:
1438
+ return format_html('<a href="{}">{}</a>', value, truncatechars(value, 70))
1439
+ elif cf.type == CustomFieldTypeChoices.TYPE_MULTISELECT and value:
1440
+ return format_html_join(", ", "{}", ([v] for v in value))
1441
+ elif cf.type == CustomFieldTypeChoices.TYPE_MARKDOWN and value:
1442
+ return render_markdown(value)
1443
+ elif cf.type == CustomFieldTypeChoices.TYPE_JSON and value is not None:
1444
+ return format_html(
1445
+ """<p>
1446
+ <button class="btn btn-xs btn-primary" type="button" data-toggle="collapse"
1447
+ data-target="#cf_{field_key}" aria-expanded="false" aria-controls="cf_{field_key}">
1448
+ Show/Hide
1449
+ </button>
1450
+ </p>
1451
+ <pre class="collapse" id="cf_{field_key}">{rendered_value}</pre>""",
1452
+ field_key=cf.key,
1453
+ rendered_value=render_json(value),
1454
+ )
1455
+ elif value or value == 0:
1456
+ return format_html("{}", value)
1457
+ elif cf.required:
1458
+ return format_html('<span class="text-warning">Not defined</span>')
1459
+ return placeholder(value)
1460
+
1461
+
1462
+ class _ObjectComputedFieldsPanel(GroupedKeyValueTablePanel):
1463
+ """A panel that renders a table of object computed field values."""
1464
+
1465
+ def __init__(
1466
+ self,
1467
+ *,
1468
+ advanced_ui=False,
1469
+ weight=Panel.WEIGHT_COMPUTED_FIELDS_PANEL,
1470
+ label="Computed Fields",
1471
+ section=SectionChoices.LEFT_HALF,
1472
+ **kwargs,
1473
+ ):
1474
+ """Instantiate this panel.
1475
+
1476
+ Args:
1477
+ advanced_ui (bool): Whether this is on the "main" tab (False) or the "advanced" tab (True)
1478
+ """
1479
+ self.advanced_ui = advanced_ui
1480
+ super().__init__(
1481
+ data=None,
1482
+ body_id=f"computed_fields_{advanced_ui}",
1483
+ weight=weight,
1484
+ label=label,
1485
+ section=section,
1486
+ **kwargs,
1487
+ )
1488
+
1489
+ def should_render(self, context: Context):
1490
+ """Render only if any relevant computed fields are defined."""
1491
+ obj = get_obj_from_context(context)
1492
+ if not hasattr(obj, "get_computed_fields_grouping"):
1493
+ return False
1494
+ self.computed_fields_data = obj.get_computed_fields_grouping(advanced_ui=self.advanced_ui)
1495
+ return bool(self.computed_fields_data)
1496
+
1497
+ def get_data(self, context: Context):
1498
+ """Remap `get_computed_fields_grouping()` to the nested dict format expected by the base class."""
1499
+ data = {}
1500
+ for grouping, entries in self.computed_fields_data.items():
1501
+ data[grouping] = {entry[0]: entry[1] for entry in entries}
1502
+ return data
1503
+
1504
+ def render_key(self, key, value, context: Context):
1505
+ """Render the computed field's description as well as its label."""
1506
+ return format_html('<span title="{}">{}</span>', key.description, key)
1507
+
1508
+
1509
+ class _ObjectRelationshipsPanel(KeyValueTablePanel):
1510
+ """A panel that renders a table of object "custom" relationships."""
1511
+
1512
+ def __init__(
1513
+ self,
1514
+ *,
1515
+ advanced_ui=False,
1516
+ weight=Panel.WEIGHT_RELATIONSHIPS_PANEL,
1517
+ label="Relationships",
1518
+ section=SectionChoices.LEFT_HALF,
1519
+ **kwargs,
1520
+ ):
1521
+ """Instantiate this panel.
1522
+
1523
+ Args:
1524
+ advanced_ui (bool): Whether this is on the "main" tab (False) or the "advanced" tab (True)
1525
+ """
1526
+ self.advanced_ui = advanced_ui
1527
+ super().__init__(data=None, weight=weight, label=label, section=section, **kwargs)
1528
+
1529
+ def should_render(self, context: Context):
1530
+ """Render only if any relevant relationships are defined."""
1531
+ obj = get_obj_from_context(context)
1532
+ if not hasattr(obj, "get_relationships_with_related_objects"):
1533
+ return False
1534
+ self.relationships_data = obj.get_relationships_with_related_objects(
1535
+ advanced_ui=self.advanced_ui, include_hidden=False
1536
+ )
1537
+ return bool(
1538
+ self.relationships_data["source"]
1539
+ or self.relationships_data["destination"]
1540
+ or self.relationships_data["peer"]
1541
+ )
1542
+
1543
+ def get_data(self, context: Context):
1544
+ """Remap `get_relationships_with_related_objects()` to the flat dict format expected by the base class."""
1545
+ data = {}
1546
+ for side, relationships in self.relationships_data.items():
1547
+ for relationship, value in relationships.items():
1548
+ key = (relationship, side)
1549
+ data[key] = value
1550
+
1551
+ return data
1552
+
1553
+ def render_key(self, key, value, context: Context):
1554
+ """Render the relationship's label and key as well as the related-objects label."""
1555
+ relationship, side = key
1556
+ return format_html(
1557
+ '<span title="{} ({})">{}</span>',
1558
+ relationship.label,
1559
+ relationship.key,
1560
+ bettertitle(relationship.get_label(side)),
1561
+ )
1562
+
1563
+ def queryset_list_url_filter(self, key, value, context: Context):
1564
+ """Filter the list URL based on the given relationship key and side."""
1565
+ relationship, side = key
1566
+ obj = get_obj_from_context(context)
1567
+ return f"cr_{relationship.key}__{side}={obj.pk}"
1568
+
1569
+
1570
+ class _ObjectTagsPanel(Panel):
1571
+ """Panel displaying an object's tags as a space-separated list of color-coded tag names."""
1572
+
1573
+ def __init__(
1574
+ self,
1575
+ *,
1576
+ weight=Panel.WEIGHT_TAGS_PANEL,
1577
+ label="Tags",
1578
+ section=SectionChoices.LEFT_HALF,
1579
+ body_content_template_path="components/panel/body_content_tags.html",
1580
+ **kwargs,
1581
+ ):
1582
+ """Instantiate an `_ObjectTagsPanel`."""
1583
+ super().__init__(
1584
+ weight=weight,
1585
+ label=label,
1586
+ section=section,
1587
+ body_content_template_path=body_content_template_path,
1588
+ **kwargs,
1589
+ )
1590
+
1591
+ def should_render(self, context: Context):
1592
+ return hasattr(get_obj_from_context(context), "tags")
1593
+
1594
+ def get_extra_context(self, context: Context):
1595
+ obj = get_obj_from_context(context)
1596
+ return {
1597
+ "tags": obj.tags.all(),
1598
+ "list_url_name": validated_viewname(obj, "list"),
1599
+ }
1600
+
1601
+
1602
+ class _ObjectCommentPanel(ObjectTextPanel):
1603
+ """Panel displaying an object's comments as a Markdown formatted panel."""
1604
+
1605
+ def __init__(
1606
+ self,
1607
+ *,
1608
+ label="Comments",
1609
+ section=SectionChoices.LEFT_HALF,
1610
+ weight=Panel.WEIGHT_COMMENTS_PANEL,
1611
+ object_field="comments",
1612
+ **kwargs,
1613
+ ):
1614
+ super().__init__(
1615
+ weight=weight,
1616
+ label=label,
1617
+ section=section,
1618
+ object_field=object_field,
1619
+ **kwargs,
1620
+ )
1621
+
1622
+ def should_render(self, context: Context):
1623
+ return hasattr(get_obj_from_context(context), "comments")
1624
+
1625
+
1626
+ class _ObjectDetailMainTab(Tab):
1627
+ """Base class for a main display tab containing an overview of object fields and similar data."""
1628
+
1629
+ def __init__(
1630
+ self,
1631
+ *,
1632
+ tab_id="main",
1633
+ label="", # see render_label()
1634
+ weight=Tab.WEIGHT_MAIN_TAB,
1635
+ panels=(),
1636
+ **kwargs,
1637
+ ):
1638
+ panels = list(panels)
1639
+ # Inject standard panels (custom fields, relationships, tags, etc.) as appropriate
1640
+ panels.append(_ObjectCommentPanel())
1641
+ panels.append(_ObjectCustomFieldsPanel())
1642
+ panels.append(_ObjectComputedFieldsPanel())
1643
+ panels.append(_ObjectRelationshipsPanel())
1644
+ panels.append(_ObjectTagsPanel())
1645
+
1646
+ super().__init__(tab_id=tab_id, label=label, weight=weight, panels=panels, **kwargs)
1647
+
1648
+ def render_label(self, context: Context):
1649
+ """Use the `verbose_name` of the given instance's Model as the tab label by default."""
1650
+ return bettertitle(get_obj_from_context(context)._meta.verbose_name)
1651
+
1652
+
1653
+ class _ObjectDataProvenancePanel(ObjectFieldsPanel):
1654
+ """Built-in class for a Panel displaying data provenance information on the Advanced tab."""
1655
+
1656
+ def __init__(
1657
+ self,
1658
+ *,
1659
+ weight=150,
1660
+ label="Data Provenance",
1661
+ section=SectionChoices.LEFT_HALF,
1662
+ fields=("created", "last_updated", "created_by", "last_updated_by", "api_url"),
1663
+ ignore_nonexistent_fields=True,
1664
+ **kwargs,
1665
+ ):
1666
+ super().__init__(
1667
+ weight=weight,
1668
+ label=label,
1669
+ section=section,
1670
+ fields=fields,
1671
+ ignore_nonexistent_fields=ignore_nonexistent_fields,
1672
+ **kwargs,
1673
+ )
1674
+
1675
+ def get_data(self, context: Context):
1676
+ data = super().get_data(context)
1677
+ # 3.0 TODO: instead of passing these around as context variables, just call
1678
+ # `get_created_and_last_updated_usernames_for_model(context[self.context_object_key])` right here?
1679
+ data["created_by"] = context["created_by"]
1680
+ data["last_updated_by"] = context["last_updated_by"]
1681
+ with contextlib.suppress(AttributeError):
1682
+ data["api_url"] = get_obj_from_context(context, self.context_object_key).get_absolute_url(api=True)
1683
+ return data
1684
+
1685
+ def render_key(self, key, value, context: Context):
1686
+ if key == "api_url":
1687
+ return "View in API Browser"
1688
+ return super().render_key(key, value, context)
1689
+
1690
+ def render_value(self, key, value, context: Context):
1691
+ if key == "api_url":
1692
+ return format_html('<a href="{}" target="_blank"><span class="mdi mdi-open-in-new"></span></a>', value)
1693
+ return super().render_value(key, value, context)
1694
+
1695
+
1696
+ class _ObjectDetailAdvancedTab(Tab):
1697
+ """Built-in class for a Tab displaying "advanced" information such as PKs and data provenance."""
1698
+
1699
+ def __init__(
1700
+ self,
1701
+ *,
1702
+ tab_id="advanced",
1703
+ label="Advanced",
1704
+ weight=Tab.WEIGHT_ADVANCED_TAB,
1705
+ panels=None,
1706
+ **kwargs,
1707
+ ):
1708
+ if not panels:
1709
+ panels = (
1710
+ ObjectFieldsPanel(
1711
+ label="Object Details",
1712
+ section=SectionChoices.LEFT_HALF,
1713
+ weight=100,
1714
+ fields=["id", "natural_slug", "slug"],
1715
+ ignore_nonexistent_fields=True,
1716
+ ),
1717
+ _ObjectDataProvenancePanel(),
1718
+ _ObjectCustomFieldsPanel(advanced_ui=True),
1719
+ _ObjectComputedFieldsPanel(advanced_ui=True),
1720
+ _ObjectRelationshipsPanel(advanced_ui=True),
1721
+ )
1722
+
1723
+ super().__init__(tab_id=tab_id, label=label, weight=weight, panels=panels, **kwargs)
1724
+
1725
+
1726
+ class _ObjectDetailContactsTab(Tab):
1727
+ """Built-in class for a Tab displaying information about contact/team associations."""
1728
+
1729
+ def __init__(
1730
+ self,
1731
+ *,
1732
+ tab_id="contacts",
1733
+ label="Contacts",
1734
+ weight=Tab.WEIGHT_CONTACTS_TAB,
1735
+ panels=None,
1736
+ **kwargs,
1737
+ ):
1738
+ if panels is None:
1739
+ panels = (
1740
+ ObjectsTablePanel(
1741
+ weight=100,
1742
+ table_class=AssociatedContactsTable,
1743
+ table_attribute="associated_contacts",
1744
+ order_by_fields=["role__name"],
1745
+ enable_bulk_actions=True,
1746
+ max_display_count=100, # since there isn't a separate list view for ContactAssociations!
1747
+ # TODO: we should provide a standard reusable component template for bulk-actions in the footer
1748
+ footer_content_template_path="components/panel/footer_contacts_table.html",
1749
+ header_extra_content_template_path=None,
1750
+ ),
1751
+ )
1752
+ super().__init__(tab_id=tab_id, label=label, weight=weight, panels=panels, **kwargs)
1753
+
1754
+ def should_render(self, context: Context):
1755
+ return getattr(get_obj_from_context(context), "is_contact_associable_model", False)
1756
+
1757
+ def render_label(self, context: Context):
1758
+ return format_html(
1759
+ "{} {}",
1760
+ self.label,
1761
+ render_to_string(
1762
+ "utilities/templatetags/badge.html", badge(get_obj_from_context(context).associated_contacts.count())
1763
+ ),
1764
+ )
1765
+
1766
+
1767
+ @dataclass
1768
+ class _ObjectDetailGroupsTab(Tab):
1769
+ """Built-in class for a Tab displaying information about associated dynamic groups."""
1770
+
1771
+ def __init__(
1772
+ self,
1773
+ *,
1774
+ tab_id="dynamic_groups",
1775
+ label="Dynamic Groups",
1776
+ weight=Tab.WEIGHT_GROUPS_TAB,
1777
+ panels=None,
1778
+ **kwargs,
1779
+ ):
1780
+ if panels is None:
1781
+ panels = (
1782
+ ObjectsTablePanel(
1783
+ weight=100,
1784
+ table_class=DynamicGroupTable,
1785
+ table_attribute="dynamic_groups",
1786
+ exclude_columns=["content_type"],
1787
+ add_button_route=None,
1788
+ related_field_name="member_id",
1789
+ ),
1790
+ )
1791
+ super().__init__(tab_id=tab_id, label=label, weight=weight, panels=panels, **kwargs)
1792
+
1793
+ def should_render(self, context: Context):
1794
+ obj = get_obj_from_context(context)
1795
+ return (
1796
+ getattr(obj, "is_dynamic_group_associable_model", False)
1797
+ and context["request"].user.has_perm("extras.view_dynamicgroup")
1798
+ and obj.dynamic_groups.exists()
1799
+ )
1800
+
1801
+ def render_label(self, context: Context):
1802
+ return format_html(
1803
+ "{} {}",
1804
+ self.label,
1805
+ render_to_string(
1806
+ "utilities/templatetags/badge.html", badge(get_obj_from_context(context).dynamic_groups.count())
1807
+ ),
1808
+ )
1809
+
1810
+
1811
+ @dataclass
1812
+ class _ObjectDetailMetadataTab(Tab):
1813
+ """Built-in class for a Tab displaying information about associated object metadata."""
1814
+
1815
+ def __init__(
1816
+ self,
1817
+ *,
1818
+ tab_id="object_metadata",
1819
+ label="Object Metadata",
1820
+ weight=Tab.WEIGHT_METADATA_TAB,
1821
+ panels=None,
1822
+ **kwargs,
1823
+ ):
1824
+ if panels is None:
1825
+ panels = (
1826
+ ObjectsTablePanel(
1827
+ weight=100,
1828
+ table_class=ObjectMetadataTable,
1829
+ table_attribute="associated_object_metadata",
1830
+ order_by_fields=["metadata_type", "scoped_fields"],
1831
+ exclude_columns=["assigned_object"],
1832
+ add_button_route=None,
1833
+ related_field_name="assigned_object_id",
1834
+ header_extra_content_template_path=None,
1835
+ ),
1836
+ )
1837
+ super().__init__(tab_id=tab_id, label=label, weight=weight, panels=panels, **kwargs)
1838
+
1839
+ def should_render(self, context: Context):
1840
+ obj = get_obj_from_context(context)
1841
+ return (
1842
+ getattr(obj, "is_metadata_associable_model", False)
1843
+ and context["request"].user.has_perm("extras.view_objectmetadata")
1844
+ and obj.associated_object_metadata.exists()
1845
+ )
1846
+
1847
+ def render_label(self, context: Context):
1848
+ return format_html(
1849
+ "{} {}",
1850
+ self.label,
1851
+ render_to_string(
1852
+ "utilities/templatetags/badge.html",
1853
+ badge(get_obj_from_context(context).associated_object_metadata.count()),
1854
+ ),
1855
+ )