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.
- nautobot/__init__.py +15 -0
- nautobot/apps/api.py +0 -2
- nautobot/apps/config.py +32 -3
- nautobot/apps/events.py +19 -0
- nautobot/apps/exceptions.py +0 -2
- nautobot/apps/ui.py +44 -9
- nautobot/apps/utils.py +0 -8
- nautobot/apps/views.py +2 -0
- nautobot/circuits/navigation.py +0 -57
- nautobot/circuits/tables.py +1 -2
- nautobot/circuits/templates/circuits/circuit_retrieve.html +0 -71
- nautobot/circuits/templates/circuits/inc/circuit_termination.html +6 -64
- nautobot/circuits/templates/circuits/inc/circuit_termination_cable_fragment.html +40 -0
- nautobot/circuits/templates/circuits/inc/circuit_termination_header_extra_content.html +26 -0
- nautobot/circuits/templates/circuits/provider_retrieve.html +0 -76
- nautobot/circuits/tests/integration/test_relationships.py +33 -24
- nautobot/circuits/tests/test_filters.py +4 -8
- nautobot/circuits/views.py +143 -26
- nautobot/cloud/factory.py +4 -1
- nautobot/cloud/models.py +1 -1
- nautobot/cloud/tests/test_filters.py +5 -4
- nautobot/core/api/fields.py +5 -5
- nautobot/core/api/metadata.py +28 -256
- nautobot/core/api/pagination.py +3 -2
- nautobot/core/api/renderers.py +3 -0
- nautobot/core/api/serializers.py +24 -244
- nautobot/core/api/urls.py +3 -4
- nautobot/core/api/utils.py +0 -62
- nautobot/core/api/views.py +48 -158
- nautobot/core/apps/__init__.py +22 -578
- nautobot/core/celery/__init__.py +13 -0
- nautobot/core/celery/log.py +4 -4
- nautobot/core/celery/schedulers.py +48 -3
- nautobot/core/cli/__init__.py +8 -0
- nautobot/core/constants.py +7 -0
- nautobot/core/events/__init__.py +116 -0
- nautobot/core/events/base.py +27 -0
- nautobot/core/events/exceptions.py +10 -0
- nautobot/core/events/redis_broker.py +48 -0
- nautobot/core/events/syslog_broker.py +19 -0
- nautobot/core/exceptions.py +0 -6
- nautobot/core/filters.py +16 -21
- nautobot/core/fixtures/user-data.json +59 -0
- nautobot/core/forms/fields.py +53 -8
- nautobot/core/forms/utils.py +2 -1
- nautobot/core/graphql/schema.py +3 -1
- nautobot/core/graphql/types.py +1 -1
- nautobot/core/jobs/__init__.py +4 -4
- nautobot/core/jobs/cleanup.py +13 -49
- nautobot/core/jobs/groups.py +1 -1
- nautobot/core/management/commands/generate_test_data.py +21 -0
- nautobot/core/management/commands/validate_models.py +1 -1
- nautobot/core/middleware.py +16 -0
- nautobot/core/models/__init__.py +1 -1
- nautobot/core/models/fields.py +11 -7
- nautobot/core/models/query_functions.py +2 -2
- nautobot/core/models/tree_queries.py +3 -6
- nautobot/core/settings.py +44 -7
- nautobot/core/settings.yaml +86 -8
- nautobot/core/tables.py +15 -65
- nautobot/core/tasks.py +1 -1
- nautobot/core/templates/components/button/default.html +7 -0
- nautobot/core/templates/components/button/dropdown.html +20 -0
- nautobot/core/templates/components/layout/one_over_two.html +19 -0
- nautobot/core/templates/components/layout/two_over_one.html +19 -0
- nautobot/core/templates/components/panel/body_content_data_table.html +27 -0
- nautobot/core/templates/components/panel/body_content_objects_table.html +4 -0
- nautobot/core/templates/components/panel/body_content_tags.html +6 -0
- nautobot/core/templates/components/panel/body_content_text.html +12 -0
- nautobot/core/templates/components/panel/body_wrapper_generic.html +3 -0
- nautobot/core/templates/components/panel/body_wrapper_key_value_table.html +3 -0
- nautobot/core/templates/components/panel/body_wrapper_table.html +3 -0
- nautobot/core/templates/components/panel/footer_contacts_table.html +20 -0
- nautobot/core/templates/components/panel/footer_content_table.html +14 -0
- nautobot/core/templates/components/panel/grouping_toggle.html +14 -0
- nautobot/core/templates/components/panel/header_extra_content_table.html +3 -0
- nautobot/core/templates/components/panel/panel.html +16 -0
- nautobot/core/templates/components/panel/stats_panel_body.html +8 -0
- nautobot/core/templates/components/tab/content_wrapper.html +3 -0
- nautobot/core/templates/components/tab/label_wrapper.html +5 -0
- nautobot/core/templates/components/tab/label_wrapper_distinct_view.html +3 -0
- nautobot/core/templates/generic/object_retrieve.html +28 -17
- nautobot/core/templates/inc/computed_fields/panel_data.html +4 -7
- nautobot/core/templates/inc/custom_fields/panel.html +2 -2
- nautobot/core/templates/inc/custom_fields/panel_data.html +4 -7
- nautobot/core/templates/inc/footer.html +1 -0
- nautobot/core/templates/inc/media.html +0 -3
- nautobot/core/templates/inc/nav_menu.html +1 -1
- nautobot/core/templates/inc/relationships_panel.html +1 -1
- nautobot/core/templates/nautobot_config.py.j2 +3 -3
- nautobot/core/templates/panel_table.html +12 -0
- nautobot/core/templates/search.html +0 -7
- nautobot/core/templates/utilities/render_jinja2.html +117 -0
- nautobot/core/templatetags/helpers.py +101 -12
- nautobot/core/templatetags/ui_framework.py +40 -0
- nautobot/core/testing/api.py +23 -128
- nautobot/core/testing/context.py +18 -0
- nautobot/core/testing/filters.py +41 -58
- nautobot/core/testing/mixins.py +2 -7
- nautobot/core/testing/views.py +25 -123
- nautobot/core/tests/integration/test_app_home.py +1 -0
- nautobot/core/tests/integration/test_app_navbar.py +1 -0
- nautobot/core/tests/integration/test_filters.py +2 -0
- nautobot/core/tests/integration/test_home.py +1 -0
- nautobot/core/tests/integration/test_navbar.py +1 -0
- nautobot/core/tests/integration/test_view_authentication.py +1 -2
- nautobot/core/tests/nautobot_config.py +198 -0
- nautobot/core/tests/runner.py +3 -3
- nautobot/core/tests/test_api.py +82 -201
- nautobot/core/tests/test_csv.py +3 -25
- nautobot/core/tests/test_events.py +214 -0
- nautobot/core/tests/test_jinja_filters.py +1 -0
- nautobot/core/tests/test_jobs.py +84 -13
- nautobot/core/tests/test_navigations.py +7 -241
- nautobot/core/tests/test_templatetags_helpers.py +16 -0
- nautobot/core/tests/test_ui.py +150 -0
- nautobot/core/tests/test_utils.py +0 -25
- nautobot/core/tests/test_views.py +123 -31
- nautobot/core/ui/__init__.py +0 -0
- nautobot/core/ui/base.py +11 -0
- nautobot/core/ui/choices.py +44 -0
- nautobot/core/ui/homepage.py +167 -0
- nautobot/core/ui/nav.py +279 -0
- nautobot/core/ui/object_detail.py +1841 -0
- nautobot/core/ui/utils.py +36 -0
- nautobot/core/urls.py +4 -9
- nautobot/core/utils/config.py +30 -3
- nautobot/core/utils/lookup.py +20 -13
- nautobot/core/views/__init__.py +6 -1
- nautobot/core/views/generic.py +47 -52
- nautobot/core/views/mixins.py +15 -25
- nautobot/core/views/paginator.py +8 -5
- nautobot/core/views/renderers.py +3 -3
- nautobot/core/views/utils.py +11 -0
- nautobot/core/wsgi.py +3 -3
- nautobot/dcim/api/serializers.py +80 -179
- nautobot/dcim/api/urls.py +5 -0
- nautobot/dcim/api/views.py +17 -4
- nautobot/dcim/apps.py +1 -0
- nautobot/dcim/choices.py +28 -0
- nautobot/dcim/factory.py +58 -0
- nautobot/dcim/filters/__init__.py +197 -24
- nautobot/dcim/forms.py +203 -12
- nautobot/dcim/graphql/types.py +2 -2
- nautobot/dcim/migrations/0063_interfacevdcassignment_virtualdevicecontext_and_more.py +165 -0
- nautobot/dcim/migrations/0064_virtualdevicecontext_status_data_migration.py +28 -0
- nautobot/dcim/migrations/0065_controller_capabilities_and_more.py +29 -0
- nautobot/dcim/migrations/0066_controllermanageddevicegroup_radio_profiles_and_more.py +33 -0
- nautobot/dcim/models/__init__.py +4 -0
- nautobot/dcim/models/device_component_templates.py +2 -2
- nautobot/dcim/models/device_components.py +20 -22
- nautobot/dcim/models/devices.py +173 -4
- nautobot/dcim/models/locations.py +3 -3
- nautobot/dcim/models/power.py +5 -6
- nautobot/dcim/models/racks.py +6 -6
- nautobot/dcim/navigation.py +25 -224
- nautobot/dcim/signals.py +44 -0
- nautobot/dcim/tables/__init__.py +5 -3
- nautobot/dcim/tables/devices.py +96 -2
- nautobot/dcim/tables/devicetypes.py +2 -2
- nautobot/dcim/templates/dcim/controller/base.html +10 -0
- nautobot/dcim/templates/dcim/controller_create.html +1 -0
- nautobot/dcim/templates/dcim/controller_retrieve.html +5 -1
- nautobot/dcim/templates/dcim/controller_wirelessnetworks.html +25 -0
- nautobot/dcim/templates/dcim/controllermanageddevicegroup_create.html +66 -0
- nautobot/dcim/templates/dcim/controllermanageddevicegroup_retrieve.html +46 -0
- nautobot/dcim/templates/dcim/device/base.html +6 -42
- nautobot/dcim/templates/dcim/device/wireless.html +73 -0
- nautobot/dcim/templates/dcim/device.html +3 -1
- nautobot/dcim/templates/dcim/interface.html +1 -0
- nautobot/dcim/templates/dcim/interface_edit.html +1 -0
- nautobot/dcim/templates/dcim/locationtype.html +0 -107
- nautobot/dcim/templates/dcim/locationtype_retrieve.html +8 -0
- nautobot/dcim/templates/dcim/virtualdevicecontext_retrieve.html +76 -0
- nautobot/dcim/templates/dcim/virtualdevicecontext_update.html +34 -0
- nautobot/dcim/tests/test_api.py +172 -61
- nautobot/dcim/tests/test_filters.py +171 -109
- nautobot/dcim/tests/test_forms.py +2 -51
- nautobot/dcim/tests/test_graphql.py +0 -52
- nautobot/dcim/tests/test_models.py +126 -4
- nautobot/dcim/tests/test_signals.py +1 -0
- nautobot/dcim/tests/test_views.py +103 -11
- nautobot/dcim/urls.py +72 -27
- nautobot/dcim/utils.py +2 -2
- nautobot/dcim/views.py +369 -62
- nautobot/extras/api/customfields.py +2 -2
- nautobot/extras/api/serializers.py +91 -75
- nautobot/extras/api/urls.py +4 -0
- nautobot/extras/api/views.py +78 -15
- nautobot/extras/choices.py +13 -0
- nautobot/extras/constants.py +0 -1
- nautobot/extras/context_managers.py +23 -6
- nautobot/extras/datasources/git.py +4 -1
- nautobot/extras/factory.py +27 -0
- nautobot/extras/filters/__init__.py +59 -0
- nautobot/extras/forms/forms.py +125 -30
- nautobot/extras/forms/mixins.py +3 -11
- nautobot/extras/graphql/types.py +25 -1
- nautobot/extras/group_sync.py +3 -3
- nautobot/extras/health_checks.py +2 -1
- nautobot/extras/jobs.py +62 -26
- nautobot/extras/management/__init__.py +1 -0
- nautobot/extras/management/commands/runjob.py +7 -79
- nautobot/extras/management/commands/runjob_with_job_result.py +46 -0
- nautobot/extras/management/utils.py +87 -0
- nautobot/extras/managers.py +1 -3
- nautobot/extras/migrations/0018_joblog_data_migration.py +9 -7
- nautobot/extras/migrations/0117_create_job_queue_model.py +129 -0
- nautobot/extras/migrations/0118_task_queue_to_job_queue_migration.py +78 -0
- nautobot/extras/migrations/0119_remove_task_queues_from_job_and_queue_from_scheduled_job.py +28 -0
- nautobot/extras/models/__init__.py +4 -0
- nautobot/extras/models/change_logging.py +7 -3
- nautobot/extras/models/customfields.py +11 -12
- nautobot/extras/models/groups.py +9 -13
- nautobot/extras/models/jobs.py +218 -37
- nautobot/extras/models/models.py +2 -2
- nautobot/extras/models/relationships.py +69 -1
- nautobot/extras/models/secrets.py +5 -0
- nautobot/extras/navigation.py +20 -262
- nautobot/extras/plugins/__init__.py +56 -32
- nautobot/extras/plugins/marketplace_manifest.yml +450 -0
- nautobot/extras/plugins/urls.py +1 -0
- nautobot/extras/plugins/views.py +48 -1
- nautobot/extras/signals.py +39 -1
- nautobot/extras/tables.py +40 -6
- nautobot/extras/templates/extras/externalintegration_retrieve.html +0 -47
- nautobot/extras/templates/extras/inc/tags_panel.html +1 -5
- nautobot/extras/templates/extras/job_bulk_edit.html +2 -1
- nautobot/extras/templates/extras/job_detail.html +36 -6
- nautobot/extras/templates/extras/job_edit.html +5 -2
- nautobot/extras/templates/extras/job_list.html +2 -7
- nautobot/extras/templates/extras/jobqueue_retrieve.html +44 -0
- nautobot/extras/templates/extras/marketplace.html +278 -0
- nautobot/extras/templates/extras/plugins_list.html +35 -1
- nautobot/extras/templates/extras/plugins_tiles.html +79 -0
- nautobot/extras/templates/extras/role_retrieve.html +16 -0
- nautobot/extras/templates/extras/secret.html +0 -65
- nautobot/extras/templates/extras/secret_check.js +16 -0
- nautobot/extras/templates/extras/secret_create.html +114 -0
- nautobot/extras/templates/extras/secret_edit.html +1 -114
- nautobot/extras/templates/extras/secretsgroup_edit.html +1 -1
- nautobot/extras/templates/extras/templatetags/plugin_object_detail_tabs.html +2 -0
- nautobot/extras/templatetags/job_buttons.py +5 -4
- nautobot/extras/templatetags/plugins.py +69 -6
- nautobot/extras/test_jobs/api_test_job.py +1 -1
- nautobot/extras/test_jobs/atomic_transaction.py +2 -2
- nautobot/extras/test_jobs/dry_run.py +1 -1
- nautobot/extras/test_jobs/fail.py +5 -5
- nautobot/extras/test_jobs/file_output.py +1 -1
- nautobot/extras/test_jobs/file_upload_fail.py +1 -1
- nautobot/extras/test_jobs/file_upload_pass.py +1 -1
- nautobot/extras/test_jobs/ipaddress_vars.py +1 -3
- nautobot/extras/test_jobs/jobs_module/jobs_submodule/jobs.py +1 -1
- nautobot/extras/test_jobs/location_with_custom_field.py +1 -1
- nautobot/extras/test_jobs/log_redaction.py +1 -1
- nautobot/extras/test_jobs/log_skip_db_logging.py +1 -1
- nautobot/extras/test_jobs/modify_db.py +1 -1
- nautobot/extras/test_jobs/object_var_optional.py +1 -1
- nautobot/extras/test_jobs/object_var_required.py +1 -1
- nautobot/extras/test_jobs/object_vars.py +1 -1
- nautobot/extras/test_jobs/pass.py +3 -3
- nautobot/extras/test_jobs/profiling.py +1 -1
- nautobot/extras/test_jobs/relative_import.py +3 -3
- nautobot/extras/test_jobs/soft_time_limit_greater_than_time_limit.py +1 -1
- nautobot/extras/test_jobs/task_queues.py +1 -1
- nautobot/extras/tests/integration/test_plugin_banner.py +2 -0
- nautobot/extras/tests/test_api.py +157 -55
- nautobot/extras/tests/test_context_managers.py +4 -1
- nautobot/extras/tests/test_customfields.py +1 -1
- nautobot/extras/tests/test_datasources.py +1 -2
- nautobot/extras/tests/test_dynamicgroups.py +1 -1
- nautobot/extras/tests/test_filters.py +219 -535
- nautobot/extras/tests/test_forms.py +1 -20
- nautobot/extras/tests/test_job_variables.py +73 -152
- nautobot/extras/tests/test_jobs.py +43 -54
- nautobot/extras/tests/test_models.py +71 -16
- nautobot/extras/tests/test_relationships.py +5 -2
- nautobot/extras/tests/test_utils.py +23 -2
- nautobot/extras/tests/test_views.py +183 -43
- nautobot/extras/tests/test_webhooks.py +2 -1
- nautobot/extras/urls.py +2 -20
- nautobot/extras/utils.py +118 -4
- nautobot/extras/views.py +203 -92
- nautobot/extras/webhooks.py +5 -2
- nautobot/ipam/api/fields.py +3 -3
- nautobot/ipam/api/serializers.py +36 -137
- nautobot/ipam/api/views.py +93 -62
- nautobot/ipam/lookups.py +62 -101
- nautobot/ipam/models.py +11 -63
- nautobot/ipam/navigation.py +0 -90
- nautobot/ipam/querysets.py +2 -2
- nautobot/ipam/tables.py +6 -20
- nautobot/ipam/templates/ipam/routetarget.html +0 -28
- nautobot/ipam/templates/ipam/vrf.html +0 -47
- nautobot/ipam/tests/test_api.py +8 -419
- nautobot/ipam/tests/test_filters.py +39 -119
- nautobot/ipam/tests/test_forms.py +47 -51
- nautobot/ipam/tests/test_migrations.py +30 -30
- nautobot/ipam/tests/test_models.py +0 -41
- nautobot/ipam/tests/test_querysets.py +1 -63
- nautobot/ipam/urls.py +3 -69
- nautobot/ipam/utils/__init__.py +0 -24
- nautobot/ipam/views.py +153 -198
- nautobot/project-static/css/base.css +38 -3
- nautobot/project-static/docs/404.html +421 -19
- nautobot/project-static/docs/apps/index.html +421 -19
- nautobot/project-static/docs/apps/nautobot-apps.html +421 -19
- nautobot/project-static/docs/assets/extra.css +5 -1
- nautobot/project-static/docs/assets/javascripts/{bundle.88dd0f4e.min.js → bundle.83f73b43.min.js} +2 -2
- nautobot/project-static/docs/assets/javascripts/{bundle.88dd0f4e.min.js.map → bundle.83f73b43.min.js.map} +2 -2
- nautobot/project-static/docs/code-reference/nautobot/apps/__init__.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/admin.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/api.html +421 -172
- nautobot/project-static/docs/code-reference/nautobot/apps/change_logging.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/choices.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/config.html +425 -21
- nautobot/project-static/docs/code-reference/nautobot/apps/constants.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/datasources.html +424 -22
- nautobot/project-static/docs/code-reference/nautobot/apps/events.html +9809 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/exceptions.html +424 -63
- nautobot/project-static/docs/code-reference/nautobot/apps/factory.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/forms.html +457 -20
- nautobot/project-static/docs/code-reference/nautobot/apps/graphql.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +425 -25
- nautobot/project-static/docs/code-reference/nautobot/apps/models.html +457 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/querysets.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/secrets.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +425 -215
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +430 -342
- nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +5799 -1054
- nautobot/project-static/docs/code-reference/nautobot/apps/urls.html +421 -19
- nautobot/project-static/docs/code-reference/nautobot/apps/utils.html +447 -176
- nautobot/project-static/docs/code-reference/nautobot/apps/views.html +460 -21
- nautobot/project-static/docs/development/apps/api/configuration-view.html +421 -19
- nautobot/project-static/docs/development/apps/api/database-backend-config.html +421 -19
- nautobot/project-static/docs/development/apps/api/models/django-admin.html +421 -19
- nautobot/project-static/docs/development/apps/api/models/global-search.html +421 -19
- nautobot/project-static/docs/development/apps/api/models/graphql.html +421 -19
- nautobot/project-static/docs/development/apps/api/models/index.html +421 -19
- nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/custom-validators.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/filter-extensions.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/git-repository-content.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/index.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/jinja2-filters.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/populating-extensibility-features.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/secrets-providers.html +421 -19
- nautobot/project-static/docs/development/apps/api/platform-features/table-extensions.html +424 -41
- nautobot/project-static/docs/development/apps/api/platform-features/uniquely-identify-objects.html +421 -19
- nautobot/project-static/docs/development/apps/api/prometheus.html +421 -19
- nautobot/project-static/docs/development/apps/api/setup.html +425 -155
- nautobot/project-static/docs/development/apps/api/testing.html +421 -19
- nautobot/project-static/docs/development/apps/api/ui-extensions/banners.html +421 -19
- nautobot/project-static/docs/development/apps/api/ui-extensions/home-page.html +421 -19
- nautobot/project-static/docs/development/apps/api/ui-extensions/index.html +421 -19
- nautobot/project-static/docs/development/apps/api/ui-extensions/navigation.html +421 -19
- nautobot/project-static/docs/development/apps/api/ui-extensions/object-views.html +701 -130
- nautobot/project-static/docs/development/apps/api/views/base-template.html +421 -19
- nautobot/project-static/docs/development/apps/api/views/core-view-overrides.html +421 -19
- nautobot/project-static/docs/development/apps/api/views/django-generic-views.html +421 -19
- nautobot/project-static/docs/development/apps/api/views/help-documentation.html +421 -19
- nautobot/project-static/docs/development/apps/api/views/index.html +423 -20
- nautobot/project-static/docs/development/apps/api/views/nautobot-generic-views.html +425 -19
- nautobot/project-static/docs/development/apps/api/views/nautobotuiviewset.html +451 -19
- nautobot/project-static/docs/development/apps/api/views/nautobotuiviewsetrouter.html +421 -19
- nautobot/project-static/docs/development/apps/api/views/notes.html +421 -19
- nautobot/project-static/docs/development/apps/api/views/rest-api.html +421 -19
- nautobot/project-static/docs/development/apps/api/views/urls.html +421 -19
- nautobot/project-static/docs/development/apps/index.html +421 -19
- nautobot/project-static/docs/development/apps/migration/code-updates.html +422 -52
- nautobot/project-static/docs/development/apps/migration/dependency-updates.html +422 -20
- nautobot/project-static/docs/development/apps/migration/from-v1.html +421 -19
- nautobot/project-static/docs/development/apps/migration/model-updates/dcim.html +421 -19
- nautobot/project-static/docs/development/apps/migration/model-updates/extras.html +421 -19
- nautobot/project-static/docs/development/apps/migration/model-updates/global.html +421 -19
- nautobot/project-static/docs/development/apps/migration/model-updates/ipam.html +424 -22
- nautobot/project-static/docs/development/apps/migration/ui-component-framework/best-practices.html +9219 -0
- nautobot/project-static/docs/development/apps/migration/ui-component-framework/custom-content.html +9333 -0
- nautobot/project-static/docs/development/apps/migration/ui-component-framework/index.html +9474 -0
- nautobot/project-static/docs/development/apps/migration/ui-component-framework/migration-steps.html +9517 -0
- nautobot/project-static/docs/development/apps/porting-from-netbox.html +424 -22
- nautobot/project-static/docs/development/core/application-registry.html +421 -19
- nautobot/project-static/docs/development/core/best-practices.html +421 -19
- nautobot/project-static/docs/development/core/bootstrap-ui.html +421 -19
- nautobot/project-static/docs/development/core/caching.html +421 -19
- nautobot/project-static/docs/development/core/controllers.html +423 -19
- nautobot/project-static/docs/development/core/docker-compose-advanced-use-cases.html +490 -45
- nautobot/project-static/docs/development/core/generic-views.html +421 -19
- nautobot/project-static/docs/development/core/getting-started.html +566 -179
- nautobot/project-static/docs/development/core/homepage.html +432 -30
- nautobot/project-static/docs/development/core/index.html +421 -19
- nautobot/project-static/docs/development/core/local-k8s.html +9453 -0
- nautobot/project-static/docs/development/core/model-checklist.html +424 -22
- nautobot/project-static/docs/development/core/model-features.html +421 -19
- nautobot/project-static/docs/development/core/natural-keys.html +421 -19
- nautobot/project-static/docs/development/core/navigation-menu.html +438 -26
- nautobot/project-static/docs/development/core/release-checklist.html +435 -45
- nautobot/project-static/docs/development/core/role-internals.html +421 -19
- nautobot/project-static/docs/development/core/settings.html +421 -19
- nautobot/project-static/docs/development/core/style-guide.html +421 -19
- nautobot/project-static/docs/development/core/templates.html +431 -22
- nautobot/project-static/docs/development/core/testing.html +421 -19
- nautobot/project-static/docs/development/core/ui-component-framework.html +11020 -0
- nautobot/project-static/docs/development/core/user-preferences.html +424 -22
- nautobot/project-static/docs/development/index.html +421 -19
- nautobot/project-static/docs/development/jobs/index.html +546 -160
- nautobot/project-static/docs/development/jobs/migration/from-v1.html +421 -19
- nautobot/project-static/docs/index.html +421 -19
- nautobot/project-static/docs/media/development/core/ui-component-framework/basic-panel-layout.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/button-example.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/dropdown-button-example.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/grouped-key-value-table-panel-example-1.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/grouped-key-value-table-panel-example-2.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/object-fields-panel-example.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/stats-panel-example.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/table-panels-family.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/text-panels-family.png +0 -0
- nautobot/project-static/docs/media/development/core/ui-component-framework/ui-framework-example.png +0 -0
- nautobot/project-static/docs/media/models/virtual_device_context_overview.drawio +73 -0
- nautobot/project-static/docs/media/models/virtual_device_context_overview.png +0 -0
- nautobot/project-static/docs/models/dcim/virtualdevicecontext.html +14 -0
- nautobot/project-static/docs/models/extras/jobqueue.html +14 -0
- nautobot/project-static/docs/models/wireless/radioprofile.html +14 -0
- nautobot/project-static/docs/models/wireless/supporteddatarate.html +14 -0
- nautobot/project-static/docs/models/wireless/wirelessnetwork.html +14 -0
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/overview/application_stack.html +426 -20
- nautobot/project-static/docs/overview/design_philosophy.html +421 -19
- nautobot/project-static/docs/release-notes/index.html +445 -22
- nautobot/project-static/docs/release-notes/version-1.0.html +421 -19
- nautobot/project-static/docs/release-notes/version-1.1.html +421 -19
- nautobot/project-static/docs/release-notes/version-1.2.html +421 -19
- nautobot/project-static/docs/release-notes/version-1.3.html +421 -19
- nautobot/project-static/docs/release-notes/version-1.4.html +421 -19
- nautobot/project-static/docs/release-notes/version-1.5.html +421 -19
- nautobot/project-static/docs/release-notes/version-1.6.html +634 -667
- nautobot/project-static/docs/release-notes/version-2.0.html +421 -19
- nautobot/project-static/docs/release-notes/version-2.1.html +421 -19
- nautobot/project-static/docs/release-notes/version-2.2.html +421 -19
- nautobot/project-static/docs/release-notes/version-2.3.html +684 -886
- nautobot/project-static/docs/release-notes/version-2.4.html +10007 -0
- nautobot/project-static/docs/requirements.txt +2 -2
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +334 -270
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/authentication/ldap.html +421 -19
- nautobot/project-static/docs/user-guide/administration/configuration/authentication/remote.html +421 -19
- nautobot/project-static/docs/user-guide/administration/configuration/authentication/sso.html +423 -21
- nautobot/project-static/docs/user-guide/administration/configuration/index.html +433 -32
- nautobot/project-static/docs/user-guide/administration/configuration/redis.html +421 -19
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +765 -180
- nautobot/project-static/docs/user-guide/administration/configuration/time-zones.html +421 -19
- nautobot/project-static/docs/user-guide/administration/guides/celery-queues.html +421 -19
- nautobot/project-static/docs/user-guide/administration/guides/docker.html +434 -29
- nautobot/project-static/docs/user-guide/administration/guides/health-checks.html +421 -19
- nautobot/project-static/docs/user-guide/administration/guides/permissions.html +421 -19
- nautobot/project-static/docs/user-guide/administration/guides/prometheus-metrics.html +421 -19
- nautobot/project-static/docs/user-guide/administration/guides/replicating-nautobot.html +421 -19
- nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +421 -19
- nautobot/project-static/docs/user-guide/administration/guides/s3-django-storage.html +421 -19
- nautobot/project-static/docs/user-guide/administration/guides/selinux-troubleshooting.html +421 -19
- nautobot/project-static/docs/user-guide/administration/installation/app-install.html +421 -19
- nautobot/project-static/docs/user-guide/administration/installation/external-authentication.html +421 -19
- nautobot/project-static/docs/user-guide/administration/installation/http-server.html +421 -19
- nautobot/project-static/docs/user-guide/administration/installation/index.html +426 -20
- nautobot/project-static/docs/user-guide/administration/installation/install_system.html +421 -19
- nautobot/project-static/docs/user-guide/administration/installation/nautobot.html +421 -19
- nautobot/project-static/docs/user-guide/administration/installation/services.html +421 -19
- nautobot/project-static/docs/user-guide/administration/migration/migrating-from-netbox.html +421 -19
- nautobot/project-static/docs/user-guide/administration/migration/migrating-from-postgresql.html +442 -41
- nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +435 -66
- nautobot/project-static/docs/user-guide/administration/tools/nautobot-shell.html +435 -66
- nautobot/project-static/docs/user-guide/administration/upgrading/database-backup.html +421 -19
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/after-you-upgrade.html +421 -19
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/before-you-upgrade.html +421 -19
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/for-developers.html +421 -19
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/index.html +421 -19
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/ipam/whats-changed.html +421 -19
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/region-and-site-data-migration-guide.html +421 -19
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/tables/v2-code-nautobot-app-location.yaml +0 -16
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +421 -19
- nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +427 -21
- nautobot/project-static/docs/user-guide/core-data-model/circuits/circuit.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/circuits/circuittermination.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/circuits/circuittype.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/circuits/provider.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/circuits/providernetwork.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/cloud/cloud.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudaccount.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudnetwork.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudnetworkprefixassignment.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudresourcetype.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudservice.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/cloud/cloudservicenetworkassignment.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/cable.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleport.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleporttemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverport.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverporttemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/controller.html +457 -20
- nautobot/project-static/docs/user-guide/core-data-model/dcim/controllermanageddevicegroup.html +447 -22
- nautobot/project-static/docs/user-guide/core-data-model/dcim/device.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebay.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebaytemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/devicefamily.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/deviceredundancygroup.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/devicetype.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/frontport.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/frontporttemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/interface.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/interfaceredundancygroup.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/interfacetemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/inventoryitem.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/location.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/locationtype.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/manufacturer.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/module.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/modulebay.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/modulebaytemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/moduletype.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/platform.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/powerfeed.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlet.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlettemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/powerpanel.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/powerport.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/powerporttemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/rack.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/rackgroup.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/rackreservation.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/rearport.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/rearporttemplate.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareversion.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/virtualchassis.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/dcim/virtualdevicecontext.html +9333 -0
- nautobot/project-static/docs/user-guide/core-data-model/extras/configcontext.html +424 -22
- nautobot/project-static/docs/user-guide/core-data-model/extras/configcontextschema.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/extras/contact.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/extras/team.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/ipaddress.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/namespace.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/prefix.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/rir.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/routetarget.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/service.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/vlangroup.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/ipam/vrf.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/overview/introduction.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/tenancy/tenant.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/tenancy/tenantgroup.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/virtualization/cluster.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/virtualization/clustergroup.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/virtualization/clustertype.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/virtualization/virtualmachine.html +421 -19
- nautobot/project-static/docs/user-guide/core-data-model/virtualization/vminterface.html +424 -22
- nautobot/project-static/docs/user-guide/core-data-model/wireless/index.html +9271 -0
- nautobot/project-static/docs/user-guide/core-data-model/wireless/radioprofile.html +9175 -0
- nautobot/project-static/docs/user-guide/core-data-model/wireless/supporteddatarate.html +9169 -0
- nautobot/project-static/docs/user-guide/core-data-model/wireless/wirelessnetwork.html +9235 -0
- nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/custom-fields.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/index.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/graphql.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/ip-address-merge-tool.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/relationships.html +421 -19
- nautobot/project-static/docs/user-guide/feature-guides/software-image-files-and-versions.html +421 -19
- nautobot/project-static/docs/user-guide/index.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/change-logging.html +424 -22
- nautobot/project-static/docs/user-guide/platform-functionality/computedfield.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/customfield.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/customlink.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/dynamicgroup.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/events.html +9575 -0
- nautobot/project-static/docs/user-guide/platform-functionality/exporttemplate.html +424 -22
- nautobot/project-static/docs/user-guide/platform-functionality/externalintegration.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/gitrepository.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/graphql.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/graphqlquery.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/imageattachment.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +426 -20
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/job-scheduling-and-approvals.html +424 -22
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobbutton.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobhook.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobqueue.html +9182 -0
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +424 -22
- nautobot/project-static/docs/user-guide/platform-functionality/napalm.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/note.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/objectmetadata.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/relationship.html +424 -22
- nautobot/project-static/docs/user-guide/platform-functionality/rendering-jinja-templates.html +9250 -0
- nautobot/project-static/docs/user-guide/platform-functionality/rest-api/authentication.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/rest-api/filtering.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/rest-api/overview.html +424 -22
- nautobot/project-static/docs/user-guide/platform-functionality/rest-api/ui-related-endpoints.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/role.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/savedview.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/secret.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/staticgroupassociation.html +424 -22
- nautobot/project-static/docs/user-guide/platform-functionality/status.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/tag.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/template-filters.html +489 -56
- nautobot/project-static/docs/user-guide/platform-functionality/users/objectpermission.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/users/token.html +421 -19
- nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +421 -19
- nautobot/project-static/img/jinja_logo.svg +97 -0
- nautobot/project-static/js/forms.js +5 -0
- nautobot/project-static/js/nav_menu.js +2 -1
- nautobot/tenancy/api/serializers.py +0 -2
- nautobot/tenancy/factory.py +1 -1
- nautobot/tenancy/navigation.py +0 -29
- nautobot/tenancy/templates/tenancy/tenant.html +4 -91
- nautobot/tenancy/tests/test_filters.py +29 -134
- nautobot/tenancy/views.py +32 -23
- nautobot/users/admin.py +3 -1
- nautobot/users/api/serializers.py +4 -5
- nautobot/users/api/views.py +1 -1
- nautobot/users/forms.py +19 -0
- nautobot/users/templates/users/preferences.html +22 -0
- nautobot/users/tests/test_filters.py +1 -19
- nautobot/users/tests/test_views.py +57 -0
- nautobot/users/utils.py +8 -0
- nautobot/users/views.py +48 -11
- nautobot/virtualization/api/serializers.py +4 -4
- nautobot/virtualization/filters.py +2 -20
- nautobot/virtualization/navigation.py +0 -48
- nautobot/virtualization/templates/virtualization/clustertype.html +0 -39
- nautobot/virtualization/tests/test_filters.py +57 -183
- nautobot/virtualization/views.py +18 -15
- nautobot/wireless/__init__.py +0 -0
- nautobot/wireless/api/__init__.py +0 -0
- nautobot/wireless/api/serializers.py +44 -0
- nautobot/wireless/api/urls.py +20 -0
- nautobot/wireless/api/views.py +34 -0
- nautobot/wireless/apps.py +8 -0
- nautobot/wireless/choices.py +345 -0
- nautobot/wireless/factory.py +138 -0
- nautobot/wireless/filters.py +167 -0
- nautobot/wireless/forms.py +283 -0
- nautobot/wireless/homepage.py +19 -0
- nautobot/wireless/migrations/0001_initial.py +223 -0
- nautobot/wireless/migrations/__init__.py +0 -0
- nautobot/wireless/models.py +207 -0
- nautobot/wireless/navigation.py +105 -0
- nautobot/wireless/tables.py +244 -0
- nautobot/wireless/templates/wireless/radioprofile_retrieve.html +81 -0
- nautobot/wireless/templates/wireless/supporteddatarate_retrieve.html +26 -0
- nautobot/wireless/templates/wireless/wirelessnetwork_create.html +88 -0
- nautobot/wireless/templates/wireless/wirelessnetwork_retrieve.html +64 -0
- nautobot/wireless/tests/__init__.py +0 -0
- nautobot/wireless/tests/test_api.py +247 -0
- nautobot/wireless/tests/test_filters.py +54 -0
- nautobot/wireless/tests/test_models.py +22 -0
- nautobot/wireless/tests/test_views.py +378 -0
- nautobot/wireless/urls.py +13 -0
- nautobot/wireless/views.py +129 -0
- {nautobot-2.3.16.dist-info → nautobot-2.4.0b1.dist-info}/METADATA +11 -14
- {nautobot-2.3.16.dist-info → nautobot-2.4.0b1.dist-info}/RECORD +674 -551
- {nautobot-2.3.16.dist-info → nautobot-2.4.0b1.dist-info}/WHEEL +1 -1
- nautobot/core/utils/navigation.py +0 -54
- {nautobot-2.3.16.dist-info → nautobot-2.4.0b1.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.3.16.dist-info → nautobot-2.4.0b1.dist-info}/NOTICE +0 -0
- {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
|
+
)
|