nautobot 2.3.16__py3-none-any.whl → 2.4.0b1__py3-none-any.whl

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

Potentially problematic release.


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

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