From 927927850186bb6751b0e0a78830dc88961739e6 Mon Sep 17 00:00:00 2001 From: Jinal shah Date: Tue, 8 Feb 2022 21:57:31 +0530 Subject: [PATCH] Netbox v2.11.12 (#60) * Release v2.11.12 --- .github/FUNDING.yml | 1 - .github/ISSUE_TEMPLATE/bug_report.yaml | 47 +- .github/ISSUE_TEMPLATE/config.yml | 11 +- .../ISSUE_TEMPLATE/documentation_change.yaml | 27 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 43 +- .github/ISSUE_TEMPLATE/housekeeping.yaml | 19 +- .github/stale.yml | 30 - .github/workflows/stale.yml | 35 + .gitignore | 2 + .jenkins | 1 + CONTRIBUTING.md | 15 +- README.md | 60 +- base_requirements.txt | 4 + contrib/nginx.conf | 4 +- .../{rack_groups.yml => locations.yml} | 0 docker/initializers/racks.yml | 36 +- .../{071_rack_groups.py => 075_locations.py} | 8 +- docker/startup_scripts/080_racks.py | 4 +- docker/startup_scripts/270_cables.py | 10 +- docs/additional-features/caching.md | 7 +- docs/additional-features/change-logging.md | 2 +- docs/additional-features/custom-fields.md | 11 +- docs/additional-features/custom-links.md | 3 + docs/additional-features/custom-scripts.md | 13 +- docs/additional-features/export-templates.md | 18 +- docs/additional-features/journaling.md | 5 + docs/additional-features/napalm.md | 9 +- docs/additional-features/reports.md | 6 +- docs/additional-features/webhooks.md | 19 +- docs/administration/permissions.md | 2 +- docs/administration/replicating-netbox.md | 9 +- docs/configuration/optional-settings.md | 32 +- docs/configuration/required-settings.md | 1 + docs/core-functionality/circuits.md | 1 + docs/core-functionality/devices.md | 2 + docs/core-functionality/sites-and-racks.md | 5 +- docs/development/adding-models.md | 85 + docs/development/getting-started.md | 4 +- docs/development/index.md | 2 +- docs/development/models.md | 98 ++ docs/development/release-checklist.md | 6 +- docs/index.md | 4 +- docs/installation/1-postgresql.md | 37 +- docs/installation/2-redis.md | 20 +- docs/installation/3-netbox.md | 80 +- docs/installation/6-ldap.md | 6 +- docs/installation/index.md | 6 +- docs/installation/upgrading.md | 2 +- .../installation/netbox_application_stack.png | Bin 25852 -> 35722 bytes docs/models/circuits/circuittermination.md | 6 +- docs/models/circuits/providernetwork.md | 5 + docs/models/dcim/device.md | 2 +- docs/models/dcim/interface.md | 12 +- docs/models/dcim/location.md | 5 + docs/models/dcim/powerfeed.md | 2 +- docs/models/dcim/powerpanel.md | 2 +- docs/models/dcim/rack.md | 2 +- docs/models/dcim/rackgroup.md | 7 - docs/models/dcim/sitegroup.md | 3 + docs/models/extras/configcontext.md | 6 +- docs/models/ipam/vlan.md | 2 +- docs/models/ipam/vlangroup.md | 2 +- docs/models/virtualization/virtualmachine.md | 4 +- docs/plugins/index.md | 55 + docs/release-notes/index.md | 2 +- docs/release-notes/version-2.10.md | 129 +- docs/release-notes/version-2.11.md | 443 +++++ docs/requirements.txt | 2 +- docs/rest-api/authentication.md | 2 +- docs/rest-api/filtering.md | 51 +- docs/rest-api/overview.md | 8 +- docs/rest-api/working-with-secrets.md | 2 +- mkdocs.yml | 18 +- netbox/circuits/api/nested_serializers.py | 23 +- netbox/circuits/api/serializers.py | 64 +- netbox/circuits/api/urls.py | 3 + netbox/circuits/api/views.py | 34 +- netbox/circuits/{filters.py => filtersets.py} | 126 +- netbox/circuits/forms.py | 201 ++- .../migrations/0025_standardize_models.py | 47 + .../migrations/0026_mark_connected.py | 16 + .../migrations/0027_providernetwork.py | 65 + .../0028_cache_circuit_terminations.py | 37 + .../migrations/0029_circuit_tracing.py | 32 + netbox/circuits/models.py | 161 +- netbox/circuits/querysets.py | 17 - netbox/circuits/signals.py | 27 +- netbox/circuits/tables.py | 75 +- netbox/circuits/tests/test_api.py | 50 +- .../{test_filters.py => test_filtersets.py} | 167 +- netbox/circuits/tests/test_views.py | 112 +- netbox/circuits/urls.py | 36 +- netbox/circuits/views.py | 167 +- netbox/dcim/api/nested_serializers.py | 85 +- netbox/dcim/api/serializers.py | 303 ++-- netbox/dcim/api/urls.py | 3 +- netbox/dcim/api/views.py | 141 +- netbox/dcim/choices.py | 72 + netbox/dcim/constants.py | 5 +- netbox/dcim/{filters.py => filtersets.py} | 371 ++-- netbox/dcim/forms.py | 1529 +++++++++++------ .../dcim/management/commands/trace_paths.py | 2 - .../0123_region_custom_field_data.py | 19 - .../migrations/0123_standardize_models.py | 417 +++++ netbox/dcim/migrations/0124_mark_connected.py | 51 + .../migrations/0125_console_port_speed.py | 21 + .../0126_rename_rackgroup_location.py | 39 + .../dcim/migrations/0127_device_location.py | 17 + .../0128_device_location_populate.py | 24 + .../dcim/migrations/0129_interface_parent.py | 17 + netbox/dcim/migrations/0130_sitegroup.py | 39 + .../dcim/migrations/0131_consoleport_speed.py | 21 + netbox/dcim/models/__init__.py | 3 +- netbox/dcim/models/cables.py | 75 +- .../dcim/models/device_component_templates.py | 49 +- netbox/dcim/models/device_components.py | 200 ++- netbox/dcim/models/devices.py | 90 +- netbox/dcim/models/power.py | 44 +- netbox/dcim/models/racks.py | 141 +- netbox/dcim/models/sites.py | 173 +- netbox/dcim/signals.py | 45 +- netbox/dcim/tables/cables.py | 6 +- netbox/dcim/tables/devices.py | 233 ++- netbox/dcim/tables/devicetypes.py | 6 +- netbox/dcim/tables/power.py | 22 +- netbox/dcim/tables/racks.py | 62 +- netbox/dcim/tables/sites.py | 90 +- netbox/dcim/tables/template_code.py | 188 +- netbox/dcim/tests/test_api.py | 237 +-- netbox/dcim/tests/test_cablepaths.py | 322 +++- .../{test_filters.py => test_filtersets.py} | 909 ++++++---- netbox/dcim/tests/test_models.py | 96 +- netbox/dcim/tests/test_views.py | 341 +++- netbox/dcim/urls.py | 86 +- netbox/dcim/views.py | 650 +++++-- netbox/extras/admin.py | 74 +- netbox/extras/api/customfields.py | 35 +- netbox/extras/api/nested_serializers.py | 38 +- netbox/extras/api/serializers.py | 192 ++- netbox/extras/api/urls.py | 9 + netbox/extras/api/views.py | 59 +- netbox/extras/apps.py | 1 + netbox/extras/choices.py | 28 + netbox/extras/constants.py | 1 + netbox/extras/context_managers.py | 16 +- netbox/extras/filters.py | 298 +--- netbox/extras/filtersets.py | 373 ++++ netbox/extras/forms.py | 215 ++- netbox/extras/lookups.py | 17 + .../management/commands/webhook_receiver.py | 16 +- .../migrations/0051_migrate_customfields.py | 3 - .../migrations/0054_standardize_models.py | 61 + .../migrations/0055_objectchange_data.py | 28 + .../migrations/0056_extend_configcontext.py | 22 + .../0057_customlink_rename_fields.py | 28 + netbox/extras/migrations/0058_journalentry.py | 32 + .../0059_exporttemplate_as_attachment.py | 16 + netbox/extras/models/__init__.py | 13 +- netbox/extras/models/change_logging.py | 58 +- netbox/extras/models/configcontexts.py | 166 ++ netbox/extras/models/customfields.py | 136 +- netbox/extras/models/models.py | 227 +-- netbox/extras/models/tags.py | 12 +- netbox/extras/plugins/views.py | 2 +- netbox/extras/querysets.py | 34 +- netbox/extras/scripts.py | 26 +- netbox/extras/signals.py | 93 +- netbox/extras/tables.py | 76 +- netbox/extras/templatetags/custom_links.py | 8 +- netbox/extras/tests/test_api.py | 179 +- netbox/extras/tests/test_changelog.py | 291 +++- netbox/extras/tests/test_customfields.py | 39 +- .../{test_filters.py => test_filtersets.py} | 329 +++- netbox/extras/tests/test_forms.py | 53 + netbox/extras/tests/test_models.py | 21 +- netbox/extras/tests/test_tags.py | 6 +- netbox/extras/tests/test_views.py | 44 +- netbox/extras/tests/test_webhooks.py | 210 ++- netbox/extras/urls.py | 19 +- netbox/extras/views.py | 186 +- netbox/extras/webhooks.py | 104 +- netbox/extras/webhooks_worker.py | 5 +- netbox/ipam/api/nested_serializers.py | 21 +- netbox/ipam/api/serializers.py | 88 +- netbox/ipam/api/views.py | 30 +- netbox/ipam/apps.py | 3 + netbox/ipam/constants.py | 5 + netbox/ipam/{filters.py => filtersets.py} | 139 +- netbox/ipam/forms.py | 508 ++++-- netbox/ipam/lookups.py | 2 +- netbox/ipam/management/__init__.py | 0 netbox/ipam/management/commands/__init__.py | 0 .../management/commands/rebuild_prefixes.py | 27 + .../migrations/0044_standardize_models.py | 77 + .../ipam/migrations/0045_vlangroup_scope.py | 36 + .../0046_set_vlangroup_scope_types.py | 27 + .../migrations/0047_prefix_depth_children.py | 21 + .../0048_prefix_populate_depth_children.py | 37 + netbox/ipam/models/__init__.py | 17 + netbox/ipam/{models.py => models/ip.py} | 554 +----- netbox/ipam/models/services.py | 106 ++ netbox/ipam/models/vlans.py | 221 +++ netbox/ipam/models/vrfs.py | 135 ++ netbox/ipam/querysets.py | 125 +- netbox/ipam/signals.py | 64 + netbox/ipam/tables.py | 181 +- netbox/ipam/tests/test_api.py | 20 +- .../{test_filters.py => test_filtersets.py} | 366 ++-- netbox/ipam/tests/test_models.py | 358 +++- netbox/ipam/tests/test_views.py | 54 +- netbox/ipam/urls.py | 28 +- netbox/ipam/utils.py | 86 +- netbox/ipam/views.py | 255 +-- netbox/netbox/api/__init__.py | 3 +- netbox/netbox/api/authentication.py | 9 + netbox/netbox/api/fields.py | 15 - netbox/netbox/api/serializers.py | 117 +- netbox/netbox/api/views.py | 24 + netbox/netbox/authentication.py | 32 +- netbox/netbox/configuration.example.py | 22 +- netbox/netbox/constants.py | 56 +- netbox/netbox/filtersets.py | 235 +++ netbox/netbox/forms.py | 7 +- netbox/netbox/middleware.py | 23 +- netbox/netbox/models.py | 232 +++ netbox/netbox/settings.py | 67 +- netbox/netbox/urls.py | 12 +- netbox/netbox/views/__init__.py | 3 +- netbox/netbox/views/generic.py | 218 ++- netbox/project-static/css/base.css | 9 +- netbox/project-static/js/forms.js | 35 +- netbox/project-static/js/tableconfig.js | 22 +- netbox/secrets/api/nested_serializers.py | 4 +- netbox/secrets/api/serializers.py | 18 +- netbox/secrets/api/views.py | 9 +- netbox/secrets/{filters.py => filtersets.py} | 8 +- netbox/secrets/forms.py | 33 +- .../migrations/0013_standardize_models.py | 37 + netbox/secrets/models.py | 17 +- netbox/secrets/tables.py | 9 +- netbox/secrets/tests/test_api.py | 4 +- .../{test_filters.py => test_filtersets.py} | 15 +- netbox/secrets/tests/test_views.py | 4 + netbox/secrets/urls.py | 11 +- netbox/secrets/views.py | 57 +- netbox/templates/base.html | 6 +- netbox/templates/circuits/circuit.html | 67 +- netbox/templates/circuits/circuit_edit.html | 62 - .../circuits/circuittermination_edit.html | 145 +- netbox/templates/circuits/circuittype.html | 60 + .../circuits/inc/circuit_termination.html | 122 +- .../templates/circuits/inc/speed_widget.html | 17 - netbox/templates/circuits/provider.html | 75 +- netbox/templates/circuits/provider_edit.html | 42 - .../templates/circuits/providernetwork.html | 72 + netbox/templates/dcim/cable.html | 49 +- netbox/templates/dcim/cable_connect.html | 23 +- netbox/templates/dcim/cable_trace.html | 10 +- netbox/templates/dcim/consoleport.html | 19 +- netbox/templates/dcim/consoleserverport.html | 19 +- netbox/templates/dcim/device.html | 36 +- netbox/templates/dcim/device/base.html | 166 +- netbox/templates/dcim/device/config.html | 6 +- .../templates/dcim/device/consoleports.html | 1 + .../dcim/device/consoleserverports.html | 1 + netbox/templates/dcim/device/devicebays.html | 1 + netbox/templates/dcim/device/frontports.html | 1 + netbox/templates/dcim/device/interfaces.html | 1 + netbox/templates/dcim/device/inventory.html | 1 + .../templates/dcim/device/poweroutlets.html | 1 + netbox/templates/dcim/device/powerports.html | 1 + netbox/templates/dcim/device/rearports.html | 1 + netbox/templates/dcim/device/status.html | 2 +- netbox/templates/dcim/device_component.html | 47 +- .../templates/dcim/device_component_add.html | 24 +- netbox/templates/dcim/device_edit.html | 10 +- netbox/templates/dcim/device_list.html | 2 +- netbox/templates/dcim/devicebay.html | 7 + netbox/templates/dcim/devicerole.html | 86 + netbox/templates/dcim/devicetype.html | 122 +- netbox/templates/dcim/devicetype_edit.html | 44 - netbox/templates/dcim/frontport.html | 13 +- .../templates/dcim/inc/cabletermination.html | 10 +- .../dcim/inc/device_napalm_tabs.html | 15 - .../dcim/inc/endpoint_connection.html | 2 +- netbox/templates/dcim/interface.html | 51 +- netbox/templates/dcim/interface_edit.html | 14 +- netbox/templates/dcim/inventoryitem.html | 7 + netbox/templates/dcim/location.html | 97 ++ netbox/templates/dcim/manufacturer.html | 66 + netbox/templates/dcim/platform.html | 78 + netbox/templates/dcim/powerfeed.html | 69 +- netbox/templates/dcim/powerfeed_edit.html | 52 - netbox/templates/dcim/poweroutlet.html | 13 +- netbox/templates/dcim/powerpanel.html | 100 +- netbox/templates/dcim/powerpanel_edit.html | 23 - netbox/templates/dcim/powerport.html | 13 +- netbox/templates/dcim/rack.html | 118 +- netbox/templates/dcim/rack_edit.html | 17 +- netbox/templates/dcim/rackreservation.html | 69 +- .../templates/dcim/rackreservation_edit.html | 33 - netbox/templates/dcim/rackrole.html | 66 + netbox/templates/dcim/rearport.html | 13 +- netbox/templates/dcim/region.html | 86 + netbox/templates/dcim/site.html | 134 +- netbox/templates/dcim/site_edit.html | 57 - netbox/templates/dcim/sitegroup.html | 86 + netbox/templates/dcim/trace/attachment.html | 5 + netbox/templates/dcim/trace/device.html | 3 + netbox/templates/dcim/trace/object.html | 3 + netbox/templates/dcim/virtualchassis.html | 65 +- netbox/templates/dcim/virtualchassis_add.html | 1 + netbox/templates/extras/configcontext.html | 77 +- .../templates/extras/configcontext_edit.html | 2 + netbox/templates/extras/journalentry.html | 57 + netbox/templates/extras/object_journal.html | 32 + netbox/templates/extras/objectchange.html | 38 +- netbox/templates/extras/report.html | 6 +- netbox/templates/extras/report_list.html | 4 +- netbox/templates/extras/report_result.html | 6 +- netbox/templates/extras/script.html | 4 +- netbox/templates/extras/script_list.html | 4 +- netbox/templates/extras/script_result.html | 10 +- netbox/templates/extras/tag.html | 163 +- netbox/templates/extras/tag_edit.html | 14 - netbox/templates/generic/object.html | 80 + .../templates/generic/object_bulk_import.html | 194 ++- netbox/templates/generic/object_edit.html | 41 +- netbox/templates/generic/object_list.html | 93 +- netbox/templates/home.html | 22 +- netbox/templates/inc/created_updated.html | 3 - netbox/templates/inc/custom_fields_panel.html | 2 + netbox/templates/inc/image_attachments.html | 3 +- netbox/templates/inc/nav_menu.html | 38 +- netbox/templates/ipam/aggregate.html | 65 +- netbox/templates/ipam/aggregate_edit.html | 35 - .../templates/ipam/inc/toggle_available.html | 18 +- .../templates/ipam/inc/vlangroup_header.html | 14 - netbox/templates/ipam/ipaddress.html | 68 +- netbox/templates/ipam/ipaddress_bulk_add.html | 7 +- netbox/templates/ipam/ipaddress_edit.html | 8 +- netbox/templates/ipam/prefix.html | 81 +- netbox/templates/ipam/prefix/base.html | 38 + .../templates/ipam/prefix/ip_addresses.html | 19 + netbox/templates/ipam/prefix/prefixes.html | 35 + netbox/templates/ipam/prefix_edit.html | 46 - netbox/templates/ipam/prefix_ipaddresses.html | 11 - netbox/templates/ipam/prefix_list.html | 25 + netbox/templates/ipam/prefix_prefixes.html | 11 - netbox/templates/ipam/rir.html | 70 + netbox/templates/ipam/rir_list.html | 23 - netbox/templates/ipam/role.html | 64 + netbox/templates/ipam/routetarget.html | 55 +- netbox/templates/ipam/service.html | 54 +- netbox/templates/ipam/service_edit.html | 7 +- netbox/templates/ipam/vlan.html | 70 +- netbox/templates/ipam/vlan/base.html | 40 + netbox/templates/ipam/vlan/interfaces.html | 9 + netbox/templates/ipam/vlan/vminterfaces.html | 9 + netbox/templates/ipam/vlan_edit.html | 41 +- netbox/templates/ipam/vlan_interfaces.html | 9 - netbox/templates/ipam/vlan_vminterfaces.html | 9 - netbox/templates/ipam/vlangroup.html | 68 + netbox/templates/ipam/vlangroup_edit.html | 113 ++ netbox/templates/ipam/vlangroup_vlans.html | 24 - netbox/templates/ipam/vrf.html | 55 +- netbox/templates/ipam/vrf_edit.html | 42 - netbox/templates/secrets/secret.html | 56 +- netbox/templates/secrets/secret_edit.html | 7 +- netbox/templates/secrets/secretrole.html | 60 + netbox/templates/tenancy/tenant.html | 87 +- netbox/templates/tenancy/tenant_edit.html | 35 - netbox/templates/tenancy/tenantgroup.html | 73 + netbox/templates/users/api_tokens.html | 26 +- netbox/templates/users/profile.html | 2 +- netbox/templates/users/userkey.html | 9 +- .../templatetags/table_config_form.html | 16 +- netbox/templates/virtualization/cluster.html | 223 +-- .../virtualization/cluster/base.html | 55 + .../virtualization/cluster/devices.html | 25 + .../cluster/virtual_machines.html | 15 + .../virtualization/cluster_edit.html | 42 - .../virtualization/clustergroup.html | 60 + .../templates/virtualization/clustertype.html | 60 + .../virtualization/virtualmachine.html | 118 +- .../virtualization/virtualmachine/base.html | 50 + .../virtualmachine/interfaces.html | 54 + .../virtualmachine_component_add.html | 38 - .../virtualization/virtualmachine_edit.html | 69 - .../templates/virtualization/vminterface.html | 68 +- .../virtualization/vminterface_edit.html | 13 +- netbox/tenancy/api/nested_serializers.py | 4 +- netbox/tenancy/api/serializers.py | 18 +- netbox/tenancy/api/views.py | 9 +- netbox/tenancy/{filters.py => filtersets.py} | 9 +- netbox/tenancy/forms.py | 52 +- .../migrations/0012_standardize_models.py | 27 + netbox/tenancy/models.py | 35 +- netbox/tenancy/tables.py | 52 +- netbox/tenancy/tests/test_api.py | 4 +- .../{test_filters.py => test_filtersets.py} | 15 +- netbox/tenancy/tests/test_views.py | 8 +- netbox/tenancy/urls.py | 21 +- netbox/tenancy/views.py | 45 +- netbox/users/admin.py | 23 +- netbox/users/api/nested_serializers.py | 6 +- netbox/users/api/serializers.py | 7 +- netbox/users/api/views.py | 8 +- netbox/users/constants.py | 8 + netbox/users/{filters.py => filtersets.py} | 2 +- .../migrations/0011_standardize_models.py | 21 + netbox/users/models.py | 16 +- netbox/users/tests/test_api.py | 6 +- .../{test_filters.py => test_filtersets.py} | 21 +- netbox/users/views.py | 14 +- netbox/utilities/choices.py | 8 +- netbox/utilities/constants.py | 3 +- netbox/utilities/exceptions.py | 8 + netbox/utilities/filters.py | 206 +-- netbox/utilities/forms/fields.py | 285 +-- netbox/utilities/forms/forms.py | 14 +- netbox/utilities/forms/utils.py | 61 +- netbox/utilities/forms/widgets.py | 8 + netbox/utilities/paginator.py | 12 +- netbox/utilities/query_functions.py | 12 +- netbox/utilities/tables.py | 156 +- .../utilities/templates/buttons/export.html | 39 +- .../templates/widgets/select_speed.html | 20 + netbox/utilities/templatetags/buttons.py | 27 +- netbox/utilities/templatetags/form_helpers.py | 8 + netbox/utilities/templatetags/helpers.py | 46 + netbox/utilities/testing/__init__.py | 2 + netbox/utilities/testing/api.py | 34 +- netbox/utilities/testing/base.py | 161 ++ netbox/utilities/testing/filtersets.py | 35 + netbox/utilities/testing/utils.py | 26 + netbox/utilities/testing/views.py | 238 +-- netbox/utilities/tests/test_filters.py | 8 +- netbox/utilities/utils.py | 32 +- netbox/utilities/views.py | 13 + netbox/vapor/api/serializers.py | 19 +- netbox/vapor/filters.py | 17 +- .../virtualization/api/nested_serializers.py | 15 +- netbox/virtualization/api/serializers.py | 48 +- netbox/virtualization/api/views.py | 18 +- .../{filters.py => filtersets.py} | 60 +- netbox/virtualization/forms.py | 342 ++-- .../migrations/0020_standardize_models.py | 68 + .../0021_virtualmachine_vcpus_decimal.py | 17 + .../migrations/0022_vminterface_parent.py | 17 + .../0023_virtualmachine_natural_ordering.py | 32 + netbox/virtualization/models.py | 84 +- netbox/virtualization/tables.py | 37 +- netbox/virtualization/tests/test_api.py | 11 +- .../{test_filters.py => test_filtersets.py} | 95 +- netbox/virtualization/tests/test_views.py | 47 +- netbox/virtualization/urls.py | 23 +- netbox/virtualization/views.py | 146 +- requirements.extras.txt | 4 +- requirements.txt | 29 +- tox.ini | 7 +- upgrade.sh | 2 +- 462 files changed, 18973 insertions(+), 9368 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/stale.yml create mode 100644 .github/workflows/stale.yml rename docker/initializers/{rack_groups.yml => locations.yml} (100%) rename docker/startup_scripts/{071_rack_groups.py => 075_locations.py} (61%) create mode 100644 docs/additional-features/journaling.md create mode 100644 docs/development/adding-models.md create mode 100644 docs/development/models.md create mode 100644 docs/models/circuits/providernetwork.md create mode 100644 docs/models/dcim/location.md delete mode 100644 docs/models/dcim/rackgroup.md create mode 100644 docs/models/dcim/sitegroup.md create mode 100644 docs/release-notes/version-2.11.md rename netbox/circuits/{filters.py => filtersets.py} (62%) create mode 100644 netbox/circuits/migrations/0025_standardize_models.py create mode 100644 netbox/circuits/migrations/0026_mark_connected.py create mode 100644 netbox/circuits/migrations/0027_providernetwork.py create mode 100644 netbox/circuits/migrations/0028_cache_circuit_terminations.py create mode 100644 netbox/circuits/migrations/0029_circuit_tracing.py delete mode 100644 netbox/circuits/querysets.py rename netbox/circuits/tests/{test_filters.py => test_filtersets.py} (71%) rename netbox/dcim/{filters.py => filtersets.py} (76%) delete mode 100644 netbox/dcim/migrations/0123_region_custom_field_data.py create mode 100644 netbox/dcim/migrations/0123_standardize_models.py create mode 100644 netbox/dcim/migrations/0124_mark_connected.py create mode 100644 netbox/dcim/migrations/0125_console_port_speed.py create mode 100644 netbox/dcim/migrations/0126_rename_rackgroup_location.py create mode 100644 netbox/dcim/migrations/0127_device_location.py create mode 100644 netbox/dcim/migrations/0128_device_location_populate.py create mode 100644 netbox/dcim/migrations/0129_interface_parent.py create mode 100644 netbox/dcim/migrations/0130_sitegroup.py create mode 100644 netbox/dcim/migrations/0131_consoleport_speed.py rename netbox/dcim/tests/{test_filters.py => test_filtersets.py} (80%) create mode 100644 netbox/extras/filtersets.py create mode 100644 netbox/extras/lookups.py create mode 100644 netbox/extras/migrations/0054_standardize_models.py create mode 100644 netbox/extras/migrations/0055_objectchange_data.py create mode 100644 netbox/extras/migrations/0056_extend_configcontext.py create mode 100644 netbox/extras/migrations/0057_customlink_rename_fields.py create mode 100644 netbox/extras/migrations/0058_journalentry.py create mode 100644 netbox/extras/migrations/0059_exporttemplate_as_attachment.py create mode 100644 netbox/extras/models/configcontexts.py rename netbox/extras/tests/{test_filters.py => test_filtersets.py} (55%) create mode 100644 netbox/extras/tests/test_forms.py rename netbox/ipam/{filters.py => filtersets.py} (83%) create mode 100644 netbox/ipam/management/__init__.py create mode 100644 netbox/ipam/management/commands/__init__.py create mode 100644 netbox/ipam/management/commands/rebuild_prefixes.py create mode 100644 netbox/ipam/migrations/0044_standardize_models.py create mode 100644 netbox/ipam/migrations/0045_vlangroup_scope.py create mode 100644 netbox/ipam/migrations/0046_set_vlangroup_scope_types.py create mode 100644 netbox/ipam/migrations/0047_prefix_depth_children.py create mode 100644 netbox/ipam/migrations/0048_prefix_populate_depth_children.py create mode 100644 netbox/ipam/models/__init__.py rename netbox/ipam/{models.py => models/ip.py} (58%) create mode 100644 netbox/ipam/models/services.py create mode 100644 netbox/ipam/models/vlans.py create mode 100644 netbox/ipam/models/vrfs.py create mode 100644 netbox/ipam/signals.py rename netbox/ipam/tests/{test_filters.py => test_filtersets.py} (75%) create mode 100644 netbox/netbox/filtersets.py create mode 100644 netbox/netbox/models.py rename netbox/secrets/{filters.py => filtersets.py} (85%) create mode 100644 netbox/secrets/migrations/0013_standardize_models.py rename netbox/secrets/tests/{test_filters.py => test_filtersets.py} (91%) delete mode 100644 netbox/templates/circuits/circuit_edit.html create mode 100644 netbox/templates/circuits/circuittype.html delete mode 100644 netbox/templates/circuits/inc/speed_widget.html delete mode 100644 netbox/templates/circuits/provider_edit.html create mode 100644 netbox/templates/circuits/providernetwork.html create mode 100644 netbox/templates/dcim/devicerole.html delete mode 100644 netbox/templates/dcim/devicetype_edit.html delete mode 100644 netbox/templates/dcim/inc/device_napalm_tabs.html create mode 100644 netbox/templates/dcim/location.html create mode 100644 netbox/templates/dcim/manufacturer.html create mode 100644 netbox/templates/dcim/platform.html delete mode 100644 netbox/templates/dcim/powerfeed_edit.html delete mode 100644 netbox/templates/dcim/powerpanel_edit.html delete mode 100644 netbox/templates/dcim/rackreservation_edit.html create mode 100644 netbox/templates/dcim/rackrole.html create mode 100644 netbox/templates/dcim/region.html delete mode 100644 netbox/templates/dcim/site_edit.html create mode 100644 netbox/templates/dcim/sitegroup.html create mode 100644 netbox/templates/dcim/trace/attachment.html create mode 100644 netbox/templates/dcim/trace/object.html create mode 100644 netbox/templates/extras/journalentry.html create mode 100644 netbox/templates/extras/object_journal.html delete mode 100644 netbox/templates/extras/tag_edit.html create mode 100644 netbox/templates/generic/object.html delete mode 100644 netbox/templates/inc/created_updated.html delete mode 100644 netbox/templates/ipam/aggregate_edit.html delete mode 100644 netbox/templates/ipam/inc/vlangroup_header.html create mode 100644 netbox/templates/ipam/prefix/base.html create mode 100644 netbox/templates/ipam/prefix/ip_addresses.html create mode 100644 netbox/templates/ipam/prefix/prefixes.html delete mode 100644 netbox/templates/ipam/prefix_edit.html delete mode 100644 netbox/templates/ipam/prefix_ipaddresses.html delete mode 100644 netbox/templates/ipam/prefix_prefixes.html create mode 100644 netbox/templates/ipam/rir.html delete mode 100644 netbox/templates/ipam/rir_list.html create mode 100644 netbox/templates/ipam/role.html create mode 100644 netbox/templates/ipam/vlan/base.html create mode 100644 netbox/templates/ipam/vlan/interfaces.html create mode 100644 netbox/templates/ipam/vlan/vminterfaces.html delete mode 100644 netbox/templates/ipam/vlan_interfaces.html delete mode 100644 netbox/templates/ipam/vlan_vminterfaces.html create mode 100644 netbox/templates/ipam/vlangroup.html create mode 100644 netbox/templates/ipam/vlangroup_edit.html delete mode 100644 netbox/templates/ipam/vlangroup_vlans.html delete mode 100644 netbox/templates/ipam/vrf_edit.html create mode 100644 netbox/templates/secrets/secretrole.html delete mode 100644 netbox/templates/tenancy/tenant_edit.html create mode 100644 netbox/templates/tenancy/tenantgroup.html create mode 100644 netbox/templates/virtualization/cluster/base.html create mode 100644 netbox/templates/virtualization/cluster/devices.html create mode 100644 netbox/templates/virtualization/cluster/virtual_machines.html delete mode 100644 netbox/templates/virtualization/cluster_edit.html create mode 100644 netbox/templates/virtualization/clustergroup.html create mode 100644 netbox/templates/virtualization/clustertype.html create mode 100644 netbox/templates/virtualization/virtualmachine/base.html create mode 100644 netbox/templates/virtualization/virtualmachine/interfaces.html delete mode 100644 netbox/templates/virtualization/virtualmachine_component_add.html delete mode 100644 netbox/templates/virtualization/virtualmachine_edit.html rename netbox/tenancy/{filters.py => filtersets.py} (87%) create mode 100644 netbox/tenancy/migrations/0012_standardize_models.py rename netbox/tenancy/tests/{test_filters.py => test_filtersets.py} (88%) create mode 100644 netbox/users/constants.py rename netbox/users/{filters.py => filtersets.py} (98%) create mode 100644 netbox/users/migrations/0011_standardize_models.py rename netbox/users/tests/{test_filters.py => test_filtersets.py} (89%) create mode 100644 netbox/utilities/templates/widgets/select_speed.html create mode 100644 netbox/utilities/testing/base.py create mode 100644 netbox/utilities/testing/filtersets.py rename netbox/virtualization/{filters.py => filtersets.py} (80%) create mode 100644 netbox/virtualization/migrations/0020_standardize_models.py create mode 100644 netbox/virtualization/migrations/0021_virtualmachine_vcpus_decimal.py create mode 100644 netbox/virtualization/migrations/0022_vminterface_parent.py create mode 100644 netbox/virtualization/migrations/0023_virtualmachine_natural_ordering.py rename netbox/virtualization/tests/{test_filters.py => test_filtersets.py} (88%) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 5b4e4e5a4..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [jeremystretch] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a83e9b34e..07502c78a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,25 +1,29 @@ --- name: 🐛 Bug Report -about: Report a reproducible bug in the current release of NetBox +description: Report a reproducible bug in the current release of NetBox labels: ["type: bug"] body: - type: markdown attributes: - value: "**NOTE:** This form is only for reporting _reproducible bugs_ in a - current NetBox installation. If you're having trouble with installation or just - looking for assistance with using NetBox, please visit our - [discussion forum](https://github.com/netbox-community/netbox/discussions) instead." + value: > + **NOTE:** This form is only for reporting _reproducible bugs_ in a current NetBox + installation. If you're having trouble with installation or just looking for + assistance with using NetBox, please visit our + [discussion forum](https://github.com/netbox-community/netbox/discussions) instead. - type: input attributes: label: NetBox version - description: "What version of NetBox are you currently running?" - placeholder: v2.10.4 + description: > + What version of NetBox are you currently running? (If you don't have access to the most + recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) + before opening a bug report to see if your issue has already been addressed.) + placeholder: v2.11.12 validations: required: true - type: dropdown attributes: label: Python version - description: "What version of Python are you currently running?" + description: What version of Python are you currently running? options: - 3.6 - 3.7 @@ -30,12 +34,14 @@ body: - type: textarea attributes: label: Steps to Reproduce - description: "Describe in detail the exact steps that someone else can take to - reproduce this bug using the current stable release of NetBox. Begin with the - creation of any necessary database objects and call out every operation being - performed explicitly. If reporting a bug in the REST API, be sure to reconstruct - the raw HTTP request(s) being made: Don't rely on a client library such as - pynetbox." + description: > + Describe in detail the exact steps that someone else can take to + reproduce this bug using the current stable release of NetBox. Begin with the + creation of any necessary database objects and call out every operation being + performed explicitly. If reporting a bug in the REST API, be sure to reconstruct + the raw HTTP request(s) being made: Don't rely on a client library such as + pynetbox. Additionally, **do not rely on the demo instance** for reproducing + suspected bugs, as its data is prone to modification or deletion at any time. placeholder: | 1. Click on "create widget" 2. Set foo to 12 and bar to G @@ -45,19 +51,14 @@ body: - type: textarea attributes: label: Expected Behavior - description: "What did you expect to happen?" - placeholder: "A new widget should have been created with the specified attributes" + description: What did you expect to happen? + placeholder: A new widget should have been created with the specified attributes validations: required: true - type: textarea attributes: label: Observed Behavior - description: "What happened instead?" - placeholder: "A TypeError exception was raised" + description: What happened instead? + placeholder: A TypeError exception was raised validations: required: true - - type: markdown - attributes: - value: | - ### Additional information - You can use the space below to provide any additional information or to attach files. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3e1d0167d..1f8fdebd4 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,7 +3,10 @@ blank_issues_enabled: false contact_links: - name: 📖 Contributing Policy url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md - about: Please read through our contributing policy before opening an issue or pull request - - name: 💬 Discussion Group - url: https://groups.google.com/g/netbox-discuss - about: Join our discussion group for assistance with installation issues and other problems + about: "Please read through our contributing policy before opening an issue or pull request" + - name: ❓ Discussion + url: https://github.com/netbox-community/netbox/discussions + about: "If you're just looking for help, try starting a discussion instead" + - name: 💬 Community Slack + url: https://netdev.chat/ + about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems" diff --git a/.github/ISSUE_TEMPLATE/documentation_change.yaml b/.github/ISSUE_TEMPLATE/documentation_change.yaml index 3b2026b34..0f87115fc 100644 --- a/.github/ISSUE_TEMPLATE/documentation_change.yaml +++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml @@ -1,6 +1,6 @@ --- name: 📖 Documentation Change -about: Suggest an addition or modification to the NetBox documentation +description: Suggest an addition or modification to the NetBox documentation labels: ["type: documentation"] body: - type: dropdown @@ -14,25 +14,22 @@ body: - Cleanup (formatting, typos, etc.) validations: required: true - - type: checkboxes + - type: dropdown attributes: label: Area - description: To what section(s) of the documentation does this change pertain? + description: To what section of the documentation does this change primarily pertain? options: - - label: Installation instructions - - label: Configuration parameters - - label: Functionality/features - - label: REST API - - label: Administration/development - - label: Other + - Installation instructions + - Configuration parameters + - Functionality/features + - REST API + - Administration/development + - Other + validations: + required: true - type: textarea attributes: label: Proposed Changes - description: "Describe the proposed changes and why they are necessary" + description: Describe the proposed changes and why they are necessary. validations: required: true - - type: markdown - attributes: - value: | - ### Additional information - You can use the space below to provide any additional information or to attach files. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index efa83b376..f7b616907 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,19 +1,20 @@ --- name: ✨ Feature Request -about: Propose a new NetBox feature or enhancement +description: Propose a new NetBox feature or enhancement labels: ["type: feature"] body: - type: markdown attributes: - value: "**NOTE:** This form is only for submitting well-formed proposals to extend or - modify NetBox in some way. If you're trying to solve a problem but can't figure out how, - or if you still need time to work on the details of a proposed new feature, please start - a [discussion](https://github.com/netbox-community/netbox/discussions) instead." + value: > + **NOTE:** This form is only for submitting well-formed proposals to extend or modify + NetBox in some way. If you're trying to solve a problem but can't figure out how, or if + you still need time to work on the details of a proposed new feature, please start a + [discussion](https://github.com/netbox-community/netbox/discussions) instead. - type: input attributes: label: NetBox version - description: "What version of NetBox are you currently running?" - placeholder: v2.10.4 + description: What version of NetBox are you currently running? + placeholder: v2.11.12 validations: required: true - type: dropdown @@ -28,31 +29,29 @@ body: - type: textarea attributes: label: Proposed functionality - description: "Describe in detail the new feature or behavior you'd like to propose. - Include any specific changes to work flows, data models, or the user interface." + description: > + Describe in detail the new feature or behavior you'd like to propose. Include any specific + changes to work flows, data models, or the user interface. validations: required: true - type: textarea attributes: label: Use case - description: "Explain how adding this functionality would benefit NetBox users. What - need does it address?" + description: > + Explain how adding this functionality would benefit NetBox users. What need does it address? validations: required: true - type: textarea attributes: label: Database changes - description: "Note any changes to the database schema necessary to support the new - feature. For example, does the proposal require adding a new model or field? (Not - all new features require database changes.)" + description: > + Note any changes to the database schema necessary to support the new feature. For example, + does the proposal require adding a new model or field? (Not all new features require database + changes.) - type: textarea attributes: label: External dependencies - description: "List any new dependencies on external libraries or services that this - new feature would introduce. For example, does the proposal require the installation - of a new Python package? (Not all new features introduce new dependencies.)" - - type: markdown - attributes: - value: | - ### Additional information - You can use the space below to provide any additional information or to attach files. + description: > + List any new dependencies on external libraries or services that this new feature would + introduce. For example, does the proposal require the installation of a new Python package? + (Not all new features introduce new dependencies.) diff --git a/.github/ISSUE_TEMPLATE/housekeeping.yaml b/.github/ISSUE_TEMPLATE/housekeeping.yaml index 0f466aa24..777871395 100644 --- a/.github/ISSUE_TEMPLATE/housekeeping.yaml +++ b/.github/ISSUE_TEMPLATE/housekeeping.yaml @@ -1,27 +1,24 @@ --- name: 🏡 Housekeeping -about: A change pertaining to the codebase itself (developers only) +description: A change pertaining to the codebase itself (developers only) labels: ["type: housekeeping"] body: - type: markdown attributes: - value: "**NOTE:** This template is for use by maintainers only. Please do not submit - an issue using this template unless you have been specifically asked to do so." + value: > + **NOTE:** This template is for use by maintainers only. Please do not submit + an issue using this template unless you have been specifically asked to do so. - type: textarea attributes: label: Proposed Changes - description: "Describe in detail the new feature or behavior you'd like to propose. - Include any specific changes to work flows, data models, or the user interface." + description: > + Describe in detail the new feature or behavior you'd like to propose. + Include any specific changes to work flows, data models, or the user interface. validations: required: true - type: textarea attributes: label: Justification - description: "Please provide justification for the proposed change(s)." + description: Please provide justification for the proposed change(s). validations: required: true - - type: markdown - attributes: - value: | - ### Additional information - You can use the space below to provide any additional information or to attach files. diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 92da07e6a..000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Configuration for Stale (https://github.com/apps/stale) - -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 45 - -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 15 - -# Issues with these labels will never be considered stale -exemptLabels: - - "status: accepted" - - "status: blocked" - - "status: needs milestone" - -# Label to use when marking an issue as stale -staleLabel: "pending closure" - -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. NetBox - is governed by a small group of core maintainers which means not all opened - issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). - -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: > - This issue has been automatically closed due to lack of activity. In an - effort to reduce noise, please do not comment any further. Note that the - core maintainers may elect to reopen this issue at a later date if deemed - necessary. diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..d8099923f --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,35 @@ +# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) +name: 'Close stale issues/PRs' +on: + schedule: + - cron: '0 4 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v4 + with: + close-issue-message: > + This issue has been automatically closed due to lack of activity. In an + effort to reduce noise, please do not comment any further. Note that the + core maintainers may elect to reopen this issue at a later date if deemed + necessary. + close-pr-message: > + This PR has been automatically closed due to lack of activity. + days-before-stale: 60 + days-before-close: 30 + exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone' + operations-per-run: 100 + remove-stale-when-updated: false + stale-issue-label: 'pending closure' + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. NetBox + is governed by a small group of core maintainers which means not all opened + issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + stale-pr-label: 'pending closure' + stale-pr-message: > + This PR has been automatically marked as stale because it has not had + recent activity. It will be closed automatically if no further action is + taken. diff --git a/.gitignore b/.gitignore index 3ec36e925..56a6ac06a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.swp /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py +/netbox/project-static/.cache +/netbox/project-static/node_modules /netbox/reports/* !/netbox/reports/__init__.py /netbox/scripts/* diff --git a/.jenkins b/.jenkins index 3b12d0aa7..4441b0103 100644 --- a/.jenkins +++ b/.jenkins @@ -13,6 +13,7 @@ pythonPipeline([ 'pythonVersion': '3.7', 'skipDocs': true, 'skipLint': true, + 'skipUnitTest': true, 'skipIntegrationTest': true, 'skipPrivateRepo': true, 'podTemplate': """ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd62c5ab7..7a3b1f002 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ discussions. ### Slack -For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ). +For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/). Unfortunately, the Slack channel does not provide long-term retention of chat history, so try to avoid it for any discussions would benefit from being preserved for future reference. @@ -160,17 +160,20 @@ accumulating a large backlog of work. The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale) to aid in issue management. -* Issues will be marked as stale after 45 days of no activity. -* Then after 15 more days of inactivity, the issue will be closed. +* Issues will be marked as stale after 60 days of no activity. +* If the stable label is not removed in the following 30 days, the issue will + be closed automatically. * Any issue bearing one of the following labels will be exempt from all Stale bot actions: * `status: accepted` * `status: blocked` * `status: needs milestone` -It is natural that some new issues get more attention than others. Stale bot -helps bring renewed attention to potentially valuable issues that may have been -overlooked. +It is natural that some new issues get more attention than others. The stale +bot helps bring renewed attention to potentially valuable issues that may have +been overlooked. **Do not** comment on an issue that has been marked stale in +an effort to circumvent the bot: Doing so will not remove the stale label. +(Stale labels can be removed only by maintainers.) ## Maintainer Guidance diff --git a/README.md b/README.md index 880fa8c08..cb1991447 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ -![NetBox](docs/netbox_logo.svg "NetBox logo") +
+ NetBox logo +
-NetBox is an IP address management (IPAM) and data center infrastructure -management (DCIM) tool. Initially conceived by the network engineering team at +![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) + +NetBox is an infrastructure resource modeling (IRM) tool designed to empower +network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It is intended to function as a domain-specific source of truth for network operations. @@ -10,41 +14,35 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). -The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). +The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). A public demo instance is available at https://demo.netbox.dev. + +
+

Thank you to our sponsors!

+ + [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud) +            + [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com/) +            + [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/) +
+ [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/) + +
### Discussion * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions -* [Slack](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out +* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out * [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions -### Build Status - -| | status | -|-------------|------------| -| **master** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) | -| **develop** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=develop) | - -### Screenshots - -![Screenshot of main page](docs/media/screenshot1.png "Main page") - ---- - -![Screenshot of rack elevation](docs/media/screenshot2.png "Rack elevation") - ---- - -![Screenshot of prefix hierarchy](docs/media/screenshot3.png "Prefix hierarchy") - -## Installation +### Installation Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases) and run `upgrade.sh`. -## Providing Feedback +### Providing Feedback The best platform for general feedback, assistance, and other discussion is our [GitHub discussions](https://github.com/netbox-community/netbox/discussions). @@ -54,7 +52,15 @@ the [appropriate template](https://github.com/netbox-community/netbox/issues/new If you are interested in contributing to the development of NetBox, please read our [contributing guide](CONTRIBUTING.md) prior to beginning any work. -## Related projects +### Screenshots + +![Screenshot of main page](docs/media/screenshot1.png "Main page") + +![Screenshot of rack elevation](docs/media/screenshot2.png "Rack elevation") + +![Screenshot of prefix hierarchy](docs/media/screenshot3.png "Prefix hierarchy") + +### Related projects Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects. diff --git a/base_requirements.txt b/base_requirements.txt index a57e88604..bf03bf71e 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -93,3 +93,7 @@ redis # SVG image rendering (used for rack elevations) # https://github.com/mozman/svgwrite svgwrite + +# Tabular dataset library (for table-based exports) +# https://github.com/jazzband/tablib +tablib diff --git a/contrib/nginx.conf b/contrib/nginx.conf index 1230f3ce4..34821cd52 100644 --- a/contrib/nginx.conf +++ b/contrib/nginx.conf @@ -1,5 +1,5 @@ server { - listen 443 ssl; + listen [::]:443 ssl ipv6only=off; # CHANGE THIS TO YOUR SERVER'S NAME server_name netbox.example.com; @@ -23,7 +23,7 @@ server { server { # Redirect HTTP traffic to HTTPS - listen 80; + listen [::]:80 ipv6only=off; server_name _; return 301 https://$host$request_uri; } diff --git a/docker/initializers/rack_groups.yml b/docker/initializers/locations.yml similarity index 100% rename from docker/initializers/rack_groups.yml rename to docker/initializers/locations.yml diff --git a/docker/initializers/racks.yml b/docker/initializers/racks.yml index c3626368d..2a6bce404 100644 --- a/docker/initializers/racks.yml +++ b/docker/initializers/racks.yml @@ -1,5 +1,5 @@ - facility_id: a.r1.c1.emul8r.000 - group: Emulator C1 R1 + location: Emulator C1 R1 name: R1 Zone A role: Customer site: Local Emulator (EMUL8R.000) @@ -7,7 +7,7 @@ u_height: '36' width: '19' - facility_id: b.r1.c1.emul8r.000 - group: Emulator C1 R1 + location: Emulator C1 R1 name: R1 Zone B role: Utility site: Local Emulator (EMUL8R.000) @@ -15,7 +15,7 @@ u_height: '36' width: '19' - facility_id: f.r1.c1.emul8r.000 - group: Emulator C1 R1 + location: Emulator C1 R1 name: R1 Facility role: Facility site: Local Emulator (EMUL8R.000) @@ -23,7 +23,7 @@ u_height: '36' width: '19' - facility_id: a.r2.c1.emul8r.000 - group: Emulator C1 R2 + location: Emulator C1 R2 name: R2 Zone A role: Customer site: Local Emulator (EMUL8R.000) @@ -31,7 +31,7 @@ u_height: '36' width: '19' - facility_id: b.r2.c1.emul8r.000 - group: Emulator C1 R2 + location: Emulator C1 R2 name: R2 Zone B role: Utility site: Local Emulator (EMUL8R.000) @@ -39,7 +39,7 @@ u_height: '36' width: '19' - facility_id: f.r2.c1.emul8r.000 - group: Emulator C1 R2 + location: Emulator C1 R2 name: R2 Facility role: Facility site: Local Emulator (EMUL8R.000) @@ -47,7 +47,7 @@ u_height: '36' width: '19' - facility_id: a.r3.c1.emul8r.000 - group: Emulator C1 R3 + location: Emulator C1 R3 name: R3 Zone A role: Customer site: Local Emulator (EMUL8R.000) @@ -55,7 +55,7 @@ u_height: '36' width: '19' - facility_id: b.r3.c1.emul8r.000 - group: Emulator C1 R3 + location: Emulator C1 R3 name: R3 Zone B role: Utility site: Local Emulator (EMUL8R.000) @@ -63,7 +63,7 @@ u_height: '36' width: '19' - facility_id: f.r3.c1.emul8r.000 - group: Emulator C1 R3 + location: Emulator C1 R3 name: R3 Facility role: Facility site: Local Emulator (EMUL8R.000) @@ -71,7 +71,7 @@ u_height: '36' width: '19' - facility_id: a.r4.c1.emul8r.000 - group: Emulator C1 R4 + location: Emulator C1 R4 name: R4 Zone A role: Customer site: Local Emulator (EMUL8R.000) @@ -79,7 +79,7 @@ u_height: '36' width: '19' - facility_id: b.r4.c1.emul8r.000 - group: Emulator C1 R4 + location: Emulator C1 R4 name: R4 Zone B role: Utility site: Local Emulator (EMUL8R.000) @@ -87,7 +87,7 @@ u_height: '36' width: '19' - facility_id: f.r4.c1.emul8r.000 - group: Emulator C1 R4 + location: Emulator C1 R4 name: R4 Facility role: Facility site: Local Emulator (EMUL8R.000) @@ -95,7 +95,7 @@ u_height: '36' width: '19' - facility_id: a.r5.c1.emul8r.000 - group: Emulator C1 R5 + location: Emulator C1 R5 name: R5 Zone A role: Customer site: Local Emulator (EMUL8R.000) @@ -103,7 +103,7 @@ u_height: '36' width: '19' - facility_id: b.r5.c1.emul8r.000 - group: Emulator C1 R5 + location: Emulator C1 R5 name: R5 Zone B role: Utility site: Local Emulator (EMUL8R.000) @@ -111,7 +111,7 @@ u_height: '36' width: '19' - facility_id: f.r5.c1.emul8r.000 - group: Emulator C1 R5 + location: Emulator C1 R5 name: R5 Facility role: Facility site: Local Emulator (EMUL8R.000) @@ -119,7 +119,7 @@ u_height: '36' width: '19' - facility_id: a.r6.c1.emul8r.000 - group: Emulator C1 R6 + location: Emulator C1 R6 name: R6 Zone A role: Customer site: Local Emulator (EMUL8R.000) @@ -127,7 +127,7 @@ u_height: '36' width: '19' - facility_id: b.r6.c1.emul8r.000 - group: Emulator C1 R6 + location: Emulator C1 R6 name: R6 Zone B role: Utility site: Local Emulator (EMUL8R.000) @@ -135,7 +135,7 @@ u_height: '36' width: '19' - facility_id: f.r6.c1.emul8r.000 - group: Emulator C1 R6 + location: Emulator C1 R6 name: R6 Facility role: Facility site: Local Emulator (EMUL8R.000) diff --git a/docker/startup_scripts/071_rack_groups.py b/docker/startup_scripts/075_locations.py similarity index 61% rename from docker/startup_scripts/071_rack_groups.py rename to docker/startup_scripts/075_locations.py index d3cc6f9c4..a8f248132 100644 --- a/docker/startup_scripts/071_rack_groups.py +++ b/docker/startup_scripts/075_locations.py @@ -1,8 +1,8 @@ -from dcim.models import Site,RackGroup +from dcim.models import Location, Site from startup_script_utils import load_yaml import sys -rack_groups = load_yaml('/opt/netbox/initializers/rack_groups.yml') +rack_groups = load_yaml('/opt/netbox/initializers/locations.yml') if rack_groups is None: sys.exit() @@ -18,7 +18,7 @@ for params in rack_groups: query = { field: params.pop(assoc) } params[assoc] = model.objects.get(**query) - rack_group, created = RackGroup.objects.get_or_create(**params) + location, created = Location.objects.get_or_create(**params) if created: - print("🎨 Created rack group", rack_group.name) + print("🎨 Created rack group", location.name) diff --git a/docker/startup_scripts/080_racks.py b/docker/startup_scripts/080_racks.py index 45046245d..5d090f241 100644 --- a/docker/startup_scripts/080_racks.py +++ b/docker/startup_scripts/080_racks.py @@ -1,6 +1,6 @@ import sys -from dcim.models import Site, RackRole, Rack, RackGroup +from dcim.models import Site, RackRole, Rack, Location from startup_script_utils import * from tenancy.models import Tenant @@ -16,7 +16,7 @@ required_assocs = { optional_assocs = { 'role': (RackRole, 'name'), 'tenant': (Tenant, 'name'), - 'group': (RackGroup, 'name') + 'location': (Location, 'name') } for params in racks: diff --git a/docker/startup_scripts/270_cables.py b/docker/startup_scripts/270_cables.py index 9e7d744ff..f16903221 100644 --- a/docker/startup_scripts/270_cables.py +++ b/docker/startup_scripts/270_cables.py @@ -24,14 +24,14 @@ switches = get_devices('access-switch') i = 0 for locker in lockers: # break if i is larger than switch interface's size. - if i > len(switches[0].vc_interfaces)-1: + if i > len(switches[0].vc_interfaces())-1: break - i12 = locker.vc_interfaces[0] - i34 = locker.vc_interfaces[1] + i12 = (locker.vc_interfaces())[0] + i34 = (locker.vc_interfaces())[1] - efr1 = switches[0].vc_interfaces[i] - efr2 = switches[1].vc_interfaces[i] + efr1 = (switches[0].vc_interfaces())[i] + efr2 = (switches[1].vc_interfaces())[i] try: c1 = Cable.objects.create(termination_a=i12, termination_b=efr1) diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md index 359d65202..ebe91f37d 100644 --- a/docs/additional-features/caching.md +++ b/docs/additional-features/caching.md @@ -1,12 +1,15 @@ # Caching -NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../../configuration/optional-settings/#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache. +NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter. Within that time, all recurrences of that specific query will return the pre-fetched results from the cache. + +!!! warning + In NetBox v2.11.10 and later queryset caching is disabled by default, and must be configured. If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database. ## Invalidating Cached Data -Although caching is performed automatically and rarely requires administrative intervention, NetBox provides the `invalidate` management command to force invalidation of cached results. This command can reference a specific object my its type and numeric ID: +Although caching is performed automatically and rarely requires administrative intervention, NetBox provides the `invalidate` management command to force invalidation of cached results. This command can reference a specific object by its type and numeric ID: ```no-highlight $ python netbox/manage.py invalidate dcim.Device.34 diff --git a/docs/additional-features/change-logging.md b/docs/additional-features/change-logging.md index d580ccc6c..3eb99c94c 100644 --- a/docs/additional-features/change-logging.md +++ b/docs/additional-features/change-logging.md @@ -1,6 +1,6 @@ # Change Logging -Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object is saved to the database, along with meta data including the current time and the user associated with the change. These records form a persistent record of changes both for each individual object as well as NetBox as a whole. The global change log can be viewed by navigating to Other > Change Log. +Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object taken both before and after the change is saved to the database, along with meta data including the current time and the user associated with the change. These records form a persistent record of changes both for each individual object as well as NetBox as a whole. The global change log can be viewed by navigating to Other > Change Log. A serialized representation of the instance being modified is included in JSON format. This is similar to how objects are conveyed within the REST API, but does not include any nested representations. For instance, the `tenant` field of a site will record only the tenant's ID, not a representation of the tenant. diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md index 91bf03379..649f69256 100644 --- a/docs/additional-features/custom-fields.md +++ b/docs/additional-features/custom-fields.md @@ -16,6 +16,7 @@ Custom fields must be created through the admin UI under Extras > Custom Fields. * Date: A date in ISO 8601 format (YYYY-MM-DD) * URL: This will be presented as a link in the web UI * Selection: A selection of one of several pre-defined custom choices +* Multiple selection: A selection field which supports the assignment of multiple values Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. @@ -23,7 +24,7 @@ Marking a field as required will force the user to provide a value for the field The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. -A custom field must be assigned to one or object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. +A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. ### Custom Field Validation @@ -37,7 +38,13 @@ NetBox supports limited custom validation for custom field values. Following are Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. -If a default value is specified for a selection field, it must exactly match one of the provided choices. +If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. + +## Custom Fields in Templates + +Several features within NetBox, such as export templates and webhooks, utilize Jinja2 templating. For convenience, objects which support custom field assignment expose custom field data through the `cf` property. This is a bit cleaner than accessing custom field data through the actual field (`custom_field_data`). + +For example, a custom field named `foo123` on the Site model is accessible on an instance as `{{ site.cf.foo123 }}`. ## Custom Fields and the REST API diff --git a/docs/additional-features/custom-links.md b/docs/additional-features/custom-links.md index 0a00b6d68..196371ce3 100644 --- a/docs/additional-features/custom-links.md +++ b/docs/additional-features/custom-links.md @@ -17,6 +17,9 @@ When viewing a device named Router4, this link would render as: Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links. +!!! warning + Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. + ## Context Data The following context data is available within the template when rendering a custom link's text or URL. diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 28d1bf856..8fe3661ed 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -170,18 +170,13 @@ Similar to `ChoiceVar`, but allows for the selection of multiple choices. A particular object within NetBox. Each ObjectVar must specify a particular model, and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below. * `model` - The model class -* `display_field` - The name of the REST API object field to display in the selection list (default: `'name'`) +* `display_field` - The name of the REST API object field to display in the selection list (default: `'display'`) * `query_params` - A dictionary of query parameters to use when retrieving available options (optional) * `null_option` - A label representing a "null" or empty choice (optional) -The `display_field` argument is useful when referencing a model which does not have a `name` field. For example, when displaying a list of device types, you would likely use the `model` field: - -```python -device_type = ObjectVar( - model=DeviceType, - display_field='model' -) -``` +!!! warning + The `display_field` parameter is now deprecated, and will be removed in NetBox v3.0. All ObjectVar instances will + instead use the new standard `display` field for all serializers (introduced in NetBox v2.11). To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status: diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md index 1e0611f06..660807444 100644 --- a/docs/additional-features/export-templates.md +++ b/docs/additional-features/export-templates.md @@ -2,10 +2,16 @@ NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. -Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. +Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). +!!! note + The name `table` is reserved for internal use. + +!!! warning + Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users. + The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: ```jinja2 @@ -18,6 +24,16 @@ Height: {{ rack.u_height }}U To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. +If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example: +``` +{% for server in queryset %} +{% set data = server.get_config_context() %} +{{ data.syslog }} +{% endfor %} +``` + +The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) + A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. ## Example diff --git a/docs/additional-features/journaling.md b/docs/additional-features/journaling.md new file mode 100644 index 000000000..ce126bf27 --- /dev/null +++ b/docs/additional-features/journaling.md @@ -0,0 +1,5 @@ +# Journaling + +All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. + +Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created. diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md index c8a3665b9..957a5a214 100644 --- a/docs/additional-features/napalm.md +++ b/docs/additional-features/napalm.md @@ -2,6 +2,13 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally. +The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met: + +* Device status is "Active" +* A primary IP has been assigned to the device +* A platform with a NAPALM driver has been assigned +* The authenticated user has the `dcim.napalm_read_device` permission + !!! note To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information. @@ -22,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment ## Authentication -By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. +By default, the [`NAPALM_USERNAME`](../configuration/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. ``` $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md index 1266c22ec..0d75abd21 100644 --- a/docs/additional-features/reports.md +++ b/docs/additional-features/reports.md @@ -12,7 +12,7 @@ A NetBox report is a mechanism for validating the integrity of data within NetBo ## Writing Reports -Reports must be saved as files in the [`REPORTS_ROOT`](../../configuration/optional-settings/#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. +Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. !!! warning The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. @@ -80,7 +80,7 @@ class DeviceConnectionsReport(Report): self.log_success(device) ``` -As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. +As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. Also note that the `description` attribute support markdown syntax. It will be rendered in the report list page. !!! warning Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data. @@ -93,7 +93,7 @@ The following methods are available to log results within a report: * log_warning(object, message) * log_failure(object, message) -The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. +The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page. To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`. diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index d6fa27f51..4fce4e037 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -2,6 +2,9 @@ A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks. +!!! warning + Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. + ## Configuration * **Name** - A unique name for the webhook. The name is not included with outbound messages. @@ -38,7 +41,8 @@ The following data is available as context for Jinja2 templates: * `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format). * `username` - The name of the user account associated with the change. * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. -* `data` - A serialized representation of the object _after_ the change was made. This is typically equivalent to the model's representation in NetBox's REST API. +* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API. +* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. ### Default Request Body @@ -47,7 +51,7 @@ If no body template is specified, the request body will be populated with a JSON ```no-highlight { "event": "created", - "timestamp": "2020-02-25 15:10:26.010582+00:00", + "timestamp": "2021-03-09 17:55:33.968016+00:00", "model": "site", "username": "jstretch", "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a", @@ -62,6 +66,17 @@ If no body template is specified, the request body will be populated with a JSON }, "region": null, ... + }, + "snapshots": { + "prechange": null, + "postchange": { + "created": "2021-03-09", + "last_updated": "2021-03-09T17:55:33.851Z", + "name": "Site 1", + "slug": "site-1", + "status": "active", + ... + } } } ``` diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index c66c65543..c7c8996dc 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -10,7 +10,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's | ----------- | ----------- | | `{"status": "active"}` | Status is active | | `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved | -| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing | +| `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing | | `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) | | `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) | | `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 | diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 23c1082bc..ee956edf5 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -12,13 +12,16 @@ NetBox employs a [PostgreSQL](https://www.postgresql.org/) database, so general Use the `pg_dump` utility to export the entire database to a file: ```no-highlight -pg_dump netbox > netbox.sql +pg_dump --username netbox --password --host localhost netbox > netbox.sql ``` +!!! note + You may need to change the username, host, and/or database in the command above to match your installation. + When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data. ```no-highlight -pg_dump --exclude-table-data=extras_objectchange netbox > netbox.sql +pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql ``` ### Load an Exported Database @@ -41,7 +44,7 @@ Keep in mind that PostgreSQL user accounts and permissions are not included with If you want to export only the database schema, and not the data itself (e.g. for development reference), do the following: ```no-highlight -pg_dump -s netbox > netbox_schema.sql +pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.sql ``` --- diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 4af83493e..bde911a0e 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -54,9 +54,9 @@ BASE_PATH = 'netbox/' ## CACHE_TIMEOUT -Default: 900 +Default: 0 (disabled) -The number of seconds that cache entries will be retained before expiring. +The number of seconds that cached database queries will be retained before expiring. --- @@ -257,6 +257,16 @@ LOGGING = { --- +## LOGIN_PERSISTENCE + +Default: False + +If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days. + +Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely. + +--- + ## LOGIN_REQUIRED Default: False @@ -281,6 +291,14 @@ Setting this to True will display a "maintenance mode" banner at the top of ever --- +## MAPS_URL + +Default: `https://maps.google.com/?q=` (Google Maps) + +This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. + +--- + ## MAX_PAGE_SIZE Default: 1000 @@ -301,7 +319,7 @@ The file path to the location where media files (such as image attachments) are Default: False -Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../../additional-features/prometheus-metrics/) documentation for more details. +Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details. --- @@ -507,6 +525,14 @@ The file path to the location where custom scripts will be kept. By default, thi --- +## SESSION_COOKIE_NAME + +Default: `sessionid` + +The name used for the session cookie. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. + +--- + ## SESSION_FILE_PATH Default: None diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index dba8cdc8c..3158fc73a 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -66,6 +66,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes * `PASSWORD` - Redis password (if set) * `DATABASE` - Numeric database ID * `SSL` - Use SSL connection to Redis +* `INSECURE_SKIP_TLS_VERIFY` - Set to `True` to **disable** TLS certificate verification (not recommended) An example configuration is provided below: diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index 43b911308..51261858c 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -1,6 +1,7 @@ # Circuits {!docs/models/circuits/provider.md!} +{!docs/models/circuits/providernetwork.md!} --- diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 96dcf866d..e05d6efd3 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -8,6 +8,8 @@ ## Device Components +Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources. + {!docs/models/dcim/consoleport.md!} {!docs/models/dcim/consoleserverport.md!} {!docs/models/dcim/powerport.md!} diff --git a/docs/core-functionality/sites-and-racks.md b/docs/core-functionality/sites-and-racks.md index 073756fa1..1b5ee3ad1 100644 --- a/docs/core-functionality/sites-and-racks.md +++ b/docs/core-functionality/sites-and-racks.md @@ -1,11 +1,12 @@ # Sites and Racks -{!docs/models/dcim/site.md!} {!docs/models/dcim/region.md!} +{!docs/models/dcim/sitegroup.md!} +{!docs/models/dcim/site.md!} +{!docs/models/dcim/location.md!} --- {!docs/models/dcim/rack.md!} -{!docs/models/dcim/rackgroup.md!} {!docs/models/dcim/rackrole.md!} {!docs/models/dcim/rackreservation.md!} diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md new file mode 100644 index 000000000..6b778d886 --- /dev/null +++ b/docs/development/adding-models.md @@ -0,0 +1,85 @@ +# Adding Models + +## 1. Define the model class + +Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. + +Each model should define, at a minimum: + +* A `__str__()` method returning a user-friendly string representation of the instance +* A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`) +* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID) + +## 2. Define field choices + +If the model has one or more fields with static choices, define those choices in `choices.py` by subclassing `utilities.choices.ChoiceSet`. + +## 3. Generate database migrations + +Once your model definition is complete, generate database migrations by running `manage.py -n $NAME --no-header`. Always specify a short unique name when generating migrations. + +!!! info + Set `DEVELOPER = True` in your NetBox configuration to enable the creation of new migrations. + +## 4. Add all standard views + +Most models will need view classes created in `views.py` to serve the following operations: + +* List view +* Detail view +* Edit view +* Delete view +* Bulk import +* Bulk edit +* Bulk delete + +## 5. Add URL paths + +Add the relevant URL path for each view created in the previous step to `urls.py`. + +## 6. Create the FilterSet + +Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class. + +Every model FilterSet should define a `q` filter to support general search queries. + +## 7. Create the table + +Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns. + +## 8. Create the object template + +Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`. + +## 9. Add the model to the navigation menu + +For NetBox releases prior to v3.0, add the relevant link(s) to the navigation menu template. For later releases, add the relevant items in `netbox/netbox/navigation_menu.py`. + +## 10. REST API components + +Create the following for each model: + +* Detailed (full) model serializer in `api/serializers.py` +* Nested serializer in `api/nested_serializers.py` +* API view in `api/views.py` +* Endpoint route in `api/urls.py` + +## 11. GraphQL API components (v3.0+) + +Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. + +Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. + +## 12. Add tests + +Add tests for the following: + +* UI views +* API views +* Filter sets + +## 13. Documentation + +Create a new documentation page for the model in `docs/models//.md`. Include this file under the "features" documentation where appropriate. + +Also add your model to the index in `docs/development/models.md`. diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 9b2249653..beeae2ffb 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -5,8 +5,8 @@ Getting started with NetBox development is pretty straightforward, and should feel very familiar to anyone with Django development experience. There are a few things you'll need: * A Linux system or environment -* A PostgreSQL server, which can be installed locally [per the documentation](/installation/1-postgresql/) -* A Redis server, which can also be [installed locally](/installation/2-redis/) +* A PostgreSQL server, which can be installed locally [per the documentation](../installation/1-postgresql.md) +* A Redis server, which can also be [installed locally](../installation/2-redis.md) * A supported version of Python ### Fork the Repo diff --git a/docs/development/index.md b/docs/development/index.md index bbcb1eac8..b856e315b 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -8,7 +8,7 @@ There are several official forums for communication among the developers and com * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. -* [#netbox on NetDev Community Slack](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. +* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. * [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions. ## Governance diff --git a/docs/development/models.md b/docs/development/models.md new file mode 100644 index 000000000..7dec2cb61 --- /dev/null +++ b/docs/development/models.md @@ -0,0 +1,98 @@ +# NetBox Models + +## Model Types + +A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type. + +The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/) framework can be used to reference models within the database. A ContentType instance references a model by its `app_label` and `name`: For example, the Site model is referred to as `dcim.site`. The content type combined with an object's primary key form a globally unique identifier for the object (e.g. `dcim.site:123`). + +### Features Matrix + +* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log +* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects +* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields +* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models +* [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags +* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary +* Nesting - These models can be nested recursively to create a hierarchy + +| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | +| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | +| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | +| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | | +| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | | | :material-check: | +| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | +| Component Template | :material-check: | :material-check: | :material-check: | | | | | + +## Models Index + +### Primary Models + +* [circuits.Circuit](../models/circuits/circuit.md) +* [circuits.Provider](../models/circuits/provider.md) +* [circuits.ProviderNetwork](../models/circuits/providernetwork.md) +* [dcim.Cable](../models/dcim/cable.md) +* [dcim.Device](../models/dcim/device.md) +* [dcim.DeviceType](../models/dcim/devicetype.md) +* [dcim.PowerFeed](../models/dcim/powerfeed.md) +* [dcim.PowerPanel](../models/dcim/powerpanel.md) +* [dcim.Rack](../models/dcim/rack.md) +* [dcim.RackReservation](../models/dcim/rackreservation.md) +* [dcim.Site](../models/dcim/site.md) +* [dcim.VirtualChassis](../models/dcim/virtualchassis.md) +* [ipam.Aggregate](../models/ipam/aggregate.md) +* [ipam.IPAddress](../models/ipam/ipaddress.md) +* [ipam.Prefix](../models/ipam/prefix.md) +* [ipam.RouteTarget](../models/ipam/routetarget.md) +* [ipam.Service](../models/ipam/service.md) +* [ipam.VLAN](../models/ipam/vlan.md) +* [ipam.VRF](../models/ipam/vrf.md) +* [secrets.Secret](../models/secrets/secret.md) +* [tenancy.Tenant](../models/tenancy/tenant.md) +* [virtualization.Cluster](../models/virtualization/cluster.md) +* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md) + +### Organizational Models + +* [circuits.CircuitType](../models/circuits/circuittype.md) +* [dcim.DeviceRole](../models/dcim/devicerole.md) +* [dcim.Manufacturer](../models/dcim/manufacturer.md) +* [dcim.Platform](../models/dcim/platform.md) +* [dcim.RackRole](../models/dcim/rackrole.md) +* [ipam.RIR](../models/ipam/rir.md) +* [ipam.Role](../models/ipam/role.md) +* [ipam.VLANGroup](../models/ipam/vlangroup.md) +* [secrets.SecretRole](../models/secrets/secretrole.md) +* [virtualization.ClusterGroup](../models/virtualization/clustergroup.md) +* [virtualization.ClusterType](../models/virtualization/clustertype.md) + +### Nested Group Models + +* [dcim.Location](../models/dcim/location.md) (formerly RackGroup) +* [dcim.Region](../models/dcim/region.md) +* [dcim.SiteGroup](../models/dcim/sitegroup.md) +* [tenancy.TenantGroup](../models/tenancy/tenantgroup.md) + +### Component Models + +* [dcim.ConsolePort](../models/dcim/consoleport.md) +* [dcim.ConsoleServerPort](../models/dcim/consoleserverport.md) +* [dcim.DeviceBay](../models/dcim/devicebay.md) +* [dcim.FrontPort](../models/dcim/frontport.md) +* [dcim.Interface](../models/dcim/interface.md) +* [dcim.InventoryItem](../models/dcim/inventoryitem.md) +* [dcim.PowerOutlet](../models/dcim/poweroutlet.md) +* [dcim.PowerPort](../models/dcim/powerport.md) +* [dcim.RearPort](../models/dcim/rearport.md) +* [virtualization.VMInterface](../models/virtualization/vminterface.md) + +### Component Template Models + +* [dcim.ConsolePortTemplate](../models/dcim/consoleporttemplate.md) +* [dcim.ConsoleServerPortTemplate](../models/dcim/consoleserverporttemplate.md) +* [dcim.DeviceBayTemplate](../models/dcim/devicebaytemplate.md) +* [dcim.FrontPortTemplate](../models/dcim/frontporttemplate.md) +* [dcim.InterfaceTemplate](../models/dcim/interfacetemplate.md) +* [dcim.PowerOutletTemplate](../models/dcim/poweroutlettemplate.md) +* [dcim.PowerPortTemplate](../models/dcim/powerporttemplate.md) +* [dcim.RearPortTemplate](../models/dcim/rearporttemplate.md) diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index d8cb671f6..91d5ab2ab 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -70,7 +70,11 @@ Ensure that continuous integration testing on the `develop` branch is completing ### Update Version and Changelog -Update the `VERSION` constant in `settings.py` to the new release version and annotate the current data in the release notes for the new version. Commit these changes to the `develop` branch. +* Update the `VERSION` constant in `settings.py` to the new release version. +* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`. +* Replace the "FUTURE" placeholder in the release notes with the current date. + +Commit these changes to the `develop` branch. ### Submit a Pull Request diff --git a/docs/index.md b/docs/index.md index 9dedfedff..0fc0dc0b7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,8 +1,8 @@ -![NetBox](netbox_logo.svg "NetBox logo") +![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} # What is NetBox? -NetBox is an open source web application designed to help manage and document computer networks. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It encompasses the following aspects of network management: +NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management: * **IP address management (IPAM)** - IP networks and addresses, VRFs, and VLANs * **Equipment racks** - Organized by group and site diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index bcc670e2b..644b2715c 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -7,32 +7,31 @@ This section entails the installation and configuration of a local PostgreSQL da ## Installation -#### Ubuntu +=== "Ubuntu" -Install the PostgreSQL server and client development libraries using `apt`. + ```no-highlight + sudo apt update + sudo apt install -y postgresql + ``` -```no-highlight -sudo apt update -sudo apt install -y postgresql libpq-dev -``` +=== "CentOS" -#### CentOS + ```no-highlight + sudo yum install -y postgresql-server + sudo postgresql-setup --initdb + ``` -PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). + !!! info + PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). -```no-highlight -sudo yum install -y postgresql-server libpq-devel -sudo postgresql-setup --initdb -``` + CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: -CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: + ```no-highlight + host all all 127.0.0.1/32 md5 + host all all ::1/128 md5 + ``` -```no-highlight -host all all 127.0.0.1/32 md5 -host all all ::1/128 md5 -``` - -Then, start the service and enable it to run at boot: +Once PostgreSQL has been installed, start the service and enable it to run at boot: ```no-highlight sudo systemctl start postgresql diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md index 19ec9073e..e31873d2b 100644 --- a/docs/installation/2-redis.md +++ b/docs/installation/2-redis.md @@ -7,19 +7,19 @@ !!! note NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details. -### Ubuntu +=== "Ubuntu" -```no-highlight -sudo apt install -y redis-server -``` + ```no-highlight + sudo apt install -y redis-server + ``` -### CentOS +=== "CentOS" -```no-highlight -sudo yum install -y redis -sudo systemctl start redis -sudo systemctl enable redis -``` + ```no-highlight + sudo yum install -y redis + sudo systemctl start redis + sudo systemctl enable redis + ``` You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient. diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index f2461b40d..eb717093d 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -9,17 +9,17 @@ Begin by installing all system packages required by NetBox and its dependencies. !!! note NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. -### Ubuntu +=== "Ubuntu" -```no-highlight -sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev -``` + ```no-highlight + sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev + ``` -### CentOS +=== "CentOS" -```no-highlight -sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config -``` + ```no-highlight + sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config + ``` Before continuing with either platform, update pip (Python's package management tool) to its latest release: @@ -57,22 +57,27 @@ sudo mkdir -p /opt/netbox/ && cd /opt/netbox/ If `git` is not already installed, install it: -#### Ubuntu +=== "Ubuntu" -```no-highlight -sudo apt install -y git -``` + ```no-highlight + sudo apt install -y git + ``` -#### CentOS +=== "CentOS" -```no-highlight -sudo yum install -y git -``` + ```no-highlight + sudo yum install -y git + ``` Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.) ```no-highlight $ sudo git clone -b master https://github.com/netbox-community/netbox.git . +``` + +The screen below should be the result: + +``` Cloning into '.'... remote: Counting objects: 1994, done. remote: Compressing objects: 100% (150/150), done. @@ -89,20 +94,20 @@ Checking connectivity... done. Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files. -#### Ubuntu +=== "Ubuntu" -``` -sudo adduser --system --group netbox -sudo chown --recursive netbox /opt/netbox/netbox/media/ -``` + ``` + sudo adduser --system --group netbox + sudo chown --recursive netbox /opt/netbox/netbox/media/ + ``` -#### CentOS +=== "CentOS" -``` -sudo groupadd --system netbox -sudo adduser --system -g netbox netbox -sudo chown --recursive netbox /opt/netbox/netbox/media/ -``` + ``` + sudo groupadd --system netbox + sudo adduser --system -g netbox netbox + sudo chown --recursive netbox /opt/netbox/netbox/media/ + ``` ## Configuration @@ -113,7 +118,7 @@ cd /opt/netbox/netbox/netbox/ sudo cp configuration.example.py configuration.py ``` -Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations: +Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: * `ALLOWED_HOSTS` * `DATABASE` @@ -136,7 +141,7 @@ ALLOWED_HOSTS = ['*'] ### DATABASE -This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](/configuration/required-settings/#database) for more detail on individual parameters. +This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-settings.md#database) for more detail on individual parameters. ```python DATABASE = { @@ -151,7 +156,7 @@ DATABASE = { ### REDIS -Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters. +Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings.md#redis) for more detail on individual parameters. Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID. @@ -198,15 +203,15 @@ All Python packages required by NetBox are listed in `requirements.txt` and will The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device. ```no-highlight -sudo echo napalm >> /opt/netbox/local_requirements.txt +sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt" ``` ### Remote File Storage -By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`. +By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`. ```no-highlight -sudo echo django-storages >> /opt/netbox/local_requirements.txt +sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt" ``` ## Run the Upgrade Script @@ -262,9 +267,16 @@ Starting development server at http://0.0.0.0:8000/ Quit the server with CONTROL-C. ``` +!!! note + By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts): + + ```no-highlight + firewall-cmd --zone=public --add-port=8000/tcp + ``` + Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, . You should be greeted with the NetBox home page. -!!! warning +!!! danger The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.** !!! warning diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 1c5424595..86114dfb0 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -30,7 +30,7 @@ pip3 install django-auth-ldap Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment: ```no-highlight -sudo echo django-auth-ldap >> /opt/netbox/local_requirements.txt +sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt" ``` ## Configuration @@ -74,7 +74,7 @@ STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the ### User Authentication !!! info - When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. + When using Windows Server 2012+, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. ```python from django_auth_ldap.config import LDAPSearch @@ -142,7 +142,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600 `systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`. -For troubleshooting LDAP user/group queries, add or merge the following [logging](/configuration/optional-settings.md#logging) configuration to `configuration.py`: +For troubleshooting LDAP user/group queries, add or merge the following [logging](../configuration/optional-settings.md#logging) configuration to `configuration.py`: ```python LOGGING = { diff --git a/docs/installation/index.md b/docs/installation/index.md index 71e669295..1c2fcf567 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -23,6 +23,9 @@ The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 | PostgreSQL | 9.6 | | Redis | 4.0 | +!!! note + Python 3.7 or later will be required in NetBox v3.0. Users are strongly encouraged to install NetBox using Python 3.7 or later for new deployments. + Below is a simplified overview of the NetBox application stack for reference: ![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png) @@ -30,6 +33,3 @@ Below is a simplified overview of the NetBox application stack for reference: ## Upgrading If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md). - -!!! note - Beginning with v2.5.9, the official documentation calls for systemd to be used for managing the WSGI workers in place of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord. diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index cdd20f01b..e824ad7ab 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -2,7 +2,7 @@ ## Review the Release Notes -Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. +Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. ## Update Dependencies to Required Versions diff --git a/docs/media/installation/netbox_application_stack.png b/docs/media/installation/netbox_application_stack.png index e8634490074d8e38883ca333896c2cdf513e5631..c860b964b1a27228423f414ca546150b1ff33393 100644 GIT binary patch literal 35722 zcmc$`by$>N7dEP*2-2c-NJ=xb0|-cWcZW2HFod)UGDAx@$j~*EfV4vjkh zCU6D&6Z_1qTW@bENWaj2W4f7f-;Y`^<;X?SrIEvfvT-xOukX$RR|SW8++9g|I*H9- zLdCa_CCYudW`#)ucoaw3+L3^B3|~iRC(BD zwgwKWc@2Oe`Nq`9rQ>+)yI6F=gp&Vn{uAmEb?;7=Jl0)=8t&cgD*g~SOBh_3fBE$j z(22y8U77vq7g)ca=;mCf=2<85I922Vw^@pDYK#WMGZGOzSxmu%(dGgN7Kw)R7q>qF zHw(A<`4|XKV5i1(!5yH9>x(WKyUj;38jr>qsPXUC8i9N2fo%*9-Q#wG7G#vRjVY2cE^)#JVeHC-6(K zT0?8Bb1X*9PcqNMzh-S};kudq+H(D*^&-5NmQOb03h83(9pk>YVOBSIzP2j1+PlN8 z*u19*?v69NEM|{}pQ$ehWr2Y%eRXJ}jUM{MiiV@duFnG%0>-V@5-F6z)25w^x z_wyy3ydx+?UHF|hh0ptq>g2Z;)>LT@R4&hXnRq`M*Asq(b@AN~MJQ?c_HL?FF>PUM za(mSWoE54OM15C+E!z@lYsDb>=lFyo2HMNQ65~KZB%KGVG(=uLB31$-e~7^puqK5* zNowT~-s@3o06v{Mn5na+qJsY3Exz5$r&H;WK!!M)(4Bo^bNxFS;dc3gA3Bx?v_i+D zcn3*0j`O(nFp(_-td(aL*sORp;H5qN{$BCbiK|Y1K(p{=iCKNG6_x2CrAuK$J5&dm z`J8Ph^y%2}pr0)It*ZZin&$SD_+?y1qR+Ta_9b`g4^<^{kVM|qF0|Nhnip)^i5_RW z458dydWlctl!R>A;1YR$YP3)_Iw*Qkv2l?u{Cnq`3$xgBK};``H6q-}#Xe?FhM2i9 zaYXafn3#5lE$yI4ZF=?Xeoxb>jZVEqP_ekDa>77rp6bQI^~TK8%Na+V(_fv%T&xNL zHr7SOWkxzzYr{k;j$MNR`MpUT8i#Vxz5ZM&JW^ef!hO$>7JJth(bDoOzDA9@M?xE- zXVZhl?@7(#eKow6VQgta<@>(bg*0KPj~~gH-l0CCKMZ+1C>C_q+(IJvrWGc(>SnE@ z0~T>Cos=Csx2nW#FD_O!1vTs3e%%+hD`@j8t`+n(s;ITl)F8nu<>BLt|1tE0nERpl zg$0j9tgeyoKo8lp*F38=PFIx2N{XxfZ0&TG zK$(sX+v)5=ZJ+JswwGc}U0P=_dTX{F&GlxTsTww!Vv$r-PylG4-VjSGqOhV^izT1)w12wlB{-eqCSCdZ4nQn=hrg4u2Dx zj17xQ@0#!NgOBGo8c4KYU*5^bdf!Sav?}y|%jECD6o2scF^QL@JU$Q@6g_ODeAaBr!sv zskv;dp_?Y9eu6G>qg7k*O!IH8XcZF^!`OtJO|~Yoyqx|~AB$;)y}#D*^wDF$w*PC4 z4bmCwHTMY=8^|8!8stkSmCaW&lsa7%YQ*CEIN%SLp-ZLY7#%%#@XOEF3%30EANM6e z6V??$eq`dw2X%oPy^sfUvlQ-ev*%BrDj7O@*9-4eMo+J+LiB6AA^Iku*i>S()398+ zU!mJaR@W!do?tSC$n7w0|DEU4vf5){RpPmcd*w~^UHBe!$SA5z;m4jAsm;Dj)I_@A z$wB`C!Lv3}(~3T(fMo_xYVT-q8r}Oc9!#lHl9ur|zJPQjq}=K1qT-;}V}p^}q;ZNQ zZus4_O8DUsKd>w|{Hkdh5WgphNo`ZIDq5zQ5w7lup}AIO7jWsPJS_wLd^*CmQ6}OI z!-vAqfrCwv?TS7TBwa*hHKO0>Rpov2OcagWYDzET67Y67Rk@dmzwH~me z6|^#Bz|Tza^W#Z*N3(M9J~mb zNOE0@^rT@Pf5LgPS$&i&l`O1GF|4$CiyADQBv_~<|2m_f2w}sX^Xe1Q;!G{jfwq8s z*Cg#wOPg_+>fys2#vv-r=8w+!w*6R2Lcl1gnPuJ?Sy-cwAqT1GtTs&65d89qt*glqFqg3<_WV#C&1XJE*P*~;L88XX6 zq3zqf+2Q&SbnCUz2xy7?r^t&A?a9!}Br-jiwMXm~%7wA407clP7390lp`_9L_&?6| zaEwYZHB;ImC4-MZjD;_Zh;?)>?nx?&kl56)q=d;N(LF_YeE!(4_v5uq#~ z2b)YSvWU($ zxbz*Y>Pt&k1g;2mb$mv3PsVX6Fk9a)C(VBmG^|(t!2epa=+nQEUnma;Gcw95d;;hC zWX~r{v7828Pi8)OU+E(g$NZt$6F){ezEK^rmVP^uyssf$`j>EnJrVU-wO7W-(iNdS z|9-j~!g^|MBl98S$0l&gxxS4w@)| zrx~9_%)0DM1L{o>LMXysa$ae8iYlQ%XKK`d+oSP}f2T?8VFVo= z`<|-Bz9`T|+JDF5lbx9=RiFyQisbrSHUHj(f&{*oFb5ZV*E~lE%r2YnaxCz=&ViLv zr*o%iX$A@}yrTPOM8%|NZWJLK;K6kQ_j1{(6|dLT#(T;GbXM@Gi}$hu45`n5kup)+ z+d}cQZlap%HA;x>HwG=|lfnbo7U_IB@BFi^=%r{FY);Q-+An&fF<@4Q@_uk>qQk5>-89f?c~fa3j`Y zmcRNPhb1qctrn}Kb?gdeij@`DIaec1x7vb=ucmldN0T!VQJ0qsB|px$;~cqC#7uCR z2D05Qed=wnRv%oqH?AFK76DDK+Y1$@w~9bl(bRW_4zw z175B!@%6JXZ>;ebnB!Y)KICdtuC=%_=VGEDTLVGmqGyKHjm?bwn|n|9v$li@x137K z64n@MT}vm5**s8-&@K7Rm}r0HJwglT-k!>6=st9Kj7T9&IO#vT5eroTCUZy9ggE?K zI`5}a)aDYICFwT<0BvKhBVFssR^66!e+A~+E05IvGvcQmkFs$aK4}Q`tN((E%4p|rYn8`tSDirV6vK(_M8!c`er?pO)p zq1A^%`oxhJrc!Qh-{UFHB|#b>D9F=d)gncith|`hDA$y%W+a7=sj8qc9@+3Df|I`- zSXAXkRB8o(a#NM}{xV-bT|=5jX0q4@nay*iofgV42~Q{5^Mb&K5Ez%$sJt%1=;>U4 zDD5fiM|4EmE4Ohu4U_-cGnV62%z@{#n$di`m=+U!%w}uO5lkGJq6P`v2K3@vXY+yL zV@#V%!%$q+@Ip=sY?Q zSE*#Wak52~2O9Ni|6l_V_dS0ak7Lr3Tza||m!peQ*Lj{}Znx8=`LXWNL0Mg|x6Qm? zwF4K2yGmhp;r74&`B1Xb0iUQ$>Sd|ly5>5sEQJkMj-$}=)%lj&`lKm4Gw#x!`Ix&f zvxT`Ew#}7ds&z4_{;tO_2qQ$l+ELH$j$(K6bLB7%XJ_77Qmf0Ev^1T0VzsH2VHJEn zFLc@hZiyVu>OG9Y9jh6&0QzI)tT=mo3wevR2i=j?qX%DP`<=Q3Ct{t-O5+uTc=HLp zsrkz2$V==~q}*FY8cSJ=pZ=Ev*B5}Q?_FP&_fI(Y_Qh=*xgT1Je#-nx*KpyM5`3F) z`3I?WFKXa1hJK?uUHJcSI_(bq+$oW10LXw3NE!9p;?gl!QR@bQGp|9GbG35{ru5>e97SjV~JL>?m5od?`ZW z3AQ*=l&#ojdIX8h{~?(0p-yIHu5iFZNjS#rW4hpv$sY)KB;f@A7k;-BDp?611Uwl- z>}nOfP@B%f20j{}!q;gp3T8O`$GKDhI`Ba2 z#@XX18T4X*QOTY#Y-9v@!jb4~08;z7PuBQF=1|>SaQW-Pcuo2;hxE;Bwl}ZQn0Rn5 zEV4-5TyVU(z-2S(_AZYAn(zc58_#$Eqn9SFcJzMvM)0PQ)|*Beo;4;?a{3E$Bms2f z`sxsnhWWcs?upo7-aN8!^T^pJ$CPkWVo9t}g_}mj)(kV9m>&W|sJw;DCeUzw*N4+Z zaN>3q{7$?gz*I%XY@Ivs*pM$WEc3xryo>U0tl}r{PaFMF6G!I)hz=Vj%ET$twCa%a~*|DiQBPF?in-W40KBeisj> zweFCHQ@ie8c^?-VEO|wzW>{TnKU%r1V`Nz2+fmBizu@(Yzz*O4>vUbqvBAcRhIzjV z&3LFbb^5biZq!bIS+(zS|I;$4om4QCUB+7y=m_Wh{#aucZ{TrgT{igO*{O@)vC>2k zAK$n3cz9DmD2A!ftc01x^Jfcuw7HK~WICf$JuKyZydO+)e*1fM4z$to}=5htvBevqnAUu zEVOpZr58+foTqQ6nPJDDzy~a+K)cI6=9=+=@y@lOc5hz^Y|t5?eC#yi)jr#frL!YF zUo_oEX3Md@fqb39B2W(7iyTnBxDrp>`XxDic+h{%OPly;`fZ*D(1e0!eJow8}CZtgEL+a z^Ua5PG~*&$6atR#r@8*>+(f(UhtrDOGu0Y7Quge!@dP;&ipR<)SJ6nI4Sj-cBP8&Z zYn{v(Ze(?B#kT~xL)G6oHb~Iyz27waP#PwA$QunA$ka6u0quvWWYzegS8q{6Id|>x zxr@VWuN4!xTFnx;8V7IM9j4MW|yL9i*O6=BJOy-X1fYE_gHG zV!YC;E6&x;X5bgimU%8xGQd>qv!JcRzeHNaxmx+#p4F(~fV)`GHB-l?h;0F+dGG1f zSDh?98OWO}#pDXbJkD@E)^N|_=D`6i@NDeZbaTO2u1>6iFk;fOsre^q|Y_{LNDv=zt!(>j*_X>RShHeOIuM zUdT$f`19F$QY&(`fobOMKK*91fk{yz?oiYa?Ty8JQtP)^9HZ2RxEdBJ>5Wd%+sI=; zpjDucsj;Vl6&r8u$FyJ3Iu_K`@yk=79m+T>``#X*d~~XIxf-4H=6XT#&uHUN@OV&= z;lZ{_EDGMNGS1kq1Bz;Jg0w00IP^~!%uqOP!t?qh6m@)GW@i-Gf1jf9 z3@{}@E}pVGE?J!V+anycAE&%HKYki$R<%qU=vD3D>{L7YLdu|qO+}0$i@o|mp0NSo z#`MzhXFS@!*oQ@1Pp^XvB^x#Go0+iLD`-s=S-IMvn+hTP^ zV3_LcWM=k82_lj)a+i!R+(Fu&pB%VE$2A-q$BZE6A=W;lA|vsD5!y|bAhY9N6>F$?Lv@ta z?Th5nq+>hjk|NYGI{RWi0MD?>?TjzgDnGGfrW%tho?O#Bh$J1wGH)5! z6y+ikUUvKpRyL~_(w1>dlivLpg7qoCs9|2rrYF=%p=5wPX6urhYg07J8gri@w~((f zs-&yeP$9@7NMZckQ3^{PbM;mZOtb*p;XSsTG;>Ik(a?s#Ad*n=J~K$D13fV+OKICLLX@pFrIR-?YF^tpw1q%Hn*;1}*Os#@L5 z1jL5=nZz_EQl`jrS{_wFw{)qlDV5-Evf=I?D1@TX7J%CJ2{yzEfFKRVZAN-MJhzn1 z+l<8H-3!1RCT?nlesW%wqg1-D0;drrmZM}M&3VT<}WgNF;jI`d_Pf2bQiwcBwfR{dr7pKIbh+_k z;ZZ}Rz=zQQibUwfi222nk&?qNZUiO=-`;|p(DAX4Nexz#ctt@)Jq3Z$f=i;-fw<9m?kh8{!|I?@(6$p z-5$$nbgcn%D1n#Q%8}W`JZ}?OJrYMvRs?L7ws<~#oTnv{jOS}iKrVS3{Ns=P&GC8o z^nh?>fK~!ec>tR_Qb$&3xtB< zCm`=09MA8z$=u48w_#m$!q^TteOs5i{g#iN?#7hIBJE*d!$MwTkpRC+{WL*f;L&Izkl33q@0hyq&5|X8sb%I;IN@^q)%

gKTQXYPvSk>}1lH%cih8Aet3&Ak>; zNsL=Byx;fL4sMUXp(WDLJ7c(R%wAVrs2lrEMG=7Rk ze0!|0JPsc;(LnIOH%kyW{Vk7oh851NQCdeuTLjpa!V!YCUw9rjgYMNvW*cO4nWT(8 zRE*9Ca~QlScy58IG%!~1ibSfQNF{wi18}3lX)g=fFUFs`R77&KEP#3uNvI0j8+YJg zi$8o>%-~NZ4r4K1j8XRGeE>pQ3>*zCxFqvBH&tX|THVSxH}|vD@_>4OPPO%(F<5^u zJBb*xBJb!3E!o_A&I29r!1vG-JQ`J+{?02xZ88A*4C6(wcuRDpUTQ|6KE9#;I}QX7 z<^7nSsYe-FZQs^uwykP55j_`dgIFeU}l1&FhTJE`&$w92$(x4mYbnrc}F{q6aXLrbg1v5C##HMAsx1fHg^Mr2(X4as&oKf&3-r52Zb{)Glipma7bp|2=c zu(ZX43NU>H1aF*hbt_Dsmji3#@9W>!OC9h0J{Amj9qAiO2J-t9DSsb9-e7_Ihpy>X z*UA?DGRcBXWq0Q$W9B8@oI()des}QhLc*GI>n1%UP5Sl0oJh-ajIxRh5t@n8)3- zO!Mj?a<0k*2FxUf)Q#nBaMCX1t~t&D1IWT*N)FG|R@+3}+#<$<*bbQ{2<+ zA3SL;&I66Czpo>OLR9dRNevk$(3;Nknm9)a>|I$nEzPxY<`rZ(8kHo5HNkec5q{S? z-U*pQ^UUIgCGU&<=5{OGR8AF&#k`vx1ItjcxH@T{zbD{l&-XfW_cVqM%5dn)M%&(90DJz-aIYl2kSkiQibGo$Kp3v8gHxQ zdT|}s>~h%-&H3h7sr<7%=K#8!-%o5B7)a5rzqB6LOysmFd829@OkhL!Xz;R3dfc!C zXN9^4c0@;;Un=Gi*Tg(XZc++9K{&WL>+1X_)&UX`&-lxtCc1mwEGB29jZXai+2r8h zQ|AYnSW0{h&>M_rH4vb;Oo$?M@p8>i;%Mm>?)GpK{Xx7hTA0~GfT}ooQ%emN74`@s zlNz5#s3HRwbgulQA#W@IUas=N&E7sDhUi!A4t!jMxbF+T+*Su9-eT0Zmm-Q-i+s3r zVmYE9B$2Y7kyQJR&v*@TrIzzj^{kgHeJDnf2UKqqPXf0&fM7K;^OtiaC?-E(EW% z>F1Qt&K+7i(vXfuZU^p@!FEZ26l`8Lm$;kVgjYJEOqDzNg2C}REjPT$Z!2zZx^zSH zJ21kZuN&B#QBFm@3t3c&p1b3D=;a?w;d!8Lmcbl#7Ap5}=OG!fA}M4XoT6HRFk};l$q`i6xiV(!w>Fs82yD z9#1Eyj-fJ1m296ps27Z=8|#+1JF2(|TE)c_e)2f%N~~K2t!^>$uDp@svfmUtMMV z@Tf5zQp+^G61TeZ(XkLOvFI#T5sVLei4UozsT`>LF&|QdvCb~MW7fI;$t3WIq@=m# zb8mAwJ3<@j5s99Ph|GrgdRJ~5`LO8)OeEDdLN zYLD5?j^iU<1S{BcsS=Os*6vxbmh_DkIDO>uiV>{IGU6m zE7wo(rinjl*Q@b;?;nBo*DCXk;mTsK73IbxS!Fs+>~Z=YR)?^6S@Yw7U=<&$#Wig+ zUl=Q&woy$aX@_dOH>Wr63X6I z`#pVuOjIr!ImO7lb<&u&bvd{VX2R5<&!`dYg@%H z{j>dmk}zDNuYxUBKeVJ(&y^9R}T^Kgs z(~BbP^gHuy8R0N&=1;6|`e}?{JfSMK(PZ6}?ACP2YSK;arU1PGX~PbkNUt@Jk8LmsQd#dp{v z*1HCjtjXzmd8$q|>KLHTw=sxMFyMP0W4 zn_T|5#$yfy8F*#kE)GR|rAc9V6mW$n4Z<+#M;=IpIJ+OBt#V3eG}KpHENM9Tpsyg# z_-g$#4y%q3{nsoiz0+MoBQZVM8)7^VrKwT~nRT*ie9YQz_LTrC?q8q93zgvuEHbw- zrx}{V-p}7R7j}4vD(*cti$M%m_*2id8CtW`#M{v`r|x#=4+br6xU0f4hMFweu7yiZFl1AgljL_g%Wa+$4^xYH5<1 zX+GNskr>x-5Oksi1vYg6>^F6;mk{m?v#}3Kut+IMHBWJe(o>ss-#RT)TFMidDwR@d z;ODFmhX)T>JDV$43ql7YLSp!k*`tNyk9FjiN9qas+28hT6oK76%K_fPbi{m&Fh$yZ zz(8D?VXc?y+fRB;EaLF(SLp<}%w4?SUT4iw@;bn<&@i~xmx5<<&iKR&hE}Wg5ZE@v zeNLn?V%THhac6f`E^dp}D&gBJpv>YgKzI3_O0tSSoMUH%3sb&8Z~Ki3{k!B35j-$q z>MSERHDu9%eF3|x zJ`4R!0JpzoQdbLh--P(IXVauf8H;pL3O{{iH#(63mKLgv7&Wfcqfa{%<(s7X=>9yv z0z_w$P>_W;OS&3*4qci-y^fuwqW4Jwh#)d|zlrX9XTG6~e%)(=LZjSy|3Ky=J_y#* zdu*IFgnPafCPzO}!`qAvQhJ|z6{3;VqHqV;Bg@lN^%)6YH%yyV;XZl_OyYF`h<^Q+ zOvA^v+84ZmzO9ZZ}M`Ce^k1$T=1oK9A2rLB&leMG|&&0!TE3~4@-|kKNjKL>K zWK{AhwOuJk9;hvf>f_t9{)L76kXgdcDo^JPBR?iV7iQO8R9F2$q@ex`75dfBT(1cw zQvQCXS{_7sQSpGFQ1WBopahX;+(Bc-y;;3f@>(lRw4l*JC+S z!Ay@^5k&$A^3-v%@X(9=pRVJ&dd#v^rzR;<%F1oLFPuH{ESAVD9t_vuVnlEI83YaU z2CdD=iXj*Ch$1lh?;ky%&C%aNxW z0i14xTyQoXjbhx=SHIr+Lx2EL&W6bH!b3qxYgHk1oyl@Pr2IMgq05>tZnCV)ekx16 zCO{yV#_VKGPOTb`ZXBWGh>bM(Toby_WXQ5YA0ZQ8wf`z9&sm*>&(_+eTBFu!+^n6X zQ}BbwSwB{;?%IV5Brl1Jn!-kc0o#g+!Q;>}V&Sw2`yM7y*TcEyabM;}Q&QL=?TV@X zDa&d)hi1dFDbGinjF0KI!9qh{pTWavy2#@o=^JX63TX)DL1RVlIjYR`W)*rpRSU~t zh#U6Z{kwcY(^Zxfa)XK3WCMqHc|?vPtS1QV-Bpj?wvF=H(n(*urlOt>vXIP(Jwhp_ zY`$0E`5t5-*-$S?tIX8!n&&r$6$#P`(5 zUGctf2g@pIJJ5Y&L)5Q(fG6UnD*Z2UW`U~Who8P?e3mvS;On?e;}><$$jnoveT#KH zx)GiDG4KnFE=7``U2Z}|y;_;W?$suQJAW30_`WjVA2I5&D#eM}pBJN(_j9lal*}Fw zak{_Ivw19k%-%&|`NB~e-Cq^&_44w*ed#}1QzDqkS&W0VSI(i2Ch)M}Mq%2dc)q&O z56#Q^fUx3NdjCp8*}>H_OLJcPqHy{+ZR`CT0)UaxywSAiMM12+q`g_7pMbCi|8V#g z=t=UU71Av-v14|dv?udHfb7Kb_)EW!JaZa>O4we|gZYbqEEyrs$W(zDKR|vH=l3n2 zrhy3oLL%stxJ7)*50Ky{bwI8XA*p8>1Swl|hMO>>J%x=I20sH{YsMFhw?I1jOZtx_ zny``C-93Sjud}H3IJju(+0Wpez;37e!^{B({Uv2kYe?{Y78!5NM%h81HmCa?jey`n z_1Nq1eh_i1xX3ptK z{+Wj10s_>^O5cM`GXnyo@IIZQM&{`EX<{7-6Pw!Gzf1sW zRB{)2``C~zY*!+Wh1*W)DoxLC{Gxfs@hB?&lyhT|e8(qQGhWEuX7YPVU0UmL2SC7{ z!D96gC)#bnx4kf*;u=$=?_M%)yh{a?UW4+$@+aP#*SbFoQsiW`78Vl3Eq*%Mc3+!+ zI&BQ7d?B=t(5PnK#3%1`Dt7toJazE?Fj@`q6_Dz~QXg_~zGeMEB($G{+7bQ@@DRb$ zw6*M#XPC8|l3aJ3?Ee4zFO)8^%YE%qqR?cqKs6|{N{Lu<;J#QjRy|flHqk@sd92_W zO|MkTB6!F-J?kZu^ik8h{%x7WOXq*<=K0Qu*Plc-+bx)|=$h|c|L(;Jt}9EAa+j2k z|0DS)6Gg==P6vP7@3@F2ZH&iWXZTYrFo0OWRLa8ZV+u~^Mg=xaH*HgO>Zs|{C$yUTA4A}7}VgHV$vIYK*tXi z`*mOKWx!(ZB5Sl6Sk$8+&sgcEAOtQv7SOpn=ClE>JPcc8B`~E#V-}$YrP5WBM5haKj)Jg2qWcB3HH6E{z&TeZf|DD^m6_b z#NcSoTmn@#e>{rYO2ruYw(Or%PNbiRSh>>%DStixt0^trFBAeYhgx&zx>0N_E+m{` z++HgSSF(v2*UP1%dI4%4$H7h>`${QCN0&RxGl!{)gD%>QsuP8akbwqtHp?}eb1_h+ z^T!_n74wz3_c`M7?H%>d^F3gTQ?I5J=T-6N;tMs`^#|3fykqE~$HiqTMdB)(rRT%s zwwpeUR_OePdKr&a)Bp+UHsJmM_qbTm~R z(Wr+yqeINzNX{)w5YmUWHxS?sF#t^R0rfGADFsCsVW#zRHLH*6D!@3=C%{!bGBc_C zm3MvhELp?vY23q!GfUqKI zj+>%yU#&krXCI4}%~wdd*Vy-M?>1U?zP^9)!X(?DK?eURf>U$+u7P z^6W0!+iG7F4cPr`A03qDhe|Q8PT9kLtvAfi6QUoya(b2YoqoiJ7gfQoxkoX^8AU!I z?k$WI^jdF|JtqTscUNsNHP_cXFZK03uXJc^N&l6vo|5S4aopiF+5D;SiZfM8{Dyf3 zjuYs|+T#?(>cxl1_V=$CRf$SD}`N-7x4f!+KbyH03j*x@99%LC;nxZA zv#|tl@U9K<(-|t$bL9pO0*u`Tky6)_6JKv$3^McdpLo<8_))28E<4fW?G1eZ2|`5P zQEFAT+%&)gmGIJ#I@gq}&TXr;8b^2L-E){*{CN9r@_K0>ewP1)s+nWL>G;&ZigQ?r zp|R4ky7o07f70I6$^a+IIEq9dFqzUmN8u#6E0=Xp)r=k3-oai^4T<)9y2oucpj{@5 z^$4xetoN?i2!^*z2ehh&yS)0mvK4Mhi(HbLTewC&wqro8*Qc_mvp_N#Qm<{ z*l{q(bcX!*L?&mHPLefoexsDCNRJ%2UOL%bF{|kBKj38IwZBKy2o_NscQM0>6u>QM z=Z%F?YzvdK1-ZU$4XD}w5_2#zGT<7Xzw0ATrp{p*xkUdBd+AIH8tT{KI==>=Sl5fh zQu#9M9V9|>p8ww_adV9HMiu#co`Hz+M*#WTi2)%b>_)!%d#!^v2I9>?4{_<7xOssZZNXy)5l)2qjwxYo5rKTQrz_24b&iEC;249@J8n~6i4-v~x zZBRWT>Az1qneL(OJex~!WlW6!<2g)VuYBmF-gWx+cE(G%xUi5wMkWwrqx<_?Rm2k{ z3M~duv*7bsBB%JlRzI+ovwWepcy)h`0iv$KMG@h5Uz{d^c{Y0BSq{l27bGF4Fr5A0 zSt-Ja5rnECg*SR<)3x*1HVnrPtwv0|RWH#Aqv+K^FXvbUc}6}E1U9fJ;Vyb!WI6JZ zbI;WHn%NiqTvf$s<~%a$n&OKBo_)(e2mod?d8G=ON{^Al^C zHr#lFr{e4YL`V@@Yp=}GMB$GMBQuD^E%rKO;waTG;al{usSwxMG);!%lIa=lx-SZs z-kNL1ffPxId2!RSRFQ2A`l@H7$y2_i8?L-=+Xeyw9x)FRg=Sap?ZeLaZ{ozPDfZl| zW0pb^|E8A4EL&E|hS4}A{5|4aN~0MlcKdq+k9{ZMlQ1V;dBK5 z_-*rjvyHJ0_nV&uC5n*QZey4Yj2Z7!+eTA;TvAz}V+CnU=99+T`HXHO6T1b$8r3o$ zhYK?67HyOc0wF}DY|1RV7;V@XLH{j%`gvdcs@*R4^YumtDsGuE>_6N zb8QAV&q0%M-qw$Xgn*U%T(J7G8j7S>+|zCKP`h>Iwmr7Ve9g2Cd1l6jPfXCqssq^J z$@F*{>e4!ua~a_D$~@@|&aDV~$&&%|CL-@~6zG+OZ7v~^($6N+N8`T#y;mFfpn3)& zI7BRDj84Eg&pkrdS-)IGrd0uXAt!&Ww3$gF@?fx24OK*Si;(W)8QJ?pFWeV}_jfXr zsd*I=y;EB{ooVRYi{K@b%Fuw{ej%xqHPXuFs;+T!E6;ye;AhNzCxi#jRD7dCh){+3 zIYcHybQ_X>*WVCjU7&1}NQ;icrdKBCs(s1d9{p+>>MXSl@Uq2s&HAM_{sk?qeTWp0 zsRjF)I}~ViD?7a2m{OnQlgY^XkKg2yo=cIEZu;7u=-pO3N(C<=?;>KxC*n}1c%fIk zEf@kG%UA5P$JZFlK2SH{l&0@wG_ZLfi>slupAN#*kmJpetmEs=`d+4Il|(9kpiEh? z2m#Ev(Wo7{$lu2SS0c%>z_;j1KZSi?6u#;9$&_z43oHHm0slir>SPgj9)@4o#87Nj znEFu%6@8UAi1R}1#hPjNh%i83O1s+Q7p?s#)S@MUP}}jPIk!|e^y@k-82}>1bxtm} zcX!xIaB-srd4YPRH9^_Cc*9~Dt16e^c0H}}T}{dQPA0wfH!ndX#6O?H z!?-!OjPGC(YY^IJ)bYJ^<2fQ)3w@>vIYEa>rj#zFFP4@&eqUC_P=?6gQ~~@0nMmmY z(wTST<(!xufaG*^=it4LhU0Tlm2RpK*mNBTL^gPwY#ml>aBlwo0ln9E@g#7EG+zIM zF2SyGXJJjI^Kb=<1ZJE4*RtQ!Ez4xwcfR?-aYRO>ar#L@Tsuw~9zv%Zk{82W!^zH} zygrf-=+oC6LVmaN@PIK-LD27V;AQS99p>}vJS(1EL_b|E&w6fQmCSQTYEqj=E1q8N z_pRXgh{qeGzXCG87bPtre$X}~&B((WsfZy!l7S+yqKP&-|N507)pZt&5&klA8kgvgULydd%p@_vsE*}mRA;B&Y_ z3~*Vv-%~R-V(bQhY9$$Ma&?I?eZqISIv2RLu`5-w{OaAuT7|T6O6nvY-xyV<^_{qX z;Empwg1xeje@EB(@?o=hKb;Mgl`;JukL*yrJAT6;k0V(YQl^5)q?1g-J;BP%y>aOx z=2&EdEV-1yBeRqw4y#?H+KS{^u+=YNo}w<}yOiBu1&^Q>A>LyyS}#5mt-iBb+XM9u z8-7aLtNyPIk~n1jWBldE`3IO-F4nI3ZCLcZHi?mm=J<^wfyq*HACxs7{q4~8@YK>) zUX49^{M>?QwTCjydNa*5ZAUV1a%c9VK5NW#>6xJq8&qke&NtN^gC64*W0U=>cz=qf zSHg(wxlxHFgkhPxo=B3_I!^X1Mc+X3y5ZW$N}3D{ zTQ0k7u9$uq_v(FxI}i}oFBPhk;TUT1ZE6AA-}?73?{_5viQIBJMwRi zY0c$H0GfY|wUM7L_yiXqbzlo`7zocACo}S=fTJTFw!n!D$&0SH5^FoAzTIkC&WSA! z0P#ea1u!#MzbXIcpvVc~&HJ?L2*=YKDhca1H9)En0)k^ArAUAS6BvgGxC^i}!6z|@ zhQyuM@xT!eGa&cJ`pw?pXrQLdT5+&g@i3V1mlVJU(FMqOaXLog4gwzw{Oh3sgmuL8 z3nzUppsPSaED6gRHt91yNF|gM{%g+MIU&Q2l31?5pEUVD#l2-z98K3Xj0Sgt4iE?d z5*&hSaEBnlEfd_`EyzG{hv30ogS)#0cOBe=+uO-?-S_i+Kfm|SJ8Q94t?B8m>ZYpB zIs5Fh8~Kd=-#U@t3;>QH#$sqNT7yTk@!o3{_b1d^`0&wtb{x^)IoS+>UV7BeF2Kqz(qgMl7t}{JdhF%2zE!c2)?(7h%&%hD=Gez)LE3R!rF zmGTHGw$xs}ofRBUhglOTgB>zH zk5`s$qC9+R?h`0K_okHpIq_fY{!dI!lh31*{##FDMTP#kzSbWqtXdbF2 zEuYn*tEq22eJT5v%WbyWnOBZfPRH<4ldrU;MOQAn%tdlRi?6)3LE)w9_J9e|fRa|J z?$y9cA7s8VL6;HWHgspDK0fOHug93@kStnIQl@cUO#1HVJ>XtYxp2GGiJfCl|Jr8R z)bvlDkaGCm#lD@-C`!2RuIK9dEveEx@>emarc_K7}XR9Q2(u;?cv{*eRj z07zkpr+4MKa=Wei*KCFkof&Yv=2!aisqmcJ(dZoA-U8?|T=)wx{>VZ5oS3g^ZnEUZ z$Nx~t|IENYkB*29qcPm6(F_oI@oc2loW#!JOR82G5qhsl<7cY3@Vnp-hkFOzv?RA+ z0y^ICJjWObNh6Jbt>Ni*6&)m8yb7mG1V_5Xs(@IQxqt-@pp0L4~ye2e4 z)z-lR5;!=LX$l`J|C8kZce#CDKX9TY_{LKCx}0iglutJX*SJ>BfqUiSSg(pKwbfh^ zU)PMZ=QLZC)JZr`H@eBN=ZmDIDjv56G3vBS-VsWI?V0wu)`~O35QTjvqp9^&6rft} zfA^uHsF#8Cn)CCeuHiiZzM`ofgwSXHXjT1L^K))!#COj_Kf0=g6@nd>pDy>~LrYQ% zI^cK#JT-Z2o>Z4z##sRX?_>IxnDA%Jzd}>UqY+Ex)B1%crSsyYtME_mtQ5&T;R z0xZ#OB!P+>V?HY6O%tf3it8c@O(KJIY97s@D=!1q3j%J!ojf{b0F?bxaRk_)!2>NX z%QDnY(4adr#?w#u)pG9xx9xswD`+izH=0DB5$smxaQ`Z;p8+qfYYqfjf68zGW9Fti zD*cW~W!KZhULWN|X+~n4TEj^qp@Q`~i|#-5O#fWn$+EWj5@GW>brbPkYs|`3I3nCG zi2G^ux6}TzSNTAS#&8w>e+ztoD+88FT>F2v0Kkw;{Lj+)W5sIzXC4CX$xFa_`_G{M zf1GM~h+?zQ-sq-4c{Fk@*)-@(+kBwz)7#|Z(d6Spu%Z@re|~s(Z{#su$z$HTqOQ9= zD(U)iwSU^Ds;)A^P`@nW=IVGyP3A*PbK7lUypQ9yhkUGyKt5->)`m>}XW9Rbzc(GV z=v>v?$U2E9zzzmj2QKlE)C|Zi>|Vf@sH{>zK%TzwjZRM=xp{x{o{8iu&f~OO%xzN?G~-P*PhiiQ`q~vc?H}s1_imG;l%H4DyKThrUG0Bo z0B~ziTZ=&%^*dkzf2KaQ)@L8XM~}!DHhdqipNwaWd9U21zWiqP>>S$Gc;5W(yQmEi z(fu{>Sl;|W_A_<5CYKnn!1bx*M<0OY@Da(uS;nz4uTn;BDzPYFX*mYTq5imA zBt#@(hM=%yQgv_C@i_9%esJ>5v+EP(yLfG3G1n6)?1lI1e~GDi2MdIEP2NUPkJK-!>M*u9q70RD z%L5UvVyv7r3-SH-%Eu@F*|bKvwXPLfbg_MH^{X(~+3RLmM7Qy<>0U1p%=fJF^+W+n zrQIb;$F}>a<@-;cR9%G(Qg0tyfqJ1l9?!8Nisk(=Yu9##?7!IgKc+uSz{v=6lbx$L z2+GTR=Ho&}(%R za$8hZC^5pr@}b2%S%m2I)3BMnc7dRyqmR=Q4U`zyKV%xgsP93KYs^oe$QM-L8ovoVMd%MV4TS| z-<=T~edG!|1a?Cl_Q{zQn!VsDj+&`rvb}??W||ZCPidw71SD=;s;e}CVeVxOUeb&MC`|Im_O*YEZ9Z5Kyp&bpxd@&ZAZj|r%0f~~?m7 z2nQa0?IavMDlc2I8Nw*aXczB}Z3S+y{C|N48??7qE1x#OD-=aO|JDlNe+L#&4l5+M ztm6xVjH~2eJHMf}Y?6G{wGf++dC6g!A@`1C(aL2ZVHYQzV%^n274b51U9pq=jC}#nF{q zEEznVQot^OL1=6Dj?m3(QoLZLYwelqjirol82{b2i97&>r-^ND=b=r*ZG&LvoYMHQ z;FzpXWG!EL#BwGz^+*Mi#H=vWUc4wOVJ5^|OylW(1WM@Ez(d~jx*04XyX1jpoeg^r z0Q&O7-(a{g%kYx&Ykqb;3nY(KJkdHwiUofF#2a2jFf8=LF1UGvymQHHv+Olw^t>La zQI_PZv}iY82CMVW)`l65*W|My_i&=Y4(->z_o2`DYaUY*YL2W2jm0E`N6!_e9nJm| z7AMi0-*;X(AgcuTvo&5)=w78%c)sKOUz>Jev9*!PE?k*)ff5i9h|8rqpns-zFjbJ6 zfGVYsn>Zp(ZmygXL$M_Wld-yH0od(E%JGOwIk6oWF}Y25w#>wh84qZV^wkj|>bMCBe0N2X14Zj;)*ZU6EXMMr$m=P2}rjFL89^Af>o-q9qBF$)OwqvZzn`^Vv#5k z!W!?cog(Vk)Afp{sgZJtP(Y%bd6;sPFlaP6h>o?D!t=NkV9pW8&EhG&5;Ha2z&Yx2 zHnkrzyCuIo5qHxKk#p^QoVYhk&334Vt64i;v=^FST#o0N0%J~0N8T?IR*x38zWo2* zAu$I`O+xLTYwJe8FbrasUr7I7y)bIl^kz-m-fzeFQ`-XGu%LI!k)0# zZi1LsqlI!Lj~I_wHp>jg=DSNLQwL=KuV=PIib%16kF4N&AiHSM`Lm-t3*`NI5`ij) zRDr2>Smq}gW!Kl?x!#7oQ1|{R|00zjp?O6NuECr0`0_-lgXAR zZUnJ$W@bH@9FRL~KeNRUzsb9+`)^ztCeVRw5SEopE}g`P$^zS+j9Cp=4YAg60%Rv?73iv%5u53e~LM-n&aG7e&hB7u>{jY2j1lZ-;=2e=bB zAq9jVX~YC^{3eHTO&x;I>{tYYEjv@qv%m6o4_kXF*8;dAYc*jslai*su_Beq_n_K+OrBcu zlMoL~_FV?}F3f5qTOBbR)=?}dX^`{aF=6KqH*9YR3%r(17fYAtfff22VSK`CSSTTs z1gx0m)XuWs&l;fli(CD|inV?=Ne$Bgy)rCPZ+MZPDWLyHdJ@7qUys+J`2b)W{>Rt0 zLBp~PTeNqM?fqH zau#3cWu`e6|!+5B{Sk zfiu4b#Vw$I641O$obou;FsWBRx)=XY;((-!e3E}$a`cv=IY}rfR>GvqHqe2dea7b zXcXP4#H*?HMqGgP*0QN{g!&Ij4xxs)nk@P6ah6wW);bE58Q7D2qtj*o%HHgun6jJl z8GC$P;PXCD7MH@WAD*1|ZOZ!4Kn76jR{We}@oekfmi}~>7Ll?MUSwU2{iNVK#vt-T zLS5GYeGJe3Nz7cGx6$s8I8M$^=26noH{j@dvm>FPaMs|M1{y=bT@jrYz1gnrg9TNp?vhop!GvtF)ufxa^K5Mq)9SU zRlUr~T{Ip}IwQVtc~G`>(pi!$C%xz$EAu%Hhm?CcKM)n!{Ejuz^B9%O{!wb@qJRHz zQ=Z(mnfNWspTK)@G9w6Gi#uqaZ}}p$VJz89_4j+n@gGRfY^EJfb<4FmKBZL#$({5J zqV-+wneV=xM`Z6xYTr3clg_CmKHk$NJYboPzQ|9ViAKt7Y#x?Fv zP#?hF!4h@0p=%`>Nq>xUsmXNj&lE3Ty1uyeF?m|iSKZjyV5%ceEf;q!P>xHS`uvzK z^yTU4`ps-_c-xG-Yl7sT?_GW`)n9V;%0G}^T(_0i-}k=Bs3Xs8TEbHMb{_ZjdbwtI zGW@KD{ChPOztr2o*edi5(`t<0h_ga`PZ9?m^Be9_%5g|eN7b9SHs4b2?5fGcNX1kb zz)2+QA7no2sdkNj2rXw>{Ez__V4!$Uk&eG@_ftNF^GE{F_leEu4S%k>Y%h6Fk%_-; z))Ss$Q5$G_k(feI8hHu>&}xfqcNra84Qc}g;o&b!k&N5|AhdufF0`|=qjO2>=)2z% zZ(=QouRAg$MIw~zOR^U^+U0OJgR9JiAMZZ zJb@?G5KslOJ*u`@I`9T)$~LN(X@x36Uur|qjQXu)pJv>JIVD{IsxtHY-+732-;e&D zoePDJ{o$DdZ=4eRVy5eCn4=u8x}9tJ@Q_Yg=i#h)+!1~@Wj%K6+!TK58$M#d5}VYM z2^&Byfx?bLiTxSZU-*?Dwv?0z`}ZW5Q5*g8Y_Dqpl8OeYjWV@M=HvNa^E*{^Q5 zAuvu7UksIB>B#nrNdCQcu_a!NyO2fU)jHl>$=0`zl))%fKoRNwWpL+t@mbGP+r#@a zRP5QX@Z0RyNZH|U^G4A>j+tY*Xn!|Qd{3$EOWzzS>@l1l7CNaiLd%b*u2J^_zj;nO z>+nBYmE>oT6bBw73zzj0%g34dd9$kO>dgiCz&CH+7kQ4wOM@HWY*NXogKtY8e4UtZEK z7Y9lnU(G%fhH-+PEU!L0u-`l*H@$rD;P&O^hd41>tqYhP?uK0y3104bqJ@9<;=cTon9UB<^61+Rn>NA1{==iRik*dh2vvVBq<|d zVv+L`Jpm#xbxdWzhpA+-8PFWZWcXZ&3SneqwA(ILFXv2T(nUR)Uhjh7-4#*g+9nZT zK@>gT(=s3#M5I#OV2;jLv1gq;P7$WK1niJlaqDQPf=0J{dMrB6QT$}arUIN!%0|!* zP$Sb&hpy8FOatd~jD>%1S`(SR@}AeNMbWVlqjQvdW|wtNvhd+^iq)*dZ& zWMpKfItZz%s%o-Cvzo@*a%O%aaoo|{yA_cs(_yvMtJZtENUc`i!^2}Yx468#?%>s< z1sxq-MW#&3%*>~1mlM5evtKlPg7yM#^q)#t`XVXnE`E4{!P#$_m=uWEW&ArRwQ4Ps zTPTQD#NO_>Cv_#Wn=lw`YHsmru1eSIL8Zu|-u6AEo-WykPsPW_2X6BKve)E;0*$U` zKj!Q0z&iRfVv~`)-Vy5MdK|jCx>q|}`hD;TorVi*w!tfd8Wc0zhe9BA$%5q8ODnRO zUm71h2Fg?rGMAhKKB5iM%UL;C?8Yc0&o7&m1%`w%qm zQ1mW!sFi&h8gHa&=b+c%;lrJHU5&c2?_Fn*J1|$hx*zzPh?|X*Tq2D1xu+>I*kf^t z*u!-*K9&1OR=doWn@xoqq$k=zS;V`7|5Vm;^LSYC@G}GYz5KjbZgZRIzzpH)(+DG1 zY)070*x3RiL{3+}$*Rvaqow*aE@mB6^?NmK%1FmXsKPTtwez~6ibLa@-{zCFKGSFQ zw*@cM_S-43w-@5+)fqgo&wnq%(S4+ef*pq?``G)vx4F65mcbYA;N*nM%gf7!RMcN7&(JG`?^uc5|mEmPZ zpwh!z6P@lyV626N5E9Z6D7l2&Zk6dab++c_=GLaxE-x<|Hl3fJ2UxhaD|5?{l3<$f ztu${p$#mJrFutf3h&LGtD94lWXF;3_g&0;`X}zrf>D3 zxSH!~`OU|GTGxYa7-?3R1os%hGRcS06%=n1A)Pv&WAM<&m1&_NMMsF?g zeuO`J4LdR`3GJ8bBgX8{$EDWQ!>J|OCk`ot|43hB~J zX+>p^caXtKeoBG2*oJw%5F0{&fHTJRGpSC!lxvJx%PLC=-8F~t6auT?mRdcP;N^4Y zsp6G8PtzDRB0E3SF}E0r^&nK?xYLR~)4J41&#>xwQ;*>ii*Dcg_6mYdfCwQvI+O9% zQ~L6>=RZGF3|Y^m_PC*C|JB7}=W^NPa_9OfE_butW9{Wa$Y_Qc>+C(!6P?zjk=tB;4&Bu~RzdW{!`&EH z(&bP@pp{8W-B7Kphg)&9QNQr3U?h#mddcM64w5acm@Jy92&W+H65Ourv9fKFz7E1V5gv&QF*3H$Z& zOZ$)n2F=gFhS&)fmRLiF`GNil_d+xWPKC%8S#J5^?t^S+_~NJLur4<&h`%V8Sewxh zfi|?$5cufa({Uq^^mHMMDJMin zjnK!^YY3Ss23`w2f0ou5y<#UUuU+89)uwak*lC}?Fm~6=lD`T*Z^ftu>W)}~5>L-y zPIFR~Dm3sxaGA$9mh-Q#CT7WH#pblb(2eS_q}c+7{Ipl46rzO^fxB(Q-W`Q!UJOJd#I23W3gbNFqoZM=`P}-sozjB5=ah4BK`?*| z!@}HLCJ9o!zIM7b5WCLPeX!F>8MRXz`BjuUU5CF+`26Xy@=_g!YURU1h4IHQr-b86 zWKfm*8!KII$ChZYFe=2%--P~kt=^e{VZ1)X750MV})y(*e;p`=j zb3&_sM^-?trsQsVsBJay6s`H+!_@R+M5}XgLM-s2GgrSY!)H0DhoRN8(j+f_E)E6n z7%_{nmUW#l+qY!?vaehC&r`(ZJ~Th6^FX)Oap=@J2ooRWUA!9}c5&(b`Mha^*10Mm zGE!Do*+f5sRsPzlr^|KcYB}I%FkFmiM;2d@MZ4Mju~rJprheqR4~6g$UwCrn814sN zUm5;SOvn-)Sxq#fiX5yzvgrc5 zYO-cQ5`}rJ1fz%*4~>VDenQ{omXfRTP4ZG6&cS&twBCzvN{X`H{-(lw0kI_ZPpj9U zaAArO?EbMfu7A&Evv^!lwyos?LI_1R`9%;69&XUaW3Oo-*Q}#!m-+mSXn<|hOgUxWA20yDq4{tfzDJl^!Pd{|*mn{!{e2qQy+f9f@q=&i_ zN2mOzn(t?7ZlJWFK7jhN5`x~$i?5Ail-@1&Ar~07Q{qNyiHnQtXg@b1{Zg_;c0`ei zg=O4k0^NDN@D~jr#Ctu(45i%&ZqQSK!MDlgOF)Bw4brmcfrsS*>1v7Jv0OahvgvVq z!Y0)K>v}y>{>HJUsZJUzxd3ClN*?-LVwhvi!X3vtAGI(7=j}1wUKK&dJv`^|%Gx?0 ztVc>N+J1)W&W1nw3xUY|@q$P<=*z0e6-!Q-MfbtC=nw|dFU)jdZgZOXQX-Luo55VF zRgEwI@k;4YF>x%=dw8|(hl>?=7VJejU5>lcWyKrvH}8~o$ZZBe!Ey~YT8502+M5Pl z9hB}DiEfd`o{Ca*7+BW<4{{GKQvHfCSK_4(ILix^%_6<6}>QBDD6uxG~ys0}7Dg~Q~b zZ~?BqtT5@8#)CTCzw7WxjNXFsmia*5sid-O9lL1!uwO4~F#_bc;>dnlp|Dr$VHvm^ zA6MC;b2Z-`Fefgu;lA(I(~rh;UtFp^qzLXi8VPgClEeuO>Uy-hpotde_b&0lfAz() zb-si$E{D{Gp0wHDakUA!~C^z<0my<@^_H?|bSYlVgFR zoeSsh1oV%8hB*O#LIf}&9QXU%DegSbkgB3kD8T_~tG7;mj2_Q{j5DHAuDtY*!HoC6 zOZ|*g`!IECEs)=u43SgEPDD3y4_a(QZfCIIg*qe!Z8&@1|1lJxsR1MY$ z>OvOvd2eHuTMLTp;p^|-D%{;EZ#7qvgqZBvOZ~LVB##lbH>8NP*KI8i^wzPl9rbaL z9vW<5I_Phg@d-kU-DO5paN^Df@Osp(uNQW+?~MY*)3(T}aAN_D4b0vdHmDFOTpbwjnd9B(qbxq=g_)_T?q!O=uF6u6 zK+%9%j2pO$Zr}7$NyQ-IW~yd8m@EsfuP0NrnWe%xWEcMPR~$~CEwf)=hDoy_dyrN= zQ>JJ?JI=JX=5KTWdlnA4LhS?&5P^=`V=bvK4De{u&u>2H`?E6FE}yMNGq^Leuj-Yj ztPej+#x-1rGCG=;|4%S5n8bG~Q5DpvK#H4}8eVc>{^Dt_Q$zA*qWH`L#*NltF9L0* zI2cD^RL6A=-zKy)mkbqig2KCM#aKwzxq2?J=o9(L+4C8*;ePaVwq&9X zzj10cmAy&1dL8(_VZ{W>5bCefPGC!oest}okz>m(*dyrgZGrL|aa72rIQ1a9F--Pzy5V$EOy@c`whe2@!VAf;E!?(9 zbI1SwO!~00pF})Hsej)n{Iw0Ap$A!8skxl=x@~H3sjX|;YVuvLt)fVb_8i2v0Q;x8 zW6rV*u)tMy|kMI^$q$(W^G%sQP9{Yd_d8elQrzrsQ_t@W`;s0r30nW)gHQfK( zOXL!Om^7=pTNe|r$+F4#oaflu$jXN=hJs=iTPrHYO8__`Ys7WDMeq7?KLyU+bp5n6 z?9$lX+wiNNUe#6l72?)Qr{9|`ecsCk_I=Nr`#KjJ8yqNBFkPIJe*6WMt+HSe>Y4HkY{Bo>yGphs$JX&o-gJvf6keSx?=ZD4Fn zv4Awh*OGJdYDg`MT#I|FlrpWwEAPjZeZfBc$W>bFPTBk96K^)=EX6oI&F^NbB5cZQ z?m|uNhun6&{AC3#U+k|a6igG*A2&jCoy(fx=gUdu07NILkS7IqFdN}e*VI~?jzW&2 zWYMy0wK-lX9aWh-Z0~oaghN3NQ#Qi+q||$tWx?9z?tB8JJ0c|?yoW$>a2Q6 z|7otO+GH%q=;-Lk`1$D(C=a(qUXt)#V;PuYYU)V7A=9^JmooE+Wk8l8=nv4n>}x<=36}K z7`uvlP58mWTUnwIISGlN(#2Y<6f-NUpXHT&uBVxdi%l*zOCAw^;v#duxB1HwCkdm1 zW@BoD$7BS=9VMi%QfnUJvz9ZBiB1JH+sjL4;o;o;$c1EM#%AP^OQFrQ*7O62o) zHjnXWLRwzIM%)0ikg=Xt6=%uCdQ`=JUxW5Aw5!kRe!9m9nqN(@$SX46ymD-B(ewvY zNx*@6d8k=)lUF2F?)V<1=(^KkZl!A{85&zJSr2i>k#Pj}Da=jr%HVuCbCvdsP&u+6=cIpnbTC{4tI9sYn zx6rKS;`D~L0(i&$QS-|gcG<^CvNz8Bb>1~OwnVXLKpZ8sXsP8f*?cM2I-u{gBr?{^ zK|5S5G$OxC>we}@DQNS|yZR)X@e>UzWkAt*!PZIoWB7c_cB&iTc-TGWwX`lDu6T~t zj-fBK{q|3-u%0i0|KJs^$qQ~PWgZl;TDj?YaWQIxI(&s;B4?&lyS5oqfA(~<2v^OK zKUuq4TQ>QyHy>`eOlxCV2OfDgEic_+cksGMZmwrNg&CEnkD$l%x`BvzSl=yr{4Vy? z7UZ&uY;eq4p3*;L0Yblx(FTiOnv34cliARCigL7r$^69cV=ME9_K6hiJ&Q#n85xUT zhVq}hQ|3P^8QquYI}blRNy2;eMgu0@ueQnMM5`VE4uf@G6Cy$av{3{SV&tKvan>l`Wt&kdzvF z%HIxjz0}0#ay)l&xL7wf<6t%H?+#s=o;GT!(P7?n7^@dVIpBK#e$Jsxv%2?czRD<+ zEcRf>q@yV}8NOXc;K<$GUCs;OOd{zFjihifD&5Vpqss7bds2FZH@tkn%=7sj5uzJXy{8t@eN~pTF{15=m-$3-mIrcy&dhnj@&|i#b+-~nZ-?oqJtlX^ZTa;D@fCv7GNyMO0pdj;4fgmhNR4t)Nr6FWB2`>jH(36$kdrwXvie zyoAqp5XFs8_8ee`S8CR+_8V4HeLTc-lgYx|wmj1cZ4N46j`Z*S0a#X!3DkNXdwF$z z0iX*j!j-iP1eEHvKeof9TOu6;EHm1ymPv5C@AP3otMvq4fCZCj)$OCymNxd(IGuW z0yTQQh2Op5*U=!>fQNEp^@YLTgqVzC&^v?=6PIWHM)LII869A?om*ZeL)wjN5JJ0y z@pmQWF)oAqimUrbh#0X6?96t${B`G7dKvZ(J=N`^m%K{2X<`?R$t9sBESjmq?uEIjuM zh6_w`jC=cVVZo@qQM32J2C9M&+F*=x&qVaN+%rANYG2PAjy0U`5FnIynRU82#{#dk z3n?yqSzPmi?{xRDPCD$7?7V`(K2jg$Axcq!(TADcq6JHnqY2HM-20q8rB`6B)$km| zJ))ng#ZC8(97yfGLDTSjf$86u>Sl8etsp<40{5TAj9sUcMLhL)$E3cIWPBL(tbfsd zvb@^U9BZvX369*6#< z0G+eD2HQX`EJb1mN@zf)6qjBVmV>Z0E>f z@?EFJ!u%_laJUa!ykD1j_Z%y$OpoKM2hAS040|4YECGa~Udi49U`zAovEh(Fe{tXM zz)t7u$61wh#==Gc6;8u~AiwJJ=XP4AsV=(l1rl}dbJbWQ8EyM-tUZ!nQ#!uA-%ibi zj&)pOGxdcA?^H8F+m};olay2Sf|fIuC(xWI-$^seIuobhkpA%v5VS5XiZL0h7 zBXn5xx7B-o;;bikW?H0*>bpL9~L+lR5#@x5RPafusi&uWr5V;<{!k^);^ zr%$DtvCF7uhWZ`b@ zU?kj4eOO^aOcYr#Qz}*G)grDfB|1ITb1EsYn4-C({S;&tB zY<>fQDcfC0yyitO3EwO`QZvqr1P+_Bbd*=afuj8C0q!W_F+XKBG=yVpIW_@wI6(NZ zR2POd%qesVWjBQA`Eb_urfZB)%k?v;b9Oi|YF}ziscciSgUFi4P=l)^%q5!T_6g?H zS43nNNDVq5y*T?##^W;)6dtMH3_^ln9Ff`WfGu8yDRBr7KT^hFz*H09D(19P!hujS z9NJi+y)928%`dN#-0p<9W`rAeKp1a9z4(X_;nXR3MIwts9q0r&Bl+X!PNt-EH~F6_Hs#*zKKPS4-fu zVH(W##H1|BH8u05Yo|_cJ>d4h(g=^2!*jDpf3sbW0$yAd6fd3AE&&H=#~vU(zV#(X ziEo5U*AU%-W4L(9O&xaRKFST%@g*<_uKV~Z7%UuN@xm5ZyaNIhkFY%ZK(^r+gZu-} z=;&x|I&ZTdsL8h9t7GY^ps{hGtYNDf3qoCoO_0bD6P!V8nGN9E%dO@NchL(&Q ze)SHp0VRIaj=UhyhJeL!GXWtS?H2t?1F%~$gADpErHDPaaBC4U!5KDl9EsLKA~RLq zlaQ`K7}c}>Mh^-&i{P<#%FLaap~vnENIs6uyKC)zX@zKXlNp}IYZBkyp{Ba?)a7r% zCzM$xF0BtBo$@VH>2;Po4?oei@*{^xTe|dqEd94(q)0ZP6@I>a4_{G1m)h%|Ksy|1 z))LC@E-oUF@vwmDEazl`lb?4>u-m|$cmPs7%js3~97vZthw*Bn82$+7`ldT8iEtbu zdd$c5BgXVEFtL@Pb(sAz&YlGQnT+JzXYVE>5njqw zdHI1lQ>tc00!7(O{OI}rb$#f&*^tE8d%A01H`uTCjKZ`R znrwY#EB)%@LLdnluzedp5vFc=V3v~}N|Ml4L=({aL1*puX-}77UzGt^xB^xc zIj5zGlNpl@ITkKV7zbvr(@;y{5R1i++1Grm@SQfz0X8gmvpbv^6R2#dWt9ey9NJ7e zeNSGw+(85aat5x`$nir#ONvPCvp|YOv>b$m2nh}d@8TdF!K3J_N;yLY5;aC8ebhDd zN78T|Gu{|sHYH&K1+aV((!F-qj@_iegi!&c5k@0fC7RS|$ra2OYe^gt0by?zNy=%! zY=AR1vS{r05{s$tfxo_vy44mvpgKaeIV)fo)+kd2`d;f#eEy+}vO>Z&fu*dSJe|F> zHbD6(^%w^e1*tkOGR!RE3}ItEwgm~vU$nPr#b=17dAzrr@MVdh;X>KfcP?+7A#12R zcZTaFH7mDunwJ0b5RJ0Sybt941 z041KqU)7TkU}LvQO++`y4Equ_v!?Z?M2-sSSs%tgc(=pAdRX!JHM!tuBy0r5i&MyK zEHyT?e=guSnUK;IFdGAI$jlCU3oPor;3Mh^2CI6e<~Vx{U*6=QCFW7P((+YRE>3M` zx<^0NTemfgJTl@DxRmU-fn7=D??LuqI~W?TyOMlPmRc?9envZaUZX;UiQ0P>L`CYj z%2sPp-p1_l5rL>?c;NENpFKrI(tfZvkQyOs&@>^hWFab7WxsAS<8R~2u{=oV;0Kkj&?r|Yh|Q39IBoJ zn&fVnx0`6ElM;fPtXoeGx@_dwyT;Q5Q!8q>8q>MM^$9xXf(!Fm?$#H=1m5~eL7<}<6w(D zlO|nE%m+%dBA=;YpfEc2bD(8Y%Z&Me8sC*znTAP4dtuWFvGWam z^6YJc2N_(;F^^}Z{_D${ivtN+R0wO!?GS$x~Z0()?n)?oUx_4^#LOT!_8 zUa}$#Dv#tXLVho69vTqxZ({wo-nVP|t#6O2d-J;l4B<17`>L`jqaBTrnZ z2i{{!rQJ@@?DZ{$%iP7imHxfv`!Kl8)GkF+Vo4c65Oiznp=oX1m21PVP&r(~mGcaQ z>UlmW_|!=X#^z4ie>mxRtdRU=ynTZu8~-l;_uN$SbKhU)-7%l0q69yK8d$0Qjm4dN zqYZV*rHYq>{GGy#$g@W@J{oz16%LkMym@!$qAXo1Hiw~IjElr~6u|`mfDqdCp*@Dk zOQo>(`DhfWzRksIy+L~RIJ2^vj7@(21$@_=hs+1A@`~7hGCl7ee*1Jy^_GDDmFy!@ zp3J4ZSW?>f9noC6aX0FkZSHegJq4%X{xBgfdBQuj{etT=9*3vFKZCqK!g6?`aTQ*< zqDU&Lq@8)udU0S!+DO)PH@cAjcp|RM;40j@Da4{yq&m?Vh~~5QcT_jFy{m{={Y^Mn z^6~pwv#(W5wr*EL?v2Leb+l7Y+f@sdMY((~m$#oMo)xHj2$=OA>N-TL7JW>C8_8VY zkv;|Ln|WWvD61Atj0e1}e#&_Es@LZvj|INC&yvPtPrO@&YmP_UU@fd$AgC_Q~qBy+qd27eK{xt>xXREWSPq zxW2l&tmTw_P5uMuiMrO>Jwqt?;Y7IVFuI0#6^s1401lRN{66JmNes$~&sE05>hqU* zG)3bl-P3V?lCxK8h2&I5PGKZOjS1=LPhzL1{5WSyYK6T`-w2s>ZrUDJZ;=e~1aA)8 z6q48+p!%^n+dOT)8 zaqGe4`Py5R(KYz#8U|kgIOc0jBr!!n1(SIOq2B@e@}pjq&ql4sG>{ zoAt)R{Fp#;gHA4q%lD)P)5-4PYkwhE@0;_@;%EP7kFvb~Hy=*&!)JJZd72GIk61Do P;3F-rAXX+~@b&)y`t&Ir literal 25852 zcmbq*by!r<*X{rc3ep2eD<}*~h|(=GfOJWRf=Gi%H;M=hCDIK;OSd#gD%}hnl0$b5 z+%urR@4NT@aqoTZAMhN`*}eB#>wVW+n*b#RDSTWiTo4F^|592)1q8x`fbM``IyIa7Vg_jov@ynA(d3hc^zkcyEnFAeOq>=Qd6f+gHQW*5uDP7&MBr6U zCfB*ngd=o9>r=`{v@NFQE=GkzjeA4tc$dHR<0K~~mi5pX&{T9SZ4qjjmo{x+O8K!@ zwl+!S6)iF^D4ZA_wl(*j+&9fHAE_QZoqKH4qkt^!W%gr3b@~yk)$JHsVN3Y2?RBO$ zYs}cLiCV|6_yWD;J4_uH`XwJAzHV9Wj@%El8gtA1=wCb8M^+JcIwNg5IV5!oOs|_QE zm7$lzmm&WE4?$Wv6~9s6tbn-fM-Xpp<-9RxQaW=m!|WtGE_nL$@dr`6cAvyGZR>^; z^{TD<&36qt+IUcHq^n(nm1p8BuYoF?st^0DX3fy7gh={^hf4eLY?W4~98X4L3yjO~ zUhmaplmopNJR=m`?yF@?HrcG&gLLJYHurk%4%Xrm2G`a7I?%5^Dy~h$px^5iG&uZ? zrHBhf57uIehtBPUyY!AfUDaZNr&CYR!wI*k(l% zU{2y;BUAlpJym=-7;q-~Sw&qV07L2nJ<2Pz1^j!qP_Sgc&Y(w&- z;yK%;M`cs})&8V5p!DkX5uBXK&)q%GCv?;0EZD$i`J`AF_O%V;?*}Dw9-J=>d-j2t z?MI5O*-GhX-A)B=l2u&xR7P8tbu-|3<)-dsra1TTFLy;}`HqX-?DSs}@)~WHPa;IR z+kLzlFYkk4-jDs7%|nmX5?e;I#}g|_CClojrYqaDUA2S1WQNspOeaU(x;?W0g3ptc zaqX3Ep(aNwHzmQO^>PxJzZ9{SsxfH-R@ z<(BH!i?RS|g0D?a4^`E}B_7>TMK+i4NvbLrMv=>vDtH^P>X=oL?HYS4Qi9)j`(=|U zr-_K7T$;<%67xD;ii5J+ObJH`w`HGW_XQbY$VvqAVQ&r6!5vw^wu#aQ#XavD#){@* zcFJ^V!Y$fn+(l@jNAqyJPxMB$fiql51cTip%E`cJK$Oqzyw}I#D<;Uv1Nmx>fWgE! zUxs3QzIivDu8p*ra|E)S+IT>>an= zha&hrIv2M~mGYe|@=-1eCc~fyg+=gu={(=+p}S*i@AWpte}o&hQ+9q#Ei+<{ByAK6 zmX+|*#)Ps6ahE4ZOQrAn+k@o0Gn%u{-b@W^Xq6-ELU=H_%w!%PhDmn7l+^MD1N;>m zjm*9MtQg_s%ao(}w*uJM%0dhoPe&eBtI6p_LTv`%??Ms< z=(-g#vRNRwd_hC6ba_GFc+01C2>U3tkjk}69X1c`NZ?w%OYtX2^vLiX zwqM`5E9yIByi5e0^?qr0b0lo!Q7#6q6h4u18Yq(MrljkgAFx~U>Er$?Pk+J zkt&!1+)UQjwtvpw;9d0u3DP0nFk=C~!FG~E77d`dqg95k^rxAkZtIU1#+@&<`f~=F zNGMq{6AWLs#;E%^3=$V$={nOIoeXLBG#FdGoOzttCoSL4(?(#vsk`9J7W?Tz9eZmW z1Ng) z!o-`q@fmW;(MlF=iLNaQT6Y1K1uzI`(*=A=9J>SV5c^b~^k4xM69?zJ)aaL^Z@Sgh zoln6iLlOOq`<7isWbmL*=Xhh-PKaU9e%A#e#G`-b*@gt7F+gakheuAaiH9b% zA-4LHnP~O62`QsXJE@gWYde^Ub>WvtLjy2MV1T6{n7PAPcX0dQ&_ySciE&Z8{kP$# z5Sgs}Mow)W#urXd$Rl=~rx;Bo0p?dTsty!WP9OdqHTnsDmy6SidEo}9D^D01&7d2I zBz@)gJ6{tNS7ZlII@U*`Y1#XqiE9uzL{d8Zl5&DSZ%ymil(^K>A~SM?FWymZ6)V_= zR78pO|9auatLxV0WoW2<*!h9MMq<)%>h|b#MDO$V4?Thf#LGWFRIupF9=1CsXi%Dx zB--t2qII$_-0=%Ti%wmB?O1!NVwK%n3AIaU% ze5iC|@_n%31>eXUtsxp{1pcg{a8LA}|Aygrvhm-@>>apA8MBI=u4oUNFc0!WZ6?$2 zVf5yYvVl3-q_m!Ml}pHRu8 zEuFx*^Sbf#-py=w!Hr4xWVyJwpHxM5fGwp;@pl;07?(`>%oCZ&k%E(uLCoCftEP## zH3o&iOo`fWz8|m5Hj;Jr*H%)x0THLgCTsBsyLdvG@U%{&83tk>cZjBJ}B*%Xb<0unHl*RPYY&Nljq z0<_%wTQes_g4f==gW#;?H~tXizkI_Gg0Y`*_SUO>Ep?uXV)<*DN7%PO3l+KMV$mpy z!iOpCyUw5R`7p7~C^i&;KU?^vhS=d|w)j_PD6@B*2sXd^O!u?@(twHmdMy(?tN^D7 z1f{85;lpNmAaER0$QtYql9l-VyMoYp?yfO0DV$Zp$osqhtVBzI$8NP{gRxyRS+WRY z$txPydj&6dp26%M`Q#hoek(I{CvSE5CG2Q?mE4P$XkEev?ly#AT1(4@5d_9jtp%>k2DUbu1uLchf_%*! zRD5&KJOsv)V}GWtq!#oCq^X=De$ii6;91iYe%Z(?|6hrUdG2xm#JhOyiSVH5!e zFmgw{$;yyJ37ud(%r&G7CHZ zeI8#RwYZAlYqHzO-UIDj!L_0SnRG)G%gskDEbBW&W1 zi^Kcg`WHaWc2v>KES?kJz6D1*TGt~Hg`&syjvY?Cs1@%dk7#Q$@WzbW)J*~%*V3HK zJ~Wrm>%Hil9TcS+a9a@7cqb{9|6}+U#rpe|EPW-keHgX+c;LiyQZ%d#?`yQlpI9i$ z@H&~NH97y)zK5QXo`&b@p`=^npP?spfhaAANS>y|*%{U)dt+%Bv*Kq@6R6*o@ zfC%c*MygNrSoZSl!&BW>)34E+ABLtv^#nItHMZP-=7^!EAS~N$Ivg5Qz1S`5M0wbf zDr<8%GMhwwVfDUHO80{j=EeTOU7r0jEL{7N(anjswP)$W<1K$a9@*Sc?f37P%~cr-tav}Ko#8{ z%PUHaf3?5Ac_Q_<{P2cDWV9GiJVzbbrnjVJ+0|pW>GYz3zfB!=99-kE04!`j@A_%5 zMjb&ldqYi4*Fi5QYUyrsU5s0aaJW*sfTq`U=714UjmB%xdggeg-pK2)E0;HUcl?Y{ zFVW+4J`oXp=_w7cU>s+_3D7(3^@jtIYozG9H*GZHvX(iuEp`h9p6PO zj*{rPk5z5NfTH}U`EoynDzi{Jn|K9Mn3|y3d37Yh2H_Ugo_sm@(ZFS}UmMx8v0N~A znHztkf2_PYb@*C5%-m?I!e%RNllCIKw+>;N@8EHql;(Ag_Ha5_*ziD-E9CVvVm^{NDNKbuOJ!lVEuDT$fIWTIUW=^ zj^bgP(<1H-!athc3R;&JkG}Z#wqC#5sgMTtF4=>p(2pDj9P7^vQyw0R{Ou;Wmq#!J zeF)w9K_9wh;1gQEUp<&qiK3RV5SMo-$G-kv z$%DFlwSVFw!K!|)8iBdj9bXwx(H~{66H@sL8!1=WbJjbBFr_`5v6<*D(O{nXcA#gI z?>==fdSaWlVBm#VMNqaA{JLC1Ek_!hB9OMXUvhqxE6;{s zG8ReW{!b4hs;C_&%XoGiZ%xO3GpXgmk7Td2m%Z5HUP$J(Ul_WDv_B1r{dA|@_~hq| z?maJ#Mw6BRzeOa-d_CUSyDPMz;RDZTg596Ok42#dwY&zQJgrs53S}58vZ1|a=VvoB zIt6o@>!M3BFr>X=7LBWvOFDry+ z3@R-pplA$1tEr+XQYYPsqVpfk*xaAsAbPkcDT4%uSg7od*mz} zpX*cz`Emnb7;t5g!0ah_bJDX*G5GvTh=hOkPI~CYKUc3lO|@?WN!}FP_|KX54ATK z5(iEz`aCM7AKc-;hm``N8Q{{UJWn^vulT~S_Q^02{99$_=~_Zu3aNuESOW->O_L%#s}~_6qTheNu6}e;4`Wm?7+;d5Zss{!Q(6R$08Zodx2H zrE$QW_uxu^H{sZ(k9>DLF})4*=**pK)d6XiLGMa5k8;u^ZRIwnNl;B4avsDb6s@n?N+5Z=DU(+AOsNulk{)t01bQWO(p3xs6dKO}1n zcrFyn*xLAD4)IUh&8!p0=-XM@mz}%GzSvFca`eO!*L3!1{XP9jzVATdlGf~Qp^JCY zHAm+5=t?KrUw;vj=~bh+MGy+_P`3#auWD9#cW(W@ivwayJ0_#~_dhGS( zPJ(sGuzy7hpLlCeQh)a2L8cFj+@ zsmtQ~7jG?97}1Ii&wN3P(L=CF>X7k>)NSUNF>1QGK&eB`izsxrALeL8=-#+?(`Y*k0zr^ z@ZVRXwQD2JEm-fD-meV-=O;BpSNa}qy&B^yA~3gd9B@N9J0Y_VM6Zt}TZ}Sb7kIdp zu<9J}h-}bGZU#fMTRI+a%!Pe=6sKGwNClgxBFA`WWs{F2&G`*_1S`?vxcO@QPAy5( z!kC;VUN)x6kWt1NmaE~e4%5Km4{*0sL;&h(sueCYT~sUDqrLh{ut{Lmi}uv#v+(U` zf>=|f+T_eWxDWHwZ<{{SSjd(;NA@Mf1l-)l_f*`!;eq0bAu@r9s_kX>H*Q14h&DhE z7+gtHl#wq*W}o18V}$b=0JNtpNk4vHVDMdtsyb<#2u6;#I{bN98&(`FN1j2!GF)64 zx9VTdy@%z7uxqKyY6k6ap<75JQjSo~Tu^h(*wz2~ii~HxWSn@RFgdGm| zDN{P?pM+7ujoI=y=K9?W6up(ab%{6m6z&8xB1@1MLK(_T_kw##q;ZSpOJ$Eb&ul#~ zVXUWr&8l#>gv&}yj%v;W=n&@!Vdu)RmLKG$tm=nz{2SZ6EXmrfbqCncXL!UCkqp`| z)Pl=2Rgr*$#GeaV9purv`~siUO1?e(yTb-rd;tsy85opHl$8)07}AOpPivM>qcCg9 z`XS=LjzKTMb{y{!A&eLrPKC(hWfIA}u^ZCO@I^kuh3+82F8<8ecVJaK9-+C$41i^i zr?llaw}ttzXNJy&yRwY@ZkY`PQx5)mx=FNJQl{X0Fedt%EKO7O^5?O%#LMbO;8s$@ zp#xB;F1?B_tI?mWeX0_LA#4l*Z`E{~cwo##I|wi`KyN_M6XkFM8ON17)B6mkw2i18Mv|Ma*`Z&_eSUlyW58zDnovs_y^?eT|jW;ut8esokD zsih%@x_F^s!lCb`>Dd02OypSS@@6vqpiRNSMh-B%CS}Ii>{HQi{k&V{eLB4B0;3wk z^b12e*zCJuBL{zq-8goC1!Iw)IhaJRBDQ@-e?uNA8KTabt5|#78Yn zwoJbSc-8*}TigBrpnPia=Iv&@G21ICUukHV2(}^&d9ZrGN1F=G{}c;a?V4ol21R9}cILvc@uK&1azTx40Vb zNDf)@KZd7Oy;k^NoY6Y_U$x;UF=;u^t{LcI^>5h}pjWlrgYFGFZKGmZ=ENUEo1r47 z8;Q(ydIxU-zE|hyI)+R^?$-#e)-9fjp3cq-$_pIpOb8XlTvI+g>u0tbYlJ&Zi)W&T zO^v0O2gCleVdTpu#jsn`*3A)5@dUi)R2(!_;NGqp#(falBJx0~& znZ4q$nenAxbcbr-_o@&KRS3Q{&>gm4VL+^qG_-Pc@(k_m*E8aG|Oui)v@Hyu0hJ$A0 z(b`c*`?+FEdoR=C95keb}^*sdPk=wlD6&ZZN zR*hg?gG^SDbHf zIhM6y2OKiVSVHV|6O74!J6xv=b)V}-E1ki~=8a!y%a2x4|C4U|kCFl&4pt`UU6^aE z&r1i2` zQoK!PvH5f5tu6Lwdip&lR-BPIm?7Y@1XM5J5AIIAwe{l+!-@bpasF=WU268RzBgE8 zsjL%~cTEdAr~@}OA&-2%W~TT$QkaD6b*f`oVJlXc% zZd{l!Z6ygVlj#$3(;I5FBB-C7+#m5SM@eLP@Su!^RHNH;UhTWeUT$T|u55B=Y46Nt zUMQGm%T^zbFjH4N#BKX@m7`(yt@)net)-F0FU*DQawjSsL{0hWkacN zeYq*t7|7V~yjRObN2_8T_cF1W;9_fd)I2*JwxV2yU$k2~`j@3xh!YoboODj{3l&*z z9Pkv}hGNjx;0u-YUsO!K>0MRPU$3c(gFnSmHcQ75Gvt3Ak-4hLJnp{hpG$p+4=TRn ztZk0iTJ)l~A2RdY{7au4_LS6EKVVD1lp;pX@=gXt5ScogL$Bf7tjk-JAK_do68kv! z0>O9NO;FNXD8q28EWyxhopI0T=lGqV6#sWn9aPtq0Zo-glD{CvHphfOzAB`ec768Z zs>S{^uY5Wb#$dq$CY$LLj5PBmBhK|#$Y>;6ifKK9WMY4xzbhH=k0erlVSqm;_6U%c zjT6BeJ8tgi@1{#DSCWv(erk1xQJ-`*hl~Z+TZR#e;b`k_9uE=_b^5n2txDiN@g_U% zWzwyiDN0AGlLeX`lT;A60tB~=cii32ozg+3Hd7u5iDC{MS3cc%DKJgg_}HTG%5MSi zzxdA#?jspSvW(CjE{*icY%B(2a^iSrR(h$hXRpN{6|Aq8+Dljryd@5h@x!@CORm^n z%n|ZZhBZlKq4<2>HSDP@UI(S(uU`GX$1v$WL?-$4JGhNy{$-)Fgb24?)XuScJ_Z9u zRLt9oftEoN5XlqOAaUprGdFaMQ$abs8}l2C(Sp=WW+Lm~a?(5)P|mdAmkeNM$FcUu zZS@~1XL|o|yjj3oExq0lKK7Ishpmz1Oz`xGf|ttlsbmfNgp{k0Md07{yowv5>Hq)J z1Crg6t}3)RpbQO80RkwnzvxxhU{CT2oAxbD^89H~Yj_X&45C}}xg6F{>Cy8k{q{Lm z+o^#jO3J$>Y|nG}l8EoMc}MDO7G=#Cqn%HHf_i2#oB7mA@<$hVmnP=8WT5FL?Nfvu6*I<4N#hpm zwB&oS7+~VLN0}+o^iQEUXY&RA@iEie*0g{dJ!*Vxj@?-2=4R#BXmp$7fAPg2c`%@uC=GM)`V$w{_PNu(0t3quD>~nW(+@OJr6QwWE8YvwKSP-A(e7IDzZ-JD z>0Bc3svt%(@u3bReb{*r()(B}tin!VC$^uw>B^x1xKZFtDYvjl?N5!BB=#TV58o$w zjw37aK#%@dnvtG0ZQ5_tzR!MsRb*u>b~18Q!q_=cp#t|U=yt2A9F+?T`Tad!@2{W0 z8*nzWV70PPh%AV3xrKAkUY+W>1py31dyhldk&}vy=*!2h{kWpbl6@^)Xjwe67IA=--}9p)A9!M)XFp)y_zV*&VYbqR_+l(Zq(qV^ zc*cq>viK2N=)voHzl6xDX3mnE6L(PI*d2R55K>Jf$yO8`;7ynW(O% zV|W+ai}c#R`T#tYi%Qq&jjAHaF)jd$SosE3DzZXK$mbQSo zomr3hN@4*Ak@ZcmvMQDs=yB@-)`#qHj09M@5HOu|4_EdoHG&()X8dq9`@>aI3;S&e z^MUc0vc@=wvUX`Wf%9GE9577fRvInqf>{xW#5@cZ+EJ=7#LumvDlzA1EA4G+02ZY9 zXd{{G58pR*ed5hL5QKL7p>fVCka$uipB|H)fuVxwKR^}9@TaBPzGY~qh#q7pnF7K6 zoQ2x9D-HGk31Sty$+Y(I<1KxPCgzkNf2Dl8r8>wD0tmx_R9T==i7*lIa$@EbsMVV* zNy1`v&;%S@O30v3LmGp%#&_hc7*$GlHa|Ilg6a{0ew`7oJ`(XDht8FSp$6XROIbz! zhd_RL=|YCIevT=;2D!Pvgw1leKJo%Vf|CC`ofumM0ObNYzSLnu@(zint+Qq?-8RTn|Ma+8- zg6$M5=QMZ9690MUF$XdBu-FJbjcH5$fsehkqZ!a;NhtHP7zUS@rReQ8vp%T2}?cPiRk1 zaYTMcjcT(S1eH8dtX^4bkElB~Oqk97I; zoM==`-|r17zoxzi9`vI2fpZ+rK39l5uEFi8j>Wd>#NS>U$-~K4PU2rC$BGXEquM#~ za^pSQEC?6L&J*->yrSo+QOL{V)ds4I+R!B+WF>qjZKrE!Jqt$|wP@}`C)!7cruca_ zR(o$l^#w(D3&RUM2C321;sd>N(_;e;%7*|$am(nhaCtopt?)Aa&En-UXd4Ky z{czWg)!xmG7-ysBIn8#rA-zSGC&aQEBd(F6#~M# z0tY>pnzL!WxAB6a+LmW$Gdr#;uDjwB^5;csgx`;5+jEa*=kTn!ZC%`$fbix29`yYG zq0#?|oSF-6@NHm|(g|;5+If~(di;xyI_L>+Fbzh8?moQ=d#Y(@%m9pEp-+8tPUiw#Dw)6?LyEYQZ=XkpRc^My;BP_Q8fYoii8(@m!p3er+uKSPj$_W z6|^gFz}lR(t|`0VTC5pw0!J3ArY2hAy45t;%~9dH2iMVCT4>XO$)L3Wp)3F23XgFM zXj`?fiC8^U}xs`Fjcx6Ghc9BM|eaidwIu#uH+AnU~+N44}~J%-Y=b`V`KyU*%20^)JC`f z$hyW7S~WN__KI~rk&@D_I_eY^v|J56yQ8x@;NW-Qn7{@^R3kjyw@HLY1>XKzKDt|c zvPXL9GD(10h!#__?scGPzMbBguaxVlInLaAjzGf;D+NU(wG$>F_o))@a4 zINc)2jtl)-$!{KexXj1As4N-|>17onX^@sa_qz2(!y zogVH;(ovUEv;gmHv2RA>3)#dAZlK*(U&ham=?Q7xPe&QJp(*$24i<-e$0=Ri?r2T$ z@#2ST4Pxc?yV{8ucFo!s?|V%?n{fN%7=6vV%jWtoQT-deK?m0n)Z9&B(TR(Q1O19? z1(JVX))Tc)9eS-mo?WI+?Z*sfU58i8ru!D_cbDKmRF^+{L9$r`HF@o_3-&8q>KF#- zde#!1HK3;emisqi^1sJZfHwsu2K-9{mYo~C$vuMm6Fh?FG4~Na{H)ohHiH5$zIF@u zPHN3`pE}b5Nr6|U@AT-l30=5+&F!Imj>>7S`27J?aK%}_qUYGQC)@jhe*|7ny#MPP zpS{uJ4pM^75#(O!m!tZcE+?vw{r;Uj9pw6N=Y+ohaTr3UqXPch8ZdyF-KE33dCn|P z&Hy6*xbQmj4~XUpr{21JcSO5;^f674?!>LxF};uHWLPqIq|SPB*rhtSV}PWNym#bu zl?t(##$jGYFp;;nS6*6^d^dTNEx%}B_oUiV!?L-&@|?w>{*P0AVmwu*k)p7Gs7NC@ za_Xq-v*qj-_S?t#r*-ZIxtGUhh}l#ex#&Y6Qx;erAonmg_12RB2M|dVzJTfL3o1~+ zfiuTQ+AV4vIpscDWU;(uX9J$6UDKBhP;$F|Y)m1q`8pZC@}t)#!^0Y8YRIy#UD;)1 z|7I(>LD`&U@r?AD@bS5@Cy>o^)%-!%3*xUNNYIP&6#tC?5OI^sTDjEjhCjGi|1?5d z438L2%@ouVO)L*HN^ojL;1fU8-x7Hr{lzNaq9|ygrGyWAR$@QExN_3VsM%KT%yAr5@IKBxrVw0REH1h7nnjrj zs{%d<<`E38I3ETTW1(M?pTCSJ0U%FPp-wryk-h5hlTaL(h3n8D=n2`x%#hnZmrK$c z2;7^0k-D)dAe2O)~sq!d6v_V<8Le zq-OsnTVO_)j3cRn#Vug~ig&K9|Di_-=6uiS+c$&)F`}^7BeH6wy@*=_4vynDAn6K6 zTDm<(N9%z+AhGmz$Uecb3{J?6l$e&Nqp>e`v&t7-@brI7n#T!jKngVvMPX?YJA8Mr z#4SLv{+qCt;ee^c>uk>XrRVmU!XYoLlxTdiUk#BG(=w1qxUwdF{&!vvSo5D_s*gVt zhUP1!_dWW>ctUNtZ}(C9ALTF4toJ`n3}Mx0G6yTR@;BxF-FtM_FPcgNKD__sVYO$s z<(1sCk5a*P@NRRRyX}5i-<@yNrS#qktd*?4^aZn?Pw@GJ{%h2p+7U0y`~_~$?{lldUQ|xGUn>PN zn{|t?ktn=SJyV`2zP@tc`%Gunj_E2V+8u%J4T$dD2DjRi-gzzkCnHAPP>vqX$%-Ss zvi-8A|HNp(3Hl!!U6kOy2tO&d2aFeZP|?z7e#-@r(XlhFA<0K3EyB z!*qt_6lr2ZY(>m)gDZBYFZ>A1%S1N(?HNW{oWGxI;U@R`KafLWb;GjX|(*} zm@+SCe|R%(B?|2Rac3Aa#j^_KpWvfEly$3;%qtyH9POBpIfGm2UY4QC!imd(MA|33 z)T6v2ZsXr7wvHGTItq0JQXaBMh0yxQKlRda7g?|5BYL>{s$k*asD9rQIImaqbK+5- z1?A<%Ew$_QHK7FG+-{lFwa>O!YE?)G3GIpO0cWSC1TL`JWyN{zjGtW;q`)I! z0`emO^I_t~==+2S39Q~ViwlVli4Nfzd6R6s+$v(GT8sU_a9&*i1wA0e;P|8mce$O((zWF_c=Mh%(ZcRM4 z-9PBce5_0b&w{`YXISoR&-pK9D7Cn}XYCoD$wm`7AmxM)+<*U#fWhoXf)IgJ=SP7K z^A7gA>F@#6kW4Zeqk#Rna^aBI%+y{_Oo)J9h~SwL5Wv_vly7zN%iJTGnleKAza z&Jh5E*u~i86Ei?!`?pNtq}V+J5F#S7oW>_-{$=`F^2!pLuu=A&XTj;d3kmZy@o}F% zBZcQnp6yD@f~0j9xTZ8K3itQ!d&Fi6G)vUaCwTt`y|@Q!VEja`8vZ^=+6EetJ!W0c z3(^u(&NtPPzzj_G{X0f2eN{Cs2R7pTR8=wj6$luqpNV(-~R8!W- z&maioN_c0odZ=SZatghWKmga2B9YIO{>w z$YbLBvdIcY6VAUMd*E8W!_9xp{YBDWJH@oUOA_XN|Fv&iQFfIfWwMR7U&|TDh{_m? z>Epu$$rjAA0mt`s$24i=c@LGqX?dlWl$5(-f3t7=#FfFkQW(~p4iY%G?)IlDI}MRQ zU~yy>saToKCZenUL%;;bJL_RNrTYO~hZvtDgKH$172{Vr5QMYQnZ0~C(>U6i(b=jN z^uK^x20LmLt*gpicUrpdfNutU$gkR5eUEE)mSbQj+6ti#t1H2HQYBIri2^o?<1mci zD}n@q`$Rqv_1Df-*1YT-a?PtV72p%-<%$fC^9Lo`Nf?9t%M>2cGG_fC97#=kcUE#V z;F`oGJUXfNpe#e(&0>Ss_dCR&Ju_~MLz(=r`^>E0CErXb4!B`6u7ac)Fe zU#%lL#Ia4o25PY!mIuz3SMUns!f=p7&VRur~`p)#^fu?BY9c6W^xVj^MQMvg~^@^ z#5PX=)NPWqJ=rbR555kspAzVt_)H{IBCn%vqayELRt*2Lxa_>f>ISrxOB>5Ghi=zZ zMLhSL5CmCyM^b-l(^SbcOi7;aKD+uZnz=rw!CY_v1(?sj@V)`X|G0Lvnr=hN17+`t=eC83Ol=udyB?FqO0k=Eb$9t)rUiEYRxpKqZHCsim z(Bpk%urKVos8~m*eHL#T?B&=}VsQTF>$Jfkzj=lekKm@1kth0c?0O@LH43HTFPN?rbSAA^1SenB*!`%C;nJ|3@DKC`0WgzqF!4B|ZIgyi7HqO5_C-gYV%*+2zB>!&BwyOo<)zi`K4IlH)y$+T^ zCAR@X-b|6ExJ&SA?b4H!U?f}3DBsE;wc1CTLT>jdWO#+wwmABE&Z>Q6bP9pJXGTd| zhm7%Y>t46hrSD$X-YFFLrEswItJ=8!ef^2ZzCc&wL7YiaCC6zS?{w|!f-37A&#gnj z8WpX)QWR4gVQ!bm=Kk+fr^L!xtyiM1%ZR;N+hzX~CS;V$Pk)y>f@dGsBXBh$8Q$I*ukh5>L>rQd&8-oP(7}cumIMbDccdRO~++_ zIvWvZLW=3F5%D(lvGPemT1cKFi^94n z;mh+qgvZfTw4TSoumO$d@mzTBOjG6Iq+Ks+{AS28z3YM3dN{rCZbz?Z?WL!ng>dKj zVK=V{FKXRnOZQ1TO>fdOJfu=vvhu3SvcJog+X^f)OV0C9=&B?lp4UWCf99xEus8NH zawn^&dZvZWZN1-atL}(S!#8!5&UNKJP*PUdY#@C-J1xZ3s<7^`+GMuzqZNg`aHR_6 zhm*?sg9{zrskd2qg2D83Pv8E&Y1eZyk_TcGND}NxSg4EMnp`d|oN1T|(Q?AQJp0&O z@+jMj?{Q=H_|87vlh=*$8hQ@HkGJa23V=)cTy)&#L)61t%?tWJM=?4Xe^kxXJg# z-aW^k)z4h_SNb_L8qyRAX!zbQc~VduuIvbv(>OL(m&U14aZAz;V@!IUdLkpX^6OR0Ny`b&^8tk8Cu=?H3n7A=zb;T`mGy<$j$H9U+K!0O z4QB_FLQeSc>A6gXNsUxMPado2T%HS)>WcYtuAp6TKX8nXC>?AzQ1RK0Pdmfove#4v z^`~M{zaJO6E29ePp&L56_3Ij}#Vs*@gd$@h8cwczYU3RQF}xP%W|4EfQ?=c1feHvz z*BD#L8N1d8XB>%uFUcQ%vp9{fuDNdCW=`0CqL^O3*6F#*+i=p8noU(ldUbTFoQ8P-K$a&VD*<&vpV;i(dS=E6<3xbBrC$75g*!2oW?jqoGeJ|-m>*r2!q2kJG8cFpm zZmR)EYN3OCxA6^4`Z|&OHGd}GR2g_(EPYw@qV{-qc!ZR?B_iH`=VVyJpqtKPzn=+M z9-C#PLd=us}+wc;@}{b0>SJx|SnCa=D=o6Ys6x!B%pe*ClGOJa$o*wM07YU^&hx#In?3v>PMm zhu28x0YHZ!4qG-HB2zXthX|8S&!TY{LaRZP?`*!VBK{u53d|^|VW-v|k<2bq@CM}V3-V9nU=$eQ=bY5(uXy&sgEUk_z}*$cLKa#_2_lW@tMl{f*T4z z43pQ-1{Oj(Ltm+vHuDr43}|}^EJmDbt_l<|Q6{y|Q;?A>$` z{suUJ0QZmo-C_mcUsD{k2T_Kf`BLaZrj|>4MJ9>}fE=W36PZvl&y&UIy`uq-U8i-w zg-qn0zH3OU6=R(vGQVOlA6PzO?hkJ% z86*_UQ+8!hkv5CFO*rN_Aq4-H8R>{H45YEnu4CyG#UfN*AT1(UiV2&>Qqts5(FaRP z1rs8n)czwk($mXTSZFZZi)(W*kst~9b9p_*0!13+2p&5T5Z;dk?qPURNvaoo>eR2X z8HTjB&9KU*e%Tm3p8FkKf3}MBhJz!H1#wGwCtv4FPJEjd+-Siz5?|-53u)l>ZP|m=gW;c6VgrY)O_sa zU&%YsTCW}hsX|IGzc^3o2eB4{>}viN3(K_^L_nzTJ&YcSfmRf9Ju}NolLQp&z;(?P zq4qRx4$r$||ISDetdUukF<|0u4BNmN8!o=WEJw75n7XT9Y>vI(ZnP6F-$e z<)pod-)>#+l~qW+>w*8h6pQ7bB+tEl!hWYcjKpmrOS~S^kf@{Y!MOMJzW@Wb?SxxH zpX6sAr3sk3P%F^Id|1)55W%N9UXK0oWylbfi zDn*VFgoH4Ys-JyYB@=W((B!?{fRNxHIt8R4k1=2l z4xnkN1{hgu>Yf#!Dj?UeeKzmMmXcalAr!C>&VXIm&wsx4vWo%HKTDrLhXL`EaBOqC ze2PE)y3JnFmh`*fc?vS46%Uuw_$9?m(x)=ahs9LA_}=j;c-{XP8eVVNxk)^0$wIx0 zB3K>kghE(xLp@LPr*;2oNE)?kM@2K;MJ9tp5U(wtnX%yE50X4-G1Kz-NBDFzW?@&f zZ?$j=7o^7f)r|%7>M&Rp_stBokY1%G;;PE*4dC@x6LLkERy6sCLe)qs(gSvMYvb!_ zVte=Tt~_=S!nmD(HrSbaohTJasCjGI1#V#2ZZmyg`cDvg>uAr)x&XL;8oPkOKe+y= zCB>3b94pDa8~4dTdLBt9K5Py^-KRpSMLKC! z1oBw`sYLH|zGKgyOl%HdXg5ac#$mX>JHD>@qoZprU;q`DOfdY54o;3((s^C#r`>Jp zYn-l?qZ2!lkF(}B4Q4Bv)AaGkmfXS+;=(`IeOIiFVIzFEQoiuSL8QI z>?@$h;S;~6mFm2QKEW1NXghQna6i)aQFast#{wlNDO&cu;y7<#6tF zPJWyYcry|NOMX&T-=gk7nT`Kxp#X&Zq}++unwc))g#p-qBR1$2Q4kI9=;d=?_R~Bz z$TRj)SJ}y!JX{E1uD+T|z^P0$7>}chaO`Tm+}GF*TZV}og1E{iPnCKe&=?eo%EcQI zC#vSfHPP0QX8LKU=c9eU#Fq`no|B9BQ>8>QE<~z4%_sFy!SXPV5m5?RV|l*9Xe5wkZ8v)d1ddQt4gVQL#{K1UFhth`izxH0gQ{x&!6E$q-d4 zu39Dp`S;fZUr~oCgIEW|&CBFt_jr^08`(#M@GryRNqUl;3$*&&eVhby_$+Vo&vNmb zN!qiCC*IW+xAEnZI4u$d*?`Sdv|z3tyG%PRluhGO;e_9hB1o~KFnj22)2K`$D6l{? zUw-nkGSF?{9U=7`N#N`{H9L|r+42td9@I_&!1-E2l?UE(N63dN@u1msA3 z;c{r|)zj_cV@7b{*I4m5lds~#I>d@0zEgDV(utaVM>{9ruo-|F5JTf$(Ak1CEYMh(o zcz|jYyh#eh7BxUhl@th?Bbkd_ZW|(=EF-0RA-#7NRSp&AAK!1o6lym+T}0n3(pgDr z6=2RAn-w1QMB0OjF@cX|esiJ42zT?E@&JRRCfY*b(!F5Thb$<+#{^M`Ph8(05qbu1 zS&LxR*9QHPTFas*Q}=uB!W$YVfU{wol4NG4$`%EOl1*+|!Yaj?Gb{ZFiaA7UQ&*Y) z4e}skK-%4(;Qu!Q0aMWbQrpa&8}2fGu<82){)p+lg( z*o6>ZVlsEHClv$jkr&+Ii+~o+IR%kF7C-K*n3FbnW}&=NONWw{WXMx9dc4`bJ9HSJ7RkWo zwzq7qOyjG^b^v|rcu6aiRzG@V7TpMLu{Q1jCB7PN@mZRs!@G!49hco|jr6%c*rkz! zoyj`iG)E+22t;2|>yH77m|4Ixh^*`vmA(2$=6dgGLX1%}jd~i2rZ(i_i!5x5~!KIdgky>MhSwDi-YZ76u`X`s6ekO3;mC0+hVR!cL zI0g#??(DL}{z4q&+`gvH; z)nH*spZ)Ce8pvj&A-`Wvw6a&5wi^;}l)*34WEvu`L*>{|!|F21)VbAL{06!2iTq#A zCSHVicKH>kz#68uYz+RYQ5kY{>NCJJX*Du;*MW&Pp!Kf{Z6Qf5o0ro1+R!zdBeCnQ zZH58M2U$UnfpLN3T*JB4tt>O4K4(pRdb+!T+wWExl}wE0Nbhb>$xNe{F3f#t@RDeb8n-tBisne2}7B46Li!Q`C{%=hFy(O`{<@E7Fue`?xheZ5cC&wsHtt#g(1mCuFIp0*mW{a1{vjwR(SvSV17wgH6f98n{ zEMM(~OD-u*Evg;l`mR_%Wj#&u_w8`u0FG*X$q!9#@vW6xAQa}eK8E>Qb!&ga3{P%t zZX|1yjO5IPCfRY;ROO(cMY+W9j|)mNXgE!NU*1I0#rb6?<~!2Rx~|1FOLTc`$H;>< z1=2oSi}>|5KpV0*{K(ss{=WlV?aqO%51Xw#Mxy^L`EDUNsMkI)ZvJc`!Fj2UCk6`l z%Vp2gE!T%l{yY%iY7uYVQJC|5RUo`x#x+>xSrVdXhaZmKe7D8#Rc9W`E~s?Z(;^&( zjXh^V#ttY0l8UP#J<7-bx+mH0ZszONDZ`c=$G^WXPEd`IuGup}3;0cMWCo{l^JD_tHS1r6 zzMO`?_5!0I^_Lp%*g^zdl~3rq-2>I^8}*+0e75JpkPX@_ya~Y*xLD20xaSD z#NFaMNZ$hq8eKxOc%q8WgTwnOWB8>RwRy%uZt6noZw0RLGIYy9TYv#9qZI(G&Y>iu zU}`Kdm^f>lM06bBuj&GGE0Y6~la!qafK{Q7PTmB!n2q@jkpr_%KSVyEX~$N=EKz2R zUqNQDeo_~LZou&(brT5iU>m8wIR}m=?b6kqH zYgC^Y`FCDC>_(!5;GZ_zr@x_i=PwDr^;h`w)@s|fl;@EZm{yW3FGQHYnX0uYm*<`1 zMrgd@Wf28)j=rIJ-tOM3Jvn*-*fr_!Ylb390NI>+0V|TY3bMl*&Fo)3-y2xZQOgCg zNRZ&36Yl=#r#JO$8jD7Lwv3&UFbXH~Zqh_3GI;)!OQGc?QIUny9BAa#Y(Z zZQrzg|8sC%HjffPqCD~)nrM-h&;?0nF{&@wG`3q2@0+yudsfFNCSf> zJBqA~S(aGpSWO5o@GeO-fx`@X^59{7Yci2^}6{Dca=`tvkunAS@ z+Zk)kC!UId-k4Xtu@pn<)+pUvZb4!}nv=G!q*6X?DJvfK!OX+4njcbN?Rn)X9aGuV zX3T3NcbN<^sDW1ldu)U|zM8B^wi^?x?gz{cC?;U|_4$H>H4)2jI5T9y=;}SaLM9;J zGj;SM`q)OJ z%_wz-#8@4Z+5DH`FcU}OWdkx~|7WEd>=6P5pkHv#xzyQfUKX#vwgzm5TVKiyNzu}K z!wu=iM9kp$P`Q>}2TJ9s%0(3?MetE4ELct*i3Y{XATC|gWEA$iD>yeEL4d*uo9$Vr z^W*QJe{*x1)B5A#(7LhFb={K0B~Ixodep5st;x&t=IzcPXYC0dzX=~a?yGTdUWBeB z9LB8K!JNvXY;R`|co6)&ZMdx!7*UX&xTP|wc*BedvUh$-6EF%|;p;Z6|2(Pj6|+Xt zU60YV3jETT1)&_S06+1ilXZ!u5GK+R))}$tAJVtOaqt+*1igzda=M^B)Xer=<*61LT7C2 z)mjZ;3zdc4-suE_0?X52f*>r)!-79N6N#mC8GX4Xi<_HyuFp^fkmsYLhiXauh5^!sD z2ZJvBu3V@UwUEisTm4)RS7v<0b1-{=Od^pzPcd&N0gxG9XkLfIX?Sw3Nj!;j{6YY9 zg{qJNxY`XbJ-kfJ?FxF7$AwBAf~C(O4IW!CVQwSY8H&edwLev_41bS&j@#=w&5eZRe*o+^AQ>R`bjOvYFB$d}5jEus zIbs_NH8aqFphYuFD+d<+iN%UBrX^d`N$HlenR|Sbx|=&nJApn@z*g9wLp8GFVO7YL zJRY$N;Bc4&h)}uO`|lR6W2>3i#!u^12sXgAllk@YS%diktSyjw1YJvHAUTrSTo=fyQZJX1Km(_jF>kIDk3&(!lY-EO6|vwV0L z4*ErsZ|(jgE1#6RSh_j~m69?yJeFtF5|yIw9_a=gW!19#S~fs$kQwh=!94@}Kxfu; z<P3N@Kta0#B!Z=%= z-1Z#_z24D@FCloBF3#QOm; zewA;9Bs00hr?qxH8m7kB9qp<3cqmfVszlkqHM79g7pJq4o5&)V4X%S!>(8ZMZLB|8@d*_ zJpH03daNw1tT);)yMhi&$61PMLl@KVq8ragkE3<;J=sK~xr{I*e*~9nUI6jcPzMtV z!QdxXvSFEAUOt@^u?-y40a5w8C0@*kqCtd~RgY*Gwvbs0%LKY$^sUqrcal1ZFCWlx z_YqX$sJ-e<+Sg?%ao2z#NKf$#foqcaDM0nn_pb0X_1t#^tU^*ECfrl$PWMd-j>I;- z3)Wm{kU)QL6&O3J-9i2&(s4)3s4tgiy`ccaMPOH{2T-zBUX$CHcC1-Y0nS`sKfWeIoY!f7blkFI)hgk5-lt>)sj$(gUt3soK}S15Om z5_ZekKpdc#wJfqDLKJStb7159H=2t(Jo*lM{fUu|H1<0!Dk<4Xb978Qhjkp-7oM)y zvHVjn+`F4fzE|>UEGj?M|Gtvr`^DkN1b)=@9_rK1po5Q8I{(YwYKL6j5YqIhpOXOJ zxREFXHuNwOtgd31MXni_DzY>(eGZmir7Ju|M=dowby3noXN;nqjg!`Sb})liQ6)+7LAn_U%huH zDlP~%Sz269%P0j(*+U0W2K_g(WNke9j>yx>NLg&`>L(>FtMo|E>E_D!!j5@m&0rV8 zrD57M~a(vwuSLbyqr8!iDB=OfKdet&3`$rsy1lO474<=M;t7Xv^&8*XQ9trn;rt;jv`-2Tx6;N^isPFc2;2*KJ~#sWZpvCYbR z(@qB>W`u{71@UkuPD{bgTsRjjmF&jK{K3i(vZD0Yo z=O+7%{*4Uwuk$OC#~=99V&-<%4A(yo=WDBuXfMV3eR<*l2p2fM1A}zgS4=qtpm$GZ zo`g~7MsDppbyYpv_Vt=^DSopV?Du#t_V14Z+l~*++j$n1d#^Dmy#)`fs9U?8hcl0# zeD?rk0(o7bXQv*c1)XOTkzx0@767fl-u92MO^rwfgHV8K*$DP4m#oXLgriqCbW8h6 zS)>kT)1uFrHf=$hkhBc#Veeo0ejh;tk((VCBz9us!j8XRP@#BeSKm$poH&IW`7Auo z3B?5DIbK1ve=3o{EX1DTi-+a`9A@Ya_e%g*mpYw_%X)M04k9vdb=)7q7%KPk%R4pRu-@uJ1G6)+(7W6T z-)}jT+%}DJ`IK_ml!1*8gUJn@0T;8Zuki8N0y*k6XVLL+y8!vO^&+I-}`JTBSS2j`<>ZgEDT+9Pn9noZd zg)QIPPO7hux7xOy6}|^7S={42YGGb)YZ}PPJ4>~=O;CTRvfaKtKlA2?`}cWFZOT!EPlCV# z`WYQl$ibr1Orzu5Ag2#jyZW6c2{T&e;T5LGq7~9r{zwisA(ewXbg@>*?=QE8AF=k|%Bp)LuZ9lSrKZlAsX_h8(vO6fFd3;Q`-V;1WDp!Dh5r?Z3Pm8#R_h_mBi z4qgMxb;|EgGtShCBUP&R@)uX7NlMq+NV~1RJl`rFD6;N(f3p|;C_Km0tF!LS6=6hiP18$AJ-kJ|9$&^ z!Y0$IAC1XXXKFSl-}Bo0gq~LeX{G;P-w83ZSv#ZUZkR#KeY~dveANVW7iEa7)wGNF EAB}B!RR910 diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index 1c0dbfe18..beea2f85a 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -2,9 +2,9 @@ The association of a circuit with a particular site and/or device is modeled separately as a circuit termination. A circuit may have up to two terminations, labeled A and Z. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. -Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. +Each circuit termination is attached to either a site or to a provider network. Site terminations may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. -In adherence with NetBox's philosophy of closely modeling the real world, a circuit may terminate only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. +In adherence with NetBox's philosophy of closely modeling the real world, a circuit may be connected only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. !!! note - A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. + A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. The provider network model is ideal for representing these networks. diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md new file mode 100644 index 000000000..f5a428e96 --- /dev/null +++ b/docs/models/circuits/providernetwork.md @@ -0,0 +1,5 @@ +# Provider Networks + +This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity. + +Each provider network must be assigned to a provider. A circuit may terminate to either a provider network or to a site. diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index df14c0e07..a99078472 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -8,7 +8,7 @@ A device is said to be full-depth if its installation on one rack face prevents Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.) -Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device. +Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device. Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 756e320af..bd9975a72 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -2,11 +2,15 @@ Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). -Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. +!!! note + Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. + +### Interface Types + +Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. Virtual interfaces, such as 802.1Q-tagged subinterfaces, may be assigned to physical parent interfaces. Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. -IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) +### IP Address Assignment -!!! note - Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. +IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md new file mode 100644 index 000000000..16df208ac --- /dev/null +++ b/docs/models/dcim/location.md @@ -0,0 +1,5 @@ +# Locations + +Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. + +The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.) diff --git a/docs/models/dcim/powerfeed.md b/docs/models/dcim/powerfeed.md index 48ad2a5dc..bac7214f1 100644 --- a/docs/models/dcim/powerfeed.md +++ b/docs/models/dcim/powerfeed.md @@ -1,6 +1,6 @@ # Power Feed -A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power pot (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks. +A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power port (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks. Each power feed is assigned an operational type (primary or redundant) and one of the following statuses: diff --git a/docs/models/dcim/powerpanel.md b/docs/models/dcim/powerpanel.md index 3daecbacf..813321179 100644 --- a/docs/models/dcim/powerpanel.md +++ b/docs/models/dcim/powerpanel.md @@ -2,7 +2,7 @@ A power panel represents the origin point in NetBox for electrical power being disseminated by one or more power feeds. In a data center environment, one power panel often serves a group of racks, with an individual power feed extending to each rack, though this is not always the case. It is common to have two sets of panels and feeds arranged in parallel to provide redundant power to each rack. -Each power panel must be assigned to a site, and may optionally be assigned to a particular rack group. +Each power panel must be assigned to a site, and may optionally be assigned to a particular location within that site. !!! note NetBox does not model the mechanism by which power is delivered to a power panel. Power panels define the root level of the power distribution hierarchy in NetBox. diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index e5e52cc07..90c9cfe6e 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -1,6 +1,6 @@ # Racks -The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a rack group and/or tenant. Racks can also be organized by user-defined functional roles. +The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order. diff --git a/docs/models/dcim/rackgroup.md b/docs/models/dcim/rackgroup.md deleted file mode 100644 index 974285f71..000000000 --- a/docs/models/dcim/rackgroup.md +++ /dev/null @@ -1,7 +0,0 @@ -# Rack Groups - -Racks can be organized into groups, which can be nested into themselves similar to regions. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. - -Each rack group must be assigned to a parent site, and rack groups may optionally be nested within a site to model a multi-level hierarchy. For example, you might have a tier of rooms beneath a tier of floors, all belonging to the same parent building (site). - -The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.) diff --git a/docs/models/dcim/sitegroup.md b/docs/models/dcim/sitegroup.md new file mode 100644 index 000000000..3c1ed11bd --- /dev/null +++ b/docs/models/dcim/sitegroup.md @@ -0,0 +1,3 @@ +# Site Groups + +Like regions, site groups can be used to organize sites. Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of any of its parent groups. diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index af81cfbf9..bb4a22e0d 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -3,11 +3,13 @@ Sometimes it is desirable to associate additional data with a group of devices or virtual machines to aid in automated configuration. For example, you might want to associate a set of syslog servers for all devices within a particular region. Context data enables the association of extra user-defined data with devices and virtual machines grouped by one or more of the following assignments: * Region +* Site group * Site +* Device type (devices only) * Role * Platform -* Cluster group -* Cluster +* Cluster group (VMs only) +* Cluster (VMs only) * Tenant group * Tenant * Tag diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md index f252204c5..c7aa0d05f 100644 --- a/docs/models/ipam/vlan.md +++ b/docs/models/ipam/vlan.md @@ -1,6 +1,6 @@ # VLANs -A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site, tenant, and/or VLAN group. +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into VLAN groups to define scope and to enforce uniqueness. Each VLAN must be assigned one of the following operational statuses: diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 7a0bb80ff..819d45982 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -1,5 +1,5 @@ # VLAN Groups -VLAN groups can be used to organize VLANs within NetBox. Each group may optionally be assigned to a specific site, but a group cannot belong to multiple sites. +VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope. Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index 40e9ef2c0..de9b5f214 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -11,4 +11,6 @@ Like devices, each VM can be assigned a platform and/or functional role, and mus * Failed * Decommissioning -Additional fields are available for annotating the vCPU count, memory (GB), and disk (GB) allocated to each VM. Each VM may optionally be assigned to a tenant. Virtual machines may have virtual interfaces assigned to them, but do not support any physical component. +Additional fields are available for annotating the vCPU count, memory (GB), and disk (GB) allocated to each VM. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). + +Each VM may optionally be assigned to a tenant. Virtual machines may have virtual interfaces assigned to them, but do not support any physical component. diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 202e0a96b..c2d62330f 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -89,3 +89,58 @@ Restart the WSGI service to load the new plugin: ```no-highlight # sudo systemctl restart netbox ``` + +## Removing Plugins + +Follow these steps to completely remove a plugin. + +### Update Configuration + +Remove the plugin from the `PLUGINS` list in `configuration.py`. Also remove any relevant configuration parameters from `PLUGINS_CONFIG`. + +### Remove the Python Package + +Use `pip` to remove the installed plugin: + +```no-highlight +$ source /opt/netbox/venv/bin/activate +(venv) $ pip uninstall +``` + +### Restart WSGI Service + +Restart the WSGI service: + +```no-highlight +# sudo systemctl restart netbox +``` + +### Drop Database Tables + +!!! note + This step is necessary only for plugin which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure. + +Enter the PostgreSQL database shell to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.) + +```no-highlight +netbox=> \dt pluginname_* + List of relations + List of relations + Schema | Name | Type | Owner +--------+----------------+-------+-------- + public | pluginname_foo | table | netbox + public | pluginname_bar | table | netbox +(2 rows) +``` + +!!! warning + Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions. + +Drop each of the listed tables to remove it from the database: + +```no-highlight +netbox=> DROP TABLE pluginname_foo; +DROP TABLE +netbox=> DROP TABLE pluginname_bar; +DROP TABLE +``` diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 8990f83e0..f7f6f36e9 120000 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1 +1 @@ -version-2.10.md \ No newline at end of file +version-2.11.md \ No newline at end of file diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 2b3792204..9c46d3400 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -1,5 +1,82 @@ # NetBox v2.10 +## v2.10.10 (2021-04-15) + +### Enhancements + +* [#5796](https://github.com/netbox-community/netbox/issues/5796) - Add DC terminal power port, outlet types +* [#5980](https://github.com/netbox-community/netbox/issues/5980) - Add Saf-D-Grid power port, outlet types +* [#6157](https://github.com/netbox-community/netbox/issues/6157) - Support Markdown rendering for report logs +* [#6160](https://github.com/netbox-community/netbox/issues/6160) - Add F connector port type +* [#6168](https://github.com/netbox-community/netbox/issues/6168) - Add SFP56 50GE interface type + +### Bug Fixes + +* [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP +* [#5643](https://github.com/netbox-community/netbox/issues/5643) - Fix VLAN assignment when editing VM interfaces in bulk +* [#5652](https://github.com/netbox-community/netbox/issues/5652) - Update object data when renaming a custom field +* [#6056](https://github.com/netbox-community/netbox/issues/6056) - Optimize change log cleanup +* [#6144](https://github.com/netbox-community/netbox/issues/6144) - Fix MAC address field display in VM interfaces search form +* [#6152](https://github.com/netbox-community/netbox/issues/6152) - Fix custom field filtering for cables, virtual chassis +* [#6162](https://github.com/netbox-community/netbox/issues/6162) - Fix choice field filters (multiple models) + +--- + +## v2.10.9 (2021-04-12) + +### Enhancements + +* [#5526](https://github.com/netbox-community/netbox/issues/5526) - Add MAC address search field to VM interfaces list +* [#5756](https://github.com/netbox-community/netbox/issues/5756) - Omit child devices from non-racked devices list under rack view +* [#5840](https://github.com/netbox-community/netbox/issues/5840) - Add column to cable termination objects to display cable color +* [#6054](https://github.com/netbox-community/netbox/issues/6054) - Display NAPALM-enabled device tabs only when relevant +* [#6083](https://github.com/netbox-community/netbox/issues/6083) - Support disabling TLS certificate validation for Redis + +### Bug Fixes + +* [#5805](https://github.com/netbox-community/netbox/issues/5805) - Fix missing custom field filters for cables, rack reservations +* [#6070](https://github.com/netbox-community/netbox/issues/6070) - Add missing `count_ipaddresses` attribute to VMInterface serializer +* [#6073](https://github.com/netbox-community/netbox/issues/6073) - Permit users to manage their own REST API tokens without needing explicit permission +* [#6081](https://github.com/netbox-community/netbox/issues/6081) - Fix interface connections REST API endpoint +* [#6082](https://github.com/netbox-community/netbox/issues/6082) - Support colons in webhook header values +* [#6108](https://github.com/netbox-community/netbox/issues/6108) - Do not infer tenant assignment from parent objects for prefixes, IP addresses +* [#6117](https://github.com/netbox-community/netbox/issues/6117) - Handle exception when attempting to assign an MPTT-enabled model as its own parent +* [#6131](https://github.com/netbox-community/netbox/issues/6131) - Correct handling of boolean fields when cloning objects + +--- + +## v2.10.8 (2021-03-26) + +### Bug Fixes + +* [#6060](https://github.com/netbox-community/netbox/issues/6060) - Fix exception on cable trace in UI (regression from #5650) + +--- + +## v2.10.7 (2021-03-25) + +### Enhancements + +* [#5641](https://github.com/netbox-community/netbox/issues/5641) - Allow filtering device components by label +* [#5723](https://github.com/netbox-community/netbox/issues/5723) - Allow customization of the geographic mapping service via `MAPS_URL` config parameter +* [#5736](https://github.com/netbox-community/netbox/issues/5736) - Allow changing site assignment when bulk editing devices +* [#5953](https://github.com/netbox-community/netbox/issues/5953) - Support Markdown rendering for custom script descriptions +* [#6040](https://github.com/netbox-community/netbox/issues/6040) - Add UI search fields for asset tag for devices and racks + +### Bug Fixes + +* [#5595](https://github.com/netbox-community/netbox/issues/5595) - Restore ability to delete an uploaded device type image +* [#5650](https://github.com/netbox-community/netbox/issues/5650) - Denote when the total length of a cable trace may exceed the indicated value +* [#5962](https://github.com/netbox-community/netbox/issues/5962) - Ensure consistent display of change log action labels +* [#5966](https://github.com/netbox-community/netbox/issues/5966) - Skip Markdown reference link when tabbing through form fields +* [#5977](https://github.com/netbox-community/netbox/issues/5977) - Correct validation of `RELEASE_CHECK_URL` config parameter +* [#6006](https://github.com/netbox-community/netbox/issues/6006) - Fix VLAN group/site association for bulk prefix import +* [#6010](https://github.com/netbox-community/netbox/issues/6010) - Eliminate duplicate virtual chassis search results +* [#6012](https://github.com/netbox-community/netbox/issues/6012) - Pre-populate attributes when creating an available child prefix via the UI +* [#6023](https://github.com/netbox-community/netbox/issues/6023) - Fix display of bottom banner with uBlock Origin enabled + +--- + ## v2.10.6 (2021-03-09) ### Enhancements @@ -19,6 +96,8 @@ * [#5935](https://github.com/netbox-community/netbox/issues/5935) - Fix filtering prefixes list by multiple prefix values * [#5948](https://github.com/netbox-community/netbox/issues/5948) - Invalidate cached queries when running `renaturalize` +--- + ## v2.10.5 (2021-02-24) ### Bug Fixes @@ -221,43 +300,43 @@ All end-to-end cable paths are now cached using the new CablePath backend model. * Removed the `/extras/_custom_field_choices/` endpoint (replaced by new custom fields endpoint) * Added the `/status/` endpoint to convey NetBox's current status * circuits.CircuitTermination: - * Added the `/trace/` endpoint - * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` and `cable_peer_type` - * `port_speed` may now be null + * Added the `/trace/` endpoint + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * `port_speed` may now be null * dcim.Cable: Added `custom_fields` * dcim.ConsolePort: - * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` and `cable_peer_type` - * Removed `connection_status` from nested serializer + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.ConsoleServerPort: - * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` and `cable_peer_type` - * Removed `connection_status` from nested serializer + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.FrontPort: - * Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths - * Added `cable_peer` and `cable_peer_type` + * Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths + * Added `cable_peer` and `cable_peer_type` * dcim.Interface: - * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` and `cable_peer_type` - * Removed `connection_status` from nested serializer + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning * dcim.PowerFeed: - * Added the `/trace/` endpoint - * Added fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type` + * Added the `/trace/` endpoint + * Added fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type` * dcim.PowerOutlet: - * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` and `cable_peer_type` - * Removed `connection_status` from nested serializer + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.PowerPanel: Added `custom_fields` * dcim.PowerPort - * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` and `cable_peer_type` - * Removed `connection_status` from nested serializer + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.RackReservation: Added `custom_fields` * dcim.RearPort: - * Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths - * Added `cable_peer` and `cable_peer_type` + * Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths + * Added `cable_peer` and `cable_peer_type` * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md new file mode 100644 index 000000000..73c3ad85e --- /dev/null +++ b/docs/release-notes/version-2.11.md @@ -0,0 +1,443 @@ +# NetBox v2.11 + +## v2.11.12 (2021-08-23) + +### Enhancements + +* [#6748](https://github.com/netbox-community/netbox/issues/6748) - Add site group filter to devices list +* [#6790](https://github.com/netbox-community/netbox/issues/6790) - Recognize a /32 IPv4 address as a child of a /32 IPv4 prefix +* [#6872](https://github.com/netbox-community/netbox/issues/6872) - Add table configuration button to child prefixes view +* [#6929](https://github.com/netbox-community/netbox/issues/6929) - Introduce `LOGIN_PERSISTENCE` configuration parameter to persist user sessions +* [#7011](https://github.com/netbox-community/netbox/issues/7011) - Add search field to VM interfaces filter form + +### Bug Fixes + +* [#5968](https://github.com/netbox-community/netbox/issues/5968) - Model forms should save empty custom field values as null +* [#6326](https://github.com/netbox-community/netbox/issues/6326) - Enable filtering assigned VLANs by group in interface edit form +* [#6686](https://github.com/netbox-community/netbox/issues/6686) - Force assignment of null custom field values to objects +* [#6776](https://github.com/netbox-community/netbox/issues/6776) - Fix erroneous webhook dispatch on failure to save objects +* [#6974](https://github.com/netbox-community/netbox/issues/6974) - Show contextual label for IP address role +* [#7012](https://github.com/netbox-community/netbox/issues/7012) - Fix hidden "add components" dropdown on devices list + +--- + +## v2.11.11 (2021-08-12) + +### Enhancements + +* [#6883](https://github.com/netbox-community/netbox/issues/6883) - Add C21 & C22 power types +* [#6921](https://github.com/netbox-community/netbox/issues/6921) - Employ a sandbox when rendering Jinja2 code for increased security + +### Bug Fixes + +* [#6740](https://github.com/netbox-community/netbox/issues/6740) - Add import button to VM interfaces list +* [#6892](https://github.com/netbox-community/netbox/issues/6892) - Fix validation of unit ranges when creating a rack reservation +* [#6896](https://github.com/netbox-community/netbox/issues/6896) - Fix validation of IP address assigned as device/VM primary via NAT relation +* [#6902](https://github.com/netbox-community/netbox/issues/6902) - Populate device field when cloning device components +* [#6908](https://github.com/netbox-community/netbox/issues/6908) - Allow assignment of scope to VLAN groups upon import +* [#6909](https://github.com/netbox-community/netbox/issues/6909) - Remove extraneous `site` column from VLAN group import form +* [#6910](https://github.com/netbox-community/netbox/issues/6910) - Fix exception on invalid CSV import column name +* [#6918](https://github.com/netbox-community/netbox/issues/6918) - Fix return URL persistence when adding multiple objects sequentially +* [#6935](https://github.com/netbox-community/netbox/issues/6935) - Remove extraneous columns from inventory item and device bay tables +* [#6936](https://github.com/netbox-community/netbox/issues/6936) - Add missing `parent` column to inventory item import form + +--- + +## v2.11.10 (2021-07-28) + +### Enhancements + +* [#6560](https://github.com/netbox-community/netbox/issues/6560) - Enable CSV import via uploaded file +* [#6644](https://github.com/netbox-community/netbox/issues/6644) - Add 6P/4P pass-through port types +* [#6771](https://github.com/netbox-community/netbox/issues/6771) - Add count of inventory items to manufacturer view +* [#6785](https://github.com/netbox-community/netbox/issues/6785) - Add "hardwired" type for power port types + +### Bug Fixes + +* [#5442](https://github.com/netbox-community/netbox/issues/5442) - Fix assignment of permissions based on LDAP groups +* [#5627](https://github.com/netbox-community/netbox/issues/5627) - Fix filtering of interface connections list +* [#6759](https://github.com/netbox-community/netbox/issues/6759) - Fix assignment of parent interfaces for bulk import +* [#6773](https://github.com/netbox-community/netbox/issues/6773) - Add missing `display` field to rack unit serializer +* [#6774](https://github.com/netbox-community/netbox/issues/6774) - Fix A/Z assignment when swapping circuit terminations +* [#6777](https://github.com/netbox-community/netbox/issues/6777) - Fix default value validation for custom text fields +* [#6778](https://github.com/netbox-community/netbox/issues/6778) - Rack reservation should display rack's location +* [#6780](https://github.com/netbox-community/netbox/issues/6780) - Include rack location in navigation breadcrumbs +* [#6794](https://github.com/netbox-community/netbox/issues/6794) - Fix device name display on device status view +* [#6812](https://github.com/netbox-community/netbox/issues/6812) - Limit reported prefix utilization to 100% +* [#6822](https://github.com/netbox-community/netbox/issues/6822) - Use consistent maximum value for interface MTU + +### Other Changes + +* [#6781](https://github.com/netbox-community/netbox/issues/6781) - Database query caching is now disabled by default + +--- + +## v2.11.9 (2021-07-08) + +### Bug Fixes + +* [#6456](https://github.com/netbox-community/netbox/issues/6456) - API schema type should be boolean for `_occupied` on cable termination models +* [#6710](https://github.com/netbox-community/netbox/issues/6710) - Fix assignment of VM interface parent via REST API +* [#6714](https://github.com/netbox-community/netbox/issues/6714) - Fix rendering of device type component creation forms + +--- + +## v2.11.8 (2021-07-06) + +### Enhancements + +* [#5503](https://github.com/netbox-community/netbox/issues/5503) - Annotate short date & time fields with their longer form +* [#6138](https://github.com/netbox-community/netbox/issues/6138) - Add an `empty` filter modifier for character fields +* [#6200](https://github.com/netbox-community/netbox/issues/6200) - Add rack reservations to global search +* [#6368](https://github.com/netbox-community/netbox/issues/6368) - Enable virtual chassis assignment during bulk import of devices +* [#6620](https://github.com/netbox-community/netbox/issues/6620) - Show assigned VMs count under device role view +* [#6666](https://github.com/netbox-community/netbox/issues/6666) - Show management-only status under interface detail view +* [#6667](https://github.com/netbox-community/netbox/issues/6667) - Display VM memory as GB/TB as appropriate + +### Bug Fixes + +* [#6626](https://github.com/netbox-community/netbox/issues/6626) - Fix site field on VM search form; add site group +* [#6637](https://github.com/netbox-community/netbox/issues/6637) - Fix group assignment in "available VLANs" link under VLAN group view +* [#6640](https://github.com/netbox-community/netbox/issues/6640) - Disallow numeric values in custom text fields +* [#6652](https://github.com/netbox-community/netbox/issues/6652) - Fix exception when adding components in bulk to multiple devices +* [#6676](https://github.com/netbox-community/netbox/issues/6676) - Fix device/VM counts per cluster under cluster type/group views +* [#6680](https://github.com/netbox-community/netbox/issues/6680) - Allow setting custom field values for VM interfaces on initial creation +* [#6695](https://github.com/netbox-community/netbox/issues/6695) - Fix exception when importing device type with invalid front port definition + +--- + +## v2.11.7 (2021-06-16) + +### Enhancements + +* [#6455](https://github.com/netbox-community/netbox/issues/6455) - Permit /32 IPv4 and /128 IPv6 prefixes +* [#6493](https://github.com/netbox-community/netbox/issues/6493) - Show change log diff for non-atomic (pre-2.11) changes +* [#6564](https://github.com/netbox-community/netbox/issues/6564) - Add N connector type for pass-through ports +* [#6588](https://github.com/netbox-community/netbox/issues/6588) - Add support for webp files as front/rear device type images +* [#6589](https://github.com/netbox-community/netbox/issues/6589) - Standardize breadcrumb navigation for power panels and feeds + +### Bug Fixes + +* [#6553](https://github.com/netbox-community/netbox/issues/6553) - ProviderNetwork search should match on name +* [#6562](https://github.com/netbox-community/netbox/issues/6562) - Disable ordering of secrets by assigned object +* [#6563](https://github.com/netbox-community/netbox/issues/6563) - Fix filtering by location for cable connection forms +* [#6584](https://github.com/netbox-community/netbox/issues/6584) - Fix ordering of nested inventory items +* [#6602](https://github.com/netbox-community/netbox/issues/6602) - Fix deletion of devices with cables attached + +--- + +## v2.11.6 (2021-06-04) + +### Bug Fixes + +* [#6544](https://github.com/netbox-community/netbox/issues/6544) - Fix migration error when upgrading with VRF(s) defined + +--- + +## v2.11.5 (2021-06-04) + +**NOTE:** This release includes a database migration that calculates and annotates prefix depth. It may impose a noticeable delay on the upgrade process: Users should anticipate roughly one minute of delay per 100 thousand prefixes being updated. + +### Enhancements + +* [#6087](https://github.com/netbox-community/netbox/issues/6087) - Improved prefix hierarchy rendering +* [#6487](https://github.com/netbox-community/netbox/issues/6487) - Add location filter to cable connection form +* [#6501](https://github.com/netbox-community/netbox/issues/6501) - Expose prefix depth and children on REST API serializer +* [#6527](https://github.com/netbox-community/netbox/issues/6527) - Support Markdown for report descriptions +* [#6540](https://github.com/netbox-community/netbox/issues/6540) - Add a "flat" column to the prefix table + +### Bug Fixes + +* [#6064](https://github.com/netbox-community/netbox/issues/6064) - Fix object permission assignments for user and group models +* [#6217](https://github.com/netbox-community/netbox/issues/6217) - Disallow passing of string values for integer custom fields +* [#6284](https://github.com/netbox-community/netbox/issues/6284) - Avoid sending redundant webhooks when adding/removing tags +* [#6492](https://github.com/netbox-community/netbox/issues/6492) - Correct tag population in post-change data resulting from REST API changes +* [#6496](https://github.com/netbox-community/netbox/issues/6496) - Fix upgrade script when Python installed in nonstandard path +* [#6502](https://github.com/netbox-community/netbox/issues/6502) - Correct permissions evaluation for running a report via the REST API +* [#6517](https://github.com/netbox-community/netbox/issues/6517) - Fix assignment of user when creating rack reservations via REST API +* [#6525](https://github.com/netbox-community/netbox/issues/6525) - Paginate related IPs table under IP address view + +--- + +## v2.11.4 (2021-05-25) + +### Enhancements + +* [#5121](https://github.com/netbox-community/netbox/issues/5121) - Add content type filters for tags +* [#6358](https://github.com/netbox-community/netbox/issues/6358) - Add search field for VLAN groups +* [#6393](https://github.com/netbox-community/netbox/issues/6393) - Add `description` filter for IP addresses +* [#6400](https://github.com/netbox-community/netbox/issues/6400) - Add cyan color choice for plugin buttons +* [#6422](https://github.com/netbox-community/netbox/issues/6422) - Enable filtering users by group under admin UI +* [#6441](https://github.com/netbox-community/netbox/issues/6441) - Improve UI paginator to optimize page object count + +### Bug Fixes + +* [#6376](https://github.com/netbox-community/netbox/issues/6376) - Fix assignment of VLAN groups to clusters, cluster groups via REST API +* [#6398](https://github.com/netbox-community/netbox/issues/6398) - Avoid exception when deleting device connected to self via circuit +* [#6426](https://github.com/netbox-community/netbox/issues/6426) - Allow assigning virtual chassis member interfaces to LAG on VC master +* [#6438](https://github.com/netbox-community/netbox/issues/6438) - Fix missing descriptions and label for device type imports and exports +* [#6465](https://github.com/netbox-community/netbox/issues/6465) - Fix typo in installed plugins REST API endpoint +* [#6467](https://github.com/netbox-community/netbox/issues/6467) - Fix access to metrics on custom `BASE_PATH` when login is required +* [#6468](https://github.com/netbox-community/netbox/issues/6468) - Disable ordering VLAN groups list by scope object + +--- + +## v2.11.3 (2021-05-07) + +### Enhancements + +* [#6197](https://github.com/netbox-community/netbox/issues/6197) - Introduced `SESSION_COOKIE_NAME` config parameter +* [#6318](https://github.com/netbox-community/netbox/issues/6318) - Add OM5 MMF cable type +* [#6351](https://github.com/netbox-community/netbox/issues/6351) - Add aggregates count to tenant view +* [#6359](https://github.com/netbox-community/netbox/issues/6359) - Enable custom links for organizational and nested group models + +### Bug Fixes + +* [#6240](https://github.com/netbox-community/netbox/issues/6240) - Fix display of available VLAN ranges under VLAN group view +* [#6308](https://github.com/netbox-community/netbox/issues/6308) - Fix linking of available VLANs in VLAN group view +* [#6309](https://github.com/netbox-community/netbox/issues/6309) - Restrict parent VM interface assignment to the parent VM +* [#6312](https://github.com/netbox-community/netbox/issues/6312) - Interface device filter should return all virtual chassis interfaces only if device is master +* [#6313](https://github.com/netbox-community/netbox/issues/6313) - Fix device type instance count under manufacturer view +* [#6321](https://github.com/netbox-community/netbox/issues/6321) - Restore "add an IP" button under prefix IPs view +* [#6333](https://github.com/netbox-community/netbox/issues/6333) - Fix filtering of circuit terminations by primary key +* [#6339](https://github.com/netbox-community/netbox/issues/6339) - Improve ordering of interfaces when viewing virtual chassis master +* [#6350](https://github.com/netbox-community/netbox/issues/6350) - Include first & last IP addresses when allocating available IPv6 addresses via the REST API +* [#6355](https://github.com/netbox-community/netbox/issues/6355) - Fix caching error when swapping A/Z circuit terminations +* [#6357](https://github.com/netbox-community/netbox/issues/6357) - Fix ProviderNetwork nested API serializer +* [#6363](https://github.com/netbox-community/netbox/issues/6363) - Correct pre-population of cluster group when creating a cluster +* [#6369](https://github.com/netbox-community/netbox/issues/6369) - Fix interface assignment for VLANs in non-scoped groups + +--- + +## v2.11.2 (2021-04-27) + +### Enhancements + +* [#6275](https://github.com/netbox-community/netbox/issues/6275) - Linkify rack, device counts on locations list +* [#6278](https://github.com/netbox-community/netbox/issues/6278) - Note device locations on cable traces +* [#6287](https://github.com/netbox-community/netbox/issues/6287) - Add option to clear assigned max length filter on prefixes list + +### Bug Fixes + +* [#6236](https://github.com/netbox-community/netbox/issues/6236) - Journal entry title should account for configured timezone +* [#6246](https://github.com/netbox-community/netbox/issues/6246) - Permit full-length descriptions when creating device components and VM interfaces +* [#6248](https://github.com/netbox-community/netbox/issues/6248) - Fix table column reconfiguration under Chrome +* [#6252](https://github.com/netbox-community/netbox/issues/6252) - Fix assignment of console port speed values above 19.2kbps +* [#6254](https://github.com/netbox-community/netbox/issues/6254) - Disable ordering of space column in racks table +* [#6258](https://github.com/netbox-community/netbox/issues/6258) - Fix parent assignment for SiteGroup API serializer +* [#6262](https://github.com/netbox-community/netbox/issues/6262) - Support filtering by created/updated time for all relevant objects +* [#6267](https://github.com/netbox-community/netbox/issues/6267) - Fix cable tracing API endpoint for circuit terminations +* [#6289](https://github.com/netbox-community/netbox/issues/6289) - Fix assignment of VC member interfaces to LAG interfaces + +--- + +## v2.11.1 (2021-04-21) + +### Enhancements + +* [#6161](https://github.com/netbox-community/netbox/issues/6161) - Enable ordering of device component tables +* [#6179](https://github.com/netbox-community/netbox/issues/6179) - Enable natural ordering for virtual machines +* [#6189](https://github.com/netbox-community/netbox/issues/6189) - Add ability to search for locations by name or description +* [#6190](https://github.com/netbox-community/netbox/issues/6190) - Allow filtering devices with no location assigned +* [#6210](https://github.com/netbox-community/netbox/issues/6210) - Include child locations on location view + +### Bug Fixes + +* [#6184](https://github.com/netbox-community/netbox/issues/6184) - Fix parent object table column in prefix IP addresses list +* [#6188](https://github.com/netbox-community/netbox/issues/6188) - Support custom field filtering for regions, site groups, and locations +* [#6196](https://github.com/netbox-community/netbox/issues/6196) - Fix object list display for users with read-only permissions +* [#6215](https://github.com/netbox-community/netbox/issues/6215) - Restore tenancy section in virtual machine form + +--- + +## v2.11.0 (2021-04-16) + +**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v3.0, Python 3.7 or later will be required. + +### Breaking Changes + +* All objects now use numeric IDs in their UI view URLs instead of slugs. You may need to update external references to NetBox objects. (Note that this does _not_ affect the REST API.) +* The UI now uses numeric IDs when filtering object lists. You may need to update external links to filtered object lists. (Note that the slug- and name-based filters will continue to work, however the filter selection fields within the UI will not be automatically populated.) +* The RackGroup model has been renamed to Location (see [#4971](https://github.com/netbox-community/netbox/issues/4971)). Its REST API endpoint has changed from `/api/dcim/rack-groups/` to `/api/dcim/locations/`. +* The foreign key field `group` on dcim.Rack has been renamed to `location`. +* The foreign key field `site` on ipam.VLANGroup has been replaced with the `scope` generic foreign key (see [#5284](https://github.com/netbox-community/netbox/issues/5284)). +* Custom script ObjectVars no longer support the `queryset` parameter: Use `model` instead (see [#5995](https://github.com/netbox-community/netbox/issues/5995)). + +### New Features + +#### Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151)) + +NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after some time, journal entries persist for the life of the associated object. + +#### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519)) + +Virtual device and VM interfaces can now be assigned to a "parent" interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0. + +#### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451)) + +In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include both pre- and post-change representations of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status: + +```json +"snapshots": { + "prechange": { + "name": "Site 1", + "slug": "site-1", + "status": "active", + ... + }, + "postchange": { + "name": "Site 2", + "slug": "site-2", + "status": "planned", + ... + } +} +``` + +Note: The pre-change snapshot for a newly created will always be null, as will the post-change snapshot for a deleted object. + +#### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648)) + +Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where we don't necessarily know or care what is connected to an attachment point, but still need to reflect the termination as being occupied. + +In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true. + +#### Allow Assigning Devices to Locations ([#4971](https://github.com/netbox-community/netbox/issues/4971)) + +Devices can now be assigned to locations (formerly known as rack groups) within a site without needing to be assigned to a particular rack. This is handy for assigning devices to rooms or floors within a building where racks are not used. The `location` foreign key field has been added to the Device model to support this. + +#### Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999)) + +When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the current configuration of the table being viewed. For example, if you modify the sites list to display only the site name, tenant, and status, the rendered CSV will include only these columns, and they will appear in the order chosen. + +The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v3.0. + +#### Variable Scope Support for VLAN Groups ([#5284](https://github.com/netbox-community/netbox/issues/5284)) + +In previous releases, VLAN groups could be assigned only to a site. To afford more flexibility in conveying the true scope of an L2 domain, a VLAN group can now be assigned to a region, site group (new in v2.11), site, location, or rack. VLANs assigned to a group will be available only to devices and virtual machines which exist within its scope. + +For example, a VLAN within a group assigned to a location will be available only to devices assigned to that location (or one of its child locations), or to a rack within that location. + +#### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892)) + +This release introduces the new SiteGroup model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by functional role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user. + +#### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913)) + +The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. + +#### Provider Network Modeling ([#5986](https://github.com/netbox-community/netbox/issues/5986)) + +A new provider network model has been introduced to represent the boundary of a network that exists outside the scope of NetBox. Each instance of this model must be assigned to a provider, and circuits can now terminate to either provider networks or to sites. The use of this model will likely be extended by future releases to support overlay and virtual circuit modeling. + +### Enhancements + +* [#4833](https://github.com/netbox-community/netbox/issues/4833) - Allow assigning config contexts by device type +* [#5344](https://github.com/netbox-community/netbox/issues/5344) - Add support for custom fields in tables +* [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models +* [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models +* [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models +* [#5425](https://github.com/netbox-community/netbox/issues/5425) - Create separate tabs for VMs and devices under the cluster view +* [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields +* [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links +* [#5610](https://github.com/netbox-community/netbox/issues/5610) - Add REST API endpoint for webhooks +* [#5757](https://github.com/netbox-community/netbox/issues/5757) - Add unique identifier to every object view +* [#5830](https://github.com/netbox-community/netbox/issues/5830) - Add `as_attachment` to ExportTemplate to control download behavior +* [#5848](https://github.com/netbox-community/netbox/issues/5848) - Filter custom fields by content type in format `.` +* [#5891](https://github.com/netbox-community/netbox/issues/5891) - Add `display` field to all REST API serializers +* [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI +* [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location +* [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models +* [#5971](https://github.com/netbox-community/netbox/issues/5971) - Add dedicated views for organizational models +* [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models +* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial (decimal) vCPU allocations for virtual machines +* [#6001](https://github.com/netbox-community/netbox/issues/6001) - Paginate component tables under device views +* [#6038](https://github.com/netbox-community/netbox/issues/6038) - Include tagged objects list on tag view +* [#6088](https://github.com/netbox-community/netbox/issues/6088) - Improved table configuration form +* [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views +* [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page +* [#6146](https://github.com/netbox-community/netbox/issues/6146) - Add bulk disconnect support for power feeds +* [#6149](https://github.com/netbox-community/netbox/issues/6149) - Support image attachments for locations + +### Bug Fixes (from v2.11-beta1) + +* [#5583](https://github.com/netbox-community/netbox/issues/5583) - Eliminate redundant change records when adding/removing tags +* [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link +* [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table +* [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view +* [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface +* [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form +* [#6110](https://github.com/netbox-community/netbox/issues/6110) - Fix handling of TemplateColumn values for table export +* [#6123](https://github.com/netbox-community/netbox/issues/6123) - Prevent device from being assigned to mismatched site and location +* [#6124](https://github.com/netbox-community/netbox/issues/6124) - Location `parent` filter should return all child locations (not just those directly assigned) +* [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list +* [#6155](https://github.com/netbox-community/netbox/issues/6155) - Fix admin links for plugins, background tasks +* [#6171](https://github.com/netbox-community/netbox/issues/6171) - Fix display of horizontally-scrolling object lists +* [#6173](https://github.com/netbox-community/netbox/issues/6173) - Fix assigned device/VM count when bulk editing/deleting device roles +* [#6176](https://github.com/netbox-community/netbox/issues/6176) - Correct position of MAC address field when creating VM interfaces +* [#6177](https://github.com/netbox-community/netbox/issues/6177) - Prevent VM interface from being assigned as its own parent + +### Other Changes + +* [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers +* [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs +* [#5938](https://github.com/netbox-community/netbox/issues/5938) - Deprecated support for Python 3.6 +* [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields +* [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead) +* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view +* [#6071](https://github.com/netbox-community/netbox/issues/6071) - Cable traces now traverse circuits + +### REST API Changes + +* All primary keys are now 64-bit integers +* All model serializers now include a `display` field to be used for the presentation of an object to a human user +* All device components + * Added support for custom fields + * Added `created` and `last_updated` fields to track object creation and modification +* All device component templates + * Added `created` and `last_updated` fields to track object creation and modification +* All organizational models + * Added support for custom fields +* All cable termination models (cabled device components, power feeds, and circuit terminations) + * Added `mark_connected` boolean field to force connection status + * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied +* Renamed RackGroup to Location + * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` +* circuits.CircuitTermination + * Added the `provider_network` field + * Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields + * The `trace/` endpoint has been replaced with `paths/` +* circuits.ProviderNetwork + * Added the `/api/circuits/provider-networks/` endpoint +* dcim.Device + * Added the `location` field +* dcim.Interface + * Added the `parent` field +* dcim.PowerPanel + * Renamed `rack_group` field to `location` +* dcim.Rack + * Renamed `group` field to `location` +* dcim.Site + * Added the `group` foreign key field to SiteGroup +* dcim.SiteGroup + * Added the `/api/dcim/site-groups/` endpoint +* extras.ConfigContext + * Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups +* extras.CustomField + * Added new custom field type: `multi-select` +* extras.CustomLink + * Added the `/api/extras/custom-links/` endpoint +* extras.ExportTemplate + * Added the `as_attachment` boolean field +* extras.ObjectChange + * Added the `prechange_data` field + * Renamed `object_data` to `postchange_data` +* extras.Webhook + * Added the `/api/extras/webhooks/` endpoint +* ipam.VLANGroup + * Added the `scope_type`, `scope_id`, and `scope` fields (`scope` is a generic foreign key) + * Dropped the `site` foreign key field +* virtualization.VirtualMachine + * `vcpus` has been changed from an integer to a decimal value +* virtualization.VMInterface + * Added the `parent` field diff --git a/docs/requirements.txt b/docs/requirements.txt index 9c863fec7..18b55d37b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -mkdocs==1.1 +mkdocs-material git+https://github.com/cmacmackin/markdown-include.git diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 5d5777483..7fb789e0f 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -20,7 +20,7 @@ http://netbox/api/dcim/sites/ } ``` -A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../../configuration/optional-settings/#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: +A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/optional-settings.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: ``` $ curl http://netbox/api/dcim/sites/ diff --git a/docs/rest-api/filtering.md b/docs/rest-api/filtering.md index b77513297..45dfcfa36 100644 --- a/docs/rest-api/filtering.md +++ b/docs/rest-api/filtering.md @@ -61,27 +61,48 @@ These lookup expressions can be applied by adding a suffix to the desired field' Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions: -- `n` - not equal to (negation) -- `lt` - less than -- `lte` - less than or equal -- `gt` - greater than -- `gte` - greater than or equal +| Filter | Description | +|--------|-------------| +| `n` | Not equal to | +| `lt` | Less than | +| `lte` | Less than or equal to | +| `gt` | Greater than | +| `gte` | Greater than or equal to | + +Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900: + +```no-highlight +GET /api/ipam/vlans/?vid__gt=900 +``` ### String Fields String based (char) fields (Name, Address, etc) support these lookup expressions: -- `n` - not equal to (negation) -- `ic` - case insensitive contains -- `nic` - negated case insensitive contains -- `isw` - case insensitive starts with -- `nisw` - negated case insensitive starts with -- `iew` - case insensitive ends with -- `niew` - negated case insensitive ends with -- `ie` - case insensitive exact match -- `nie` - negated case insensitive exact match +| Filter | Description | +|--------|-------------| +| `n` | Not equal to | +| `ic` | Contains (case-insensitive) | +| `nic` | Does not contain (case-insensitive) | +| `isw` | Starts with (case-insensitive) | +| `nisw` | Does not start with (case-insensitive) | +| `iew` | Ends with (case-insensitive) | +| `niew` | Does not end with (case-insensitive) | +| `ie` | Exact match (case-insensitive) | +| `nie` | Inverse exact match (case-insensitive) | +| `empty` | Is empty (boolean) | + +Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name: + +```no-highlight +GET /api/dcim/devices/?name__ic=switch +``` ### Foreign Keys & Other Fields Certain other fields, namely foreign key relationships support just the negation -expression: `n`. +expression: `n`. Here is an example of a lookup expression on a foreign key, it would return all the VLANs that don't have a VLAN Group ID of 3203: + +```no-highlight +GET /api/ipam/vlans/?group_id__n=3203 +``` diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index 290343aa6..088286e22 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -269,7 +269,7 @@ The brief format is supported for both lists and individual objects. ### Excluding Config Contexts -When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext/) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. +When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. ## Pagination @@ -308,7 +308,7 @@ Vary: Accept } ``` -The default page is determined by the [`PAGINATE_COUNT`](../../configuration/optional-settings/#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: +The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: ``` http://netbox/api/dcim/devices/?limit=100 @@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a } ``` -The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../../configuration/optional-settings/#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. +The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. !!! warning Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. @@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.' ### Creating a New Object -To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. +To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](authentication.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. ```no-highlight curl -s -X POST \ diff --git a/docs/rest-api/working-with-secrets.md b/docs/rest-api/working-with-secrets.md index dafbb7239..5fbbf7355 100644 --- a/docs/rest-api/working-with-secrets.md +++ b/docs/rest-api/working-with-secrets.md @@ -4,7 +4,7 @@ As with most other objects, the REST API can be used to view, create, modify, an ## Generating a Session Key -In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../../core-functionality/secrets/#user-keys). The private key must be POSTed with the name `private_key`. +In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../core-functionality/secrets.md#user-keys). The private key must be POSTed with the name `private_key`. ```no-highlight $ curl -X POST http://netbox/api/secrets/get-session-key/ \ diff --git a/mkdocs.yml b/mkdocs.yml index 092cb559a..16b345b96 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,18 +1,26 @@ site_name: NetBox Documentation site_url: https://netbox.readthedocs.io/ +repo_name: netbox-community/netbox repo_url: https://github.com/netbox-community/netbox python: install: - requirements: docs/requirements.txt theme: - name: readthedocs - navigation_depth: 3 + name: material + icon: + repo: fontawesome/brands/github extra_css: - extra.css markdown_extensions: - - admonition: + - admonition + - attr_list - markdown_include.include: headingOffset: 1 + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + - pymdownx.superfences + - pymdownx.tabbed nav: - Introduction: 'index.md' - Installation: @@ -49,6 +57,7 @@ nav: - Custom Links: 'additional-features/custom-links.md' - Custom Scripts: 'additional-features/custom-scripts.md' - Export Templates: 'additional-features/export-templates.md' + - Journaling: 'additional-features/journaling.md' - NAPALM: 'additional-features/napalm.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' @@ -70,11 +79,14 @@ nav: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' - Style Guide: 'development/style-guide.md' + - Models: 'development/models.md' + - Adding Models: 'development/adding-models.md' - Extending Models: 'development/extending-models.md' - Application Registry: 'development/application-registry.md' - User Preferences: 'development/user-preferences.md' - Release Checklist: 'development/release-checklist.md' - Release Notes: + - Version 2.11: 'release-notes/version-2.11.md' - Version 2.10: 'release-notes/version-2.10.md' - Version 2.9: 'release-notes/version-2.9.md' - Version 2.8: 'release-notes/version-2.8.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 2d3457d2c..6f7cb4f21 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,16 +1,29 @@ from rest_framework import serializers -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', 'NestedCircuitTerminationSerializer', 'NestedCircuitTypeSerializer', + 'NestedProviderNetworkSerializer', 'NestedProviderSerializer', ] +# +# Provider networks +# + +class NestedProviderNetworkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') + + class Meta: + model = ProviderNetwork + fields = ['id', 'url', 'display', 'name'] + + # # Providers # @@ -21,7 +34,7 @@ class NestedProviderSerializer(WritableNestedSerializer): class Meta: model = Provider - fields = ['id', 'url', 'name', 'slug', 'circuit_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count'] # @@ -34,7 +47,7 @@ class NestedCircuitTypeSerializer(WritableNestedSerializer): class Meta: model = CircuitType - fields = ['id', 'url', 'name', 'slug', 'circuit_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count'] class NestedCircuitSerializer(WritableNestedSerializer): @@ -42,7 +55,7 @@ class NestedCircuitSerializer(WritableNestedSerializer): class Meta: model = Circuit - fields = ['id', 'url', 'cid'] + fields = ['id', 'url', 'display', 'cid'] class NestedCircuitTerminationSerializer(WritableNestedSerializer): @@ -51,4 +64,4 @@ class NestedCircuitTerminationSerializer(WritableNestedSerializer): class Meta: model = CircuitTermination - fields = ['id', 'url', 'circuit', 'term_side', 'cable'] + fields = ['id', 'url', 'display', 'circuit', 'term_side', 'cable', '_occupied'] diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 12ec9ba7f..014ec0fc8 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,12 +1,13 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices -from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer +from circuits.models import * +from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer -from extras.api.customfields import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer -from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer +from netbox.api import ChoiceField +from netbox.api.serializers import ( + BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer +) from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -15,15 +16,31 @@ from .nested_serializers import * # Providers # -class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class ProviderSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider fields = [ - 'id', 'url', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + ] + + +# +# Provider networks +# + +class ProviderNetworkSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') + provider = NestedProviderSerializer() + + class Meta: + model = ProviderNetwork + fields = [ + 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] @@ -31,28 +48,31 @@ class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Circuits # -class CircuitTypeSerializer(ValidatedModelSerializer): +class CircuitTypeSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType - fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'circuit_count', + ] -class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): +class CircuitCircuitTerminationSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') site = NestedSiteSerializer() + provider_network = NestedProviderNetworkSerializer() class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', + 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', ] -class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class CircuitSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') provider = NestedProviderSerializer() status = ChoiceField(choices=CircuitStatusChoices, required=False) @@ -64,21 +84,23 @@ class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Circuit fields = [ - 'id', 'url', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', + 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] -class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer): +class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() - site = NestedSiteSerializer() + site = NestedSiteSerializer(required=False) + provider_network = NestedProviderNetworkSerializer(required=False) cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable' + 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + '_occupied', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index b496796fe..5389e0bde 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -13,5 +13,8 @@ router.register('circuit-types', views.CircuitTypeViewSet) router.register('circuits', views.CircuitViewSet) router.register('circuit-terminations', views.CircuitTerminationViewSet) +# Provider networks +router.register('provider-networks', views.ProviderNetworkViewSet) + app_name = 'circuits-api' urlpatterns = router.urls diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 736871a73..3bceb2de0 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,9 +1,8 @@ -from django.db.models import Prefetch from rest_framework.routers import APIRootView -from circuits import filters -from circuits.models import Provider, CircuitTermination, CircuitType, Circuit -from dcim.api.views import PathEndpointMixin +from circuits import filtersets +from circuits.models import * +from dcim.api.views import PassThroughPortMixin from extras.api.views import CustomFieldModelViewSet from netbox.api.views import ModelViewSet from utilities.utils import count_related @@ -27,19 +26,19 @@ class ProviderViewSet(CustomFieldModelViewSet): circuit_count=count_related(Circuit, 'provider') ) serializer_class = serializers.ProviderSerializer - filterset_class = filters.ProviderFilterSet + filterset_class = filtersets.ProviderFilterSet # # Circuit Types # -class CircuitTypeViewSet(ModelViewSet): +class CircuitTypeViewSet(CustomFieldModelViewSet): queryset = CircuitType.objects.annotate( circuit_count=count_related(Circuit, 'type') ) serializer_class = serializers.CircuitTypeSerializer - filterset_class = filters.CircuitTypeFilterSet + filterset_class = filtersets.CircuitTypeFilterSet # @@ -48,21 +47,30 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')), - 'type', 'tenant', 'provider', + 'type', 'tenant', 'provider', 'termination_a', 'termination_z' ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer - filterset_class = filters.CircuitFilterSet + filterset_class = filtersets.CircuitFilterSet # # Circuit Terminations # -class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): +class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet): queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', '_path__destination', 'cable' + 'circuit', 'site', 'provider_network', 'cable' ) serializer_class = serializers.CircuitTerminationSerializer - filterset_class = filters.CircuitTerminationFilterSet + filterset_class = filtersets.CircuitTerminationFilterSet brief_prefetch_fields = ['circuit'] + + +# +# Provider networks +# + +class ProviderNetworkViewSet(CustomFieldModelViewSet): + queryset = ProviderNetwork.objects.prefetch_related('tags') + serializer_class = serializers.ProviderNetworkSerializer + filterset_class = filtersets.ProviderNetworkFilterSet diff --git a/netbox/circuits/filters.py b/netbox/circuits/filtersets.py similarity index 62% rename from netbox/circuits/filters.py rename to netbox/circuits/filtersets.py index fa563881c..15bc5a8b3 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filtersets.py @@ -1,25 +1,25 @@ import django_filters from django.db.models import Q -from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet -from dcim.models import Region, Site -from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet -from tenancy.filters import TenancyFilterSet -from utilities.filters import ( - BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter -) +from dcim.filtersets import CableTerminationFilterSet +from dcim.models import Region, Site, SiteGroup +from extras.filters import TagFilter +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from tenancy.filtersets import TenancyFilterSet +from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * __all__ = ( 'CircuitFilterSet', 'CircuitTerminationFilterSet', 'CircuitTypeFilterSet', + 'ProviderNetworkFilterSet', 'ProviderFilterSet', ) -class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): +class ProviderFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -37,6 +37,19 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='circuits__terminations__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='circuits__terminations__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site', queryset=Site.objects.all(), @@ -66,14 +79,7 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated ) -class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): - - class Meta: - model = CircuitType - fields = ['id', 'name', 'slug'] - - -class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): +class ProviderNetworkFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -88,6 +94,49 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Provider (slug)', ) + tag = TagFilter() + + class Meta: + model = ProviderNetwork + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ).distinct() + + +class CircuitTypeFilterSet(OrganizationalModelFilterSet): + + class Meta: + model = CircuitType + fields = ['id', 'name', 'slug'] + + +class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + provider_id = django_filters.ModelMultipleChoiceFilter( + queryset=Provider.objects.all(), + label='Provider (ID)', + ) + provider = django_filters.ModelMultipleChoiceFilter( + field_name='provider__slug', + queryset=Provider.objects.all(), + to_field_name='slug', + label='Provider (slug)', + ) + provider_network_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__provider_network', + queryset=ProviderNetwork.objects.all(), + label='ProviderNetwork (ID)', + ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), label='Circuit type (ID)', @@ -102,17 +151,6 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe choices=CircuitStatusChoices, null_value=None ) - site_id = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site', - queryset=Site.objects.all(), - label='Site (ID)', - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label='Site (slug)', - ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='terminations__site__region', @@ -126,6 +164,30 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='terminations__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='terminations__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) tag = TagFilter() class Meta: @@ -145,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe ).distinct() -class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -164,10 +226,14 @@ class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, Path to_field_name='slug', label='Site (slug)', ) + provider_network_id = django_filters.ModelMultipleChoiceFilter( + queryset=ProviderNetwork.objects.all(), + label='ProviderNetwork (ID)', + ) class Meta: model = CircuitTermination - fields = ['term_side', 'port_speed', 'upstream_speed', 'xconnect_id'] + fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4731c9adb..1b3eb3242 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,6 +1,7 @@ from django import forms +from django.utils.translation import gettext as _ -from dcim.models import Region, Site +from dcim.models import Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) @@ -8,12 +9,12 @@ from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DatePicker, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, - StaticSelect2Multiple, TagFilterField, + add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, DatePicker, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SmallTextarea, SlugField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * # @@ -33,6 +34,10 @@ class ProviderForm(BootstrapMixin, CustomFieldModelForm): fields = [ 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', ] + fieldsets = ( + ('Provider', ('name', 'slug', 'asn', 'tags')), + ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ) widgets = { 'noc_contact': SmallTextarea( attrs={'rows': 5} @@ -101,24 +106,101 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) asn = forms.IntegerField( required=False, - label='ASN' + label=_('ASN') + ) + tag = TagFilterField(model) + + +# +# Provider networks +# + +class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all() + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ProviderNetwork + fields = [ + 'provider', 'name', 'description', 'comments', 'tags', + ] + fieldsets = ( + ('Provider Network', ('provider', 'name', 'description', 'tags')), + ) + + +class ProviderNetworkCSVForm(CustomFieldModelCSVForm): + provider = CSVModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Assigned provider' + ) + + class Meta: + model = ProviderNetwork + fields = [ + 'provider', 'name', 'description', 'comments', + ] + + +class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), + widget=forms.MultipleHiddenInput + ) + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'description', 'comments', + ] + + +class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = ProviderNetwork + field_order = ['q', 'provider_id'] + q = forms.CharField( + required=False, + label=_('Search') + ) + provider_id = DynamicModelMultipleChoiceField( + queryset=Provider.objects.all(), + required=False, + label=_('Provider') ) tag = TagFilterField(model) @@ -127,7 +209,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): # Circuit types # -class CircuitTypeForm(BootstrapMixin, forms.ModelForm): +class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -137,7 +219,21 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): ] -class CircuitTypeCSVForm(CSVModelForm): +class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CircuitType.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class CircuitTypeCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -171,6 +267,10 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', 'comments', 'tags', ] + fieldsets = ( + ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) help_texts = { 'cid': "Unique circuit ID", 'commit_rate': "Committed rate", @@ -178,6 +278,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): widgets = { 'status': StaticSelect2(), 'install_date': DatePicker(), + 'commit_rate': SelectSpeedWidget(), } @@ -256,44 +357,53 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit field_order = [ - 'q', 'type', 'provider', 'status', 'region', 'site', 'tenant_group', 'tenant', 'commit_rate', + 'q', 'type_id', 'provider_id', 'provider_network_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', + 'commit_rate', ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - type = DynamicModelMultipleChoiceField( + type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Type') ) - provider = DynamicModelMultipleChoiceField( + provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Provider') + ) + provider_network_id = DynamicModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), + required=False, + query_params={ + 'provider_id': '$provider_id' + }, + label=_('Provider network') ) status = forms.MultipleChoiceField( choices=CircuitStatusChoices, required=False, widget=StaticSelect2Multiple() ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) commit_rate = forms.IntegerField( required=False, min_value=0, - label='Commit rate (Kbps)' + label=_('Commit rate (Kbps)') ) tag = TagFilterField(model) @@ -310,17 +420,31 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' - } + 'region_id': '$region', + 'group_id': '$site_group', + }, + required=False + ) + provider_network = DynamicModelChoiceField( + queryset=ProviderNetwork.objects.all(), + required=False ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed', + 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", @@ -329,4 +453,11 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): } widgets = { 'term_side': forms.HiddenInput(), + 'port_speed': SelectSpeedWidget(), + 'upstream_speed': SelectSpeedWidget(), } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/migrations/0025_standardize_models.py b/netbox/circuits/migrations/0025_standardize_models.py new file mode 100644 index 000000000..42745f35b --- /dev/null +++ b/netbox/circuits/migrations/0025_standardize_models.py @@ -0,0 +1,47 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0024_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AddField( + model_name='circuittermination', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='circuittermination', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0026_mark_connected.py b/netbox/circuits/migrations/0026_mark_connected.py new file mode 100644 index 000000000..8531e9715 --- /dev/null +++ b/netbox/circuits/migrations/0026_mark_connected.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0025_standardize_models'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='mark_connected', + field=models.BooleanField(default=False), + ), + ] diff --git a/netbox/circuits/migrations/0027_providernetwork.py b/netbox/circuits/migrations/0027_providernetwork.py new file mode 100644 index 000000000..e8fbdb8d4 --- /dev/null +++ b/netbox/circuits/migrations/0027_providernetwork.py @@ -0,0 +1,65 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0058_journalentry'), + ('circuits', '0026_mark_connected'), + ] + + operations = [ + # Create the new ProviderNetwork model + migrations.CreateModel( + name='ProviderNetwork', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='networks', to='circuits.provider')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('provider', 'name'), + }, + ), + migrations.AddConstraint( + model_name='providernetwork', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_provider_name'), + ), + migrations.AlterUniqueTogether( + name='providernetwork', + unique_together={('provider', 'name')}, + ), + + # Add ProviderNetwork FK to CircuitTermination + migrations.AddField( + model_name='circuittermination', + name='provider_network', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.providernetwork'), + ), + migrations.AlterField( + model_name='circuittermination', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'), + ), + + # Add FKs to CircuitTermination on Circuit + migrations.AddField( + model_name='circuit', + name='termination_a', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), + migrations.AddField( + model_name='circuit', + name='termination_z', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), + ] diff --git a/netbox/circuits/migrations/0028_cache_circuit_terminations.py b/netbox/circuits/migrations/0028_cache_circuit_terminations.py new file mode 100644 index 000000000..23734348e --- /dev/null +++ b/netbox/circuits/migrations/0028_cache_circuit_terminations.py @@ -0,0 +1,37 @@ +import sys + +from django.db import migrations + + +def cache_circuit_terminations(apps, schema_editor): + Circuit = apps.get_model('circuits', 'Circuit') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + if 'test' not in sys.argv: + print(f"\n Caching circuit terminations...", flush=True) + + a_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='A') + } + z_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='Z') + } + for circuit in Circuit.objects.all(): + Circuit.objects.filter(pk=circuit.pk).update( + termination_a_id=a_terminations.get(circuit.pk), + termination_z_id=z_terminations.get(circuit.pk), + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0027_providernetwork'), + ] + + operations = [ + migrations.RunPython( + code=cache_circuit_terminations, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/migrations/0029_circuit_tracing.py b/netbox/circuits/migrations/0029_circuit_tracing.py new file mode 100644 index 000000000..bddb38bb6 --- /dev/null +++ b/netbox/circuits/migrations/0029_circuit_tracing.py @@ -0,0 +1,32 @@ +from django.db import migrations +from django.db.models import Q + + +def delete_obsolete_cablepaths(apps, schema_editor): + """ + Delete all CablePath instances which originate or terminate at a CircuitTermination. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + CablePath = apps.get_model('dcim', 'CablePath') + + ct = ContentType.objects.get_for_model(CircuitTermination) + CablePath.objects.filter(Q(origin_type=ct) | Q(destination_type=ct)).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0028_cache_circuit_terminations'), + ] + + operations = [ + migrations.RemoveField( + model_name='circuittermination', + name='_path', + ), + migrations.RunPython( + code=delete_obsolete_cablepaths, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 3d6d5d232..699ded7b0 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,27 +1,27 @@ +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from taggit.managers import TaggableManager from dcim.fields import ASNField from dcim.models import CableTermination, PathEndpoint -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange from extras.utils import extras_features +from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from .choices import * -from .querysets import CircuitQuerySet __all__ = ( 'Circuit', 'CircuitTermination', 'CircuitType', + 'ProviderNetwork', 'Provider', ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Provider(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -60,7 +60,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -78,7 +77,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel): return self.name def get_absolute_url(self): - return reverse('circuits:provider', args=[self.slug]) + return reverse('circuits:provider', args=[self.pk]) def to_csv(self): return ( @@ -93,7 +92,65 @@ class Provider(ChangeLoggedModel, CustomFieldModel): ) -class CircuitType(ChangeLoggedModel): +# +# Provider networks +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ProviderNetwork(PrimaryModel): + """ + This represents a provider network which exists outside of NetBox, the details of which are unknown or + unimportant to the user. + """ + name = models.CharField( + max_length=100 + ) + provider = models.ForeignKey( + to='circuits.Provider', + on_delete=models.PROTECT, + related_name='networks' + ) + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + csv_headers = [ + 'provider', 'name', 'description', 'comments', + ] + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('provider', 'name') + constraints = ( + models.UniqueConstraint( + fields=('provider', 'name'), + name='circuits_providernetwork_provider_name' + ), + ) + unique_together = ('provider', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('circuits:providernetwork', args=[self.pk]) + + def to_csv(self): + return ( + self.provider.name, + self.name, + self.description, + self.comments, + ) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". @@ -122,7 +179,7 @@ class CircuitType(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) + return reverse('circuits:circuittype', args=[self.pk]) def to_csv(self): return ( @@ -132,8 +189,8 @@ class CircuitType(ChangeLoggedModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Circuit(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -182,8 +239,25 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): blank=True ) - objects = CircuitQuerySet.as_manager() - tags = TaggableManager(through=TaggedItem) + # Cache associated CircuitTerminations + termination_a = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + termination_z = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -218,22 +292,9 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return CircuitStatusChoices.CSS_CLASSES.get(self.status) - def _get_termination(self, side): - for ct in self.terminations.all(): - if ct.term_side == side: - return ct - return None - @property - def termination_a(self): - return self._get_termination('A') - - @property - def termination_z(self): - return self._get_termination('Z') - - -class CircuitTermination(PathEndpoint, CableTermination): +@extras_features('webhooks') +class CircuitTermination(ChangeLoggedModel, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -247,7 +308,16 @@ class CircuitTermination(PathEndpoint, CableTermination): site = models.ForeignKey( to='dcim.Site', on_delete=models.PROTECT, - related_name='circuit_terminations' + related_name='circuit_terminations', + blank=True, + null=True + ) + provider_network = models.ForeignKey( + to=ProviderNetwork, + on_delete=models.PROTECT, + related_name='circuit_terminations', + blank=True, + null=True ) port_speed = models.PositiveIntegerField( verbose_name='Port speed (Kbps)', @@ -282,26 +352,33 @@ class CircuitTermination(PathEndpoint, CableTermination): unique_together = ['circuit', 'term_side'] def __str__(self): - return 'Side {}'.format(self.get_term_side_display()) + return f'Termination {self.term_side}: {self.site or self.provider_network}' + + def get_absolute_url(self): + if self.site: + return self.site.get_absolute_url() + return self.provider_network.get_absolute_url() + + def clean(self): + super().clean() + + # Must define either site *or* provider network + if self.site is None and self.provider_network is None: + raise ValidationError("A circuit termination must attach to either a site or a provider network.") + if self.site and self.provider_network: + raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") def to_objectchange(self, action): # Annotate the parent Circuit try: - related_object = self.circuit + circuit = self.circuit except Circuit.DoesNotExist: # Parent circuit has been deleted - related_object = None - - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=related_object, - object_data=serialize_object(self) - ) + circuit = None + return super().to_objectchange(action, related_object=circuit) @property - def parent(self): + def parent_object(self): return self.circuit def get_peer_termination(self): diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py deleted file mode 100644 index 8a9bd50a4..000000000 --- a/netbox/circuits/querysets.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db.models import OuterRef, Subquery - -from utilities.querysets import RestrictedQuerySet - - -class CircuitQuerySet(RestrictedQuerySet): - - def annotate_sites(self): - """ - Annotate the A and Z termination site names for ordering. - """ - from circuits.models import CircuitTermination - _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) - return self.annotate( - a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), - z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), - ) diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 86db21400..a12cef671 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -1,17 +1,26 @@ from django.db.models.signals import post_delete, post_save from django.dispatch import receiver -from django.utils import timezone -from .models import Circuit, CircuitTermination +from dcim.signals import rebuild_paths +from .models import CircuitTermination + + +@receiver(post_save, sender=CircuitTermination) +def update_circuit(instance, **kwargs): + """ + When a CircuitTermination has been modified, update its parent Circuit. + """ + termination_name = f'termination_{instance.term_side.lower()}' + setattr(instance.circuit, termination_name, instance) + instance.circuit.save() @receiver((post_save, post_delete), sender=CircuitTermination) -def update_circuit(instance, **kwargs): +def rebuild_cablepaths(instance, raw=False, **kwargs): """ - When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. + Rebuild any CablePaths which traverse the peer CircuitTermination. """ - circuits = Circuit.objects.filter(pk=instance.circuit_id) - time = timezone.now() - for circuit in circuits: - circuit.last_updated = time - circuit.save() + if not raw: + peer_termination = instance.get_peer_termination() + if peer_termination: + rebuild_paths(peer_termination) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 782b02394..41a3aed7f 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,9 +1,18 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from tenancy.tables import COL_TENANT +from tenancy.tables import TenantColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn -from .models import Circuit, CircuitType, Provider +from .models import * + + +CIRCUITTERMINATION_LINK = """ +{% if value.site %} + {{ value.site }} +{% elif value.provider_network %} + {{ value.provider_network }} +{% endif %} +""" # @@ -12,7 +21,9 @@ from .models import Circuit, CircuitType, Provider class ProviderTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) circuit_count = tables.Column( accessor=Accessor('count_circuits'), verbose_name='Circuits' @@ -29,17 +40,41 @@ class ProviderTable(BaseTable): default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') +# +# Provider networks +# + +class ProviderNetworkTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + provider = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='circuits:providernetwork_list' + ) + + class Meta(BaseTable.Meta): + model = ProviderNetwork + fields = ('pk', 'name', 'provider', 'description', 'tags') + default_columns = ('pk', 'name', 'provider', 'description') + + # # Circuit types # class CircuitTypeTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) circuit_count = tables.Column( verbose_name='Circuits' ) - actions = ButtonsColumn(CircuitType, pk_field='slug') + actions = ButtonsColumn(CircuitType) class Meta(BaseTable.Meta): model = CircuitType @@ -53,22 +88,22 @@ class CircuitTypeTable(BaseTable): class CircuitTable(BaseTable): pk = ToggleColumn() - cid = tables.LinkColumn( + cid = tables.Column( + linkify=True, verbose_name='ID' ) - provider = tables.LinkColumn( - viewname='circuits:provider', - args=[Accessor('provider__slug')] + provider = tables.Column( + linkify=True ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT + tenant = TenantColumn() + termination_a = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, + verbose_name='Side A' ) - a_side = tables.Column( - verbose_name='A Side' - ) - z_side = tables.Column( - verbose_name='Z Side' + termination_z = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, + verbose_name='Side Z' ) tags = TagColumn( url_name='circuits:circuit_list' @@ -77,7 +112,9 @@ class CircuitTable(BaseTable): class Meta(BaseTable.Meta): model = Circuit fields = ( - 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate', - 'description', 'tags', + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', + 'commit_rate', 'description', 'tags', + ) + default_columns = ( + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', ) - default_columns = ('pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 6df931553..424b13d40 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,7 +1,7 @@ from django.urls import reverse from circuits.choices import * -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from dcim.models import Site from utilities.testing import APITestCase, APIViewTestCases @@ -17,7 +17,7 @@ class AppTest(APITestCase): class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider - brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Provider 4', @@ -49,7 +49,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): class CircuitTypeTest(APIViewTestCases.APIViewTestCase): model = CircuitType - brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] create_data = ( { 'name': 'Circuit Type 4', @@ -81,7 +81,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase): class CircuitTest(APIViewTestCases.APIViewTestCase): model = Circuit - brief_fields = ['cid', 'id', 'url'] + brief_fields = ['cid', 'display', 'id', 'url'] bulk_update_data = { 'status': 'planned', } @@ -129,7 +129,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase): class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): model = CircuitTermination - brief_fields = ['cable', 'circuit', 'id', 'term_side', 'url'] + brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url'] @classmethod def setUpTestData(cls): @@ -178,3 +178,43 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): cls.bulk_update_data = { 'port_speed': 123456 } + + +class ProviderNetworkTest(APIViewTestCases.APIViewTestCase): + model = ProviderNetwork + brief_fields = ['display', 'id', 'name', 'url'] + + @classmethod + def setUpTestData(cls): + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) + + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), + ) + ProviderNetwork.objects.bulk_create(provider_networks) + + cls.create_data = [ + { + 'name': 'Provider Network 4', + 'provider': providers[0].pk, + }, + { + 'name': 'Provider Network 5', + 'provider': providers[0].pk, + }, + { + 'name': 'Provider Network 6', + 'provider': providers[0].pk, + }, + ] + + cls.bulk_update_data = { + 'provider': providers[1].pk, + 'description': 'New description', + } diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filtersets.py similarity index 71% rename from netbox/circuits/tests/test_filters.py rename to netbox/circuits/tests/test_filtersets.py index 9477bfbac..4880a8388 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -1,13 +1,14 @@ from django.test import TestCase from circuits.choices import * -from circuits.filters import * -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from dcim.models import Cable, Region, Site +from circuits.filtersets import * +from circuits.models import * +from dcim.models import Cable, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup +from utilities.testing import ChangeLoggedFilterSetTests -class ProviderTestCase(TestCase): +class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Provider.objects.all() filterset = ProviderFilterSet @@ -27,13 +28,20 @@ class ProviderTestCase(TestCase): Region(name='Test Region 1', slug='test-region-1'), Region(name='Test Region 2', slug='test-region-2'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), ) Site.objects.bulk_create(sites) @@ -54,10 +62,6 @@ class ProviderTestCase(TestCase): CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Provider 1', 'Provider 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -74,13 +78,6 @@ class ProviderTestCase(TestCase): params = {'account': ['1234', '2345']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_site(self): - sites = Site.objects.all()[:2] - params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -88,8 +85,22 @@ class ProviderTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class CircuitTypeTestCase(TestCase): + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CircuitType.objects.all() filterset = CircuitTypeFilterSet @@ -102,10 +113,6 @@ class CircuitTypeTestCase(TestCase): CircuitType(name='Circuit Type 3', slug='circuit-type-3'), )) - def test_id(self): - params = {'id': [self.queryset.first().pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_name(self): params = {'name': ['Circuit Type 1']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -115,7 +122,7 @@ class CircuitTypeTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class CircuitTestCase(TestCase): +class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Circuit.objects.all() filterset = CircuitFilterSet @@ -127,14 +134,21 @@ class CircuitTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -165,6 +179,13 @@ class CircuitTestCase(TestCase): ) Provider.objects.bulk_create(providers) + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[1]), + ProviderNetwork(name='Provider Network 2', provider=providers[1]), + ProviderNetwork(name='Provider Network 3', provider=providers[1]), + ) + ProviderNetwork.objects.bulk_create(provider_networks) + circuits = ( Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE), Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE), @@ -179,13 +200,12 @@ class CircuitTestCase(TestCase): CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), + CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_cid(self): params = {'cid': ['Test Circuit 1', 'Test Circuit 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -205,6 +225,11 @@ class CircuitTestCase(TestCase): params = {'provider': [provider.slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_provider_network(self): + provider_networks = ProviderNetwork.objects.all()[:2] + params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): circuit_type = CircuitType.objects.first() params = {'type_id': [circuit_type.pk]} @@ -223,6 +248,13 @@ class CircuitTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -245,7 +277,7 @@ class CircuitTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class CircuitTerminationTestCase(TestCase): +class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CircuitTermination.objects.all() filterset = CircuitTerminationFilterSet @@ -253,14 +285,14 @@ class CircuitTerminationTestCase(TestCase): def setUpTestData(cls): sites = ( - Site(name='Test Site 1', slug='test-site-1'), - Site(name='Test Site 2', slug='test-site-2'), - Site(name='Test Site 3', slug='test-site-3'), + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) circuit_types = ( - CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'), + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), ) CircuitType.objects.bulk_create(circuit_types) @@ -269,10 +301,20 @@ class CircuitTerminationTestCase(TestCase): ) Provider.objects.bulk_create(providers) + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), + ) + ProviderNetwork.objects.bulk_create(provider_networks) + circuits = ( - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), ) Circuit.objects.bulk_create(circuits) @@ -283,6 +325,9 @@ class CircuitTerminationTestCase(TestCase): CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), + CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -290,7 +335,7 @@ class CircuitTerminationTestCase(TestCase): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -316,12 +361,44 @@ class CircuitTerminationTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_provider_network(self): + provider_networks = ProviderNetwork.objects.all()[:2] + params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} + +class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ProviderNetwork.objects.all() + filterset = ProviderNetworkFilterSet + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), + ) + Provider.objects.bulk_create(providers) + + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[1]), + ProviderNetwork(name='Provider Network 3', provider=providers[2]), + ) + ProviderNetwork.objects.bulk_create(provider_networks) + + def test_name(self): + params = {'name': ['Provider Network 1', 'Provider Network 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_provider(self): + providers = Provider.objects.all()[:2] + params = {'provider_id': [providers[0].pk, providers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'provider': [providers[0].slug, providers[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 3356fca8f..b6401b2fa 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,8 +1,12 @@ import datetime +from django.test import override_settings +from django.urls import reverse + from circuits.choices import * -from circuits.models import Circuit, CircuitType, Provider -from utilities.testing import ViewTestCases +from circuits.models import * +from dcim.models import Cable, Interface, Site +from utilities.testing import ViewTestCases, create_tags, create_test_device class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -17,7 +21,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): Provider(name='Provider 3', slug='provider-3', asn=65003), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Provider X', @@ -73,6 +77,10 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Circuit Type 6,circuit-type-6", ) + cls.bulk_edit_data = { + 'description': 'Foo', + } + class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Circuit @@ -98,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'cid': 'Circuit X', @@ -129,3 +137,99 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', 'comments': 'New comments', } + + +class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ProviderNetwork + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) + + ProviderNetwork.objects.bulk_create([ + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Provider Network X', + 'provider': providers[1].pk, + 'description': 'A new provider network', + 'comments': 'Longer description goes here', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,provider,description", + "Provider Network 4,Provider 1,Foo", + "Provider Network 5,Provider 1,Bar", + "Provider Network 6,Provider 1,Baz", + ) + + cls.bulk_edit_data = { + 'provider': providers[1].pk, + 'description': 'New description', + 'comments': 'New comments', + } + + +class CircuitTerminationTestCase( + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, +): + model = CircuitTermination + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + provider = Provider.objects.create(name='Provider 1', slug='provider-1') + circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + + circuits = ( + Circuit(cid='Circuit 1', provider=provider, type=circuittype), + Circuit(cid='Circuit 2', provider=provider, type=circuittype), + Circuit(cid='Circuit 3', provider=provider, type=circuittype), + ) + Circuit.objects.bulk_create(circuits) + + circuit_terminations = ( + CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]), + CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]), + CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]), + CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]), + ) + CircuitTermination.objects.bulk_create(circuit_terminations) + + cls.form_data = { + 'term_side': 'A', + 'site': sites[2].pk, + 'description': 'New description', + } + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + device = create_test_device('Device 1') + + circuittermination = CircuitTermination.objects.first() + interface = Interface.objects.create( + device=device, + name='Interface 1' + ) + Cable(termination_a=circuittermination, termination_b=interface).save() + + response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk})) + self.assertHttpStatus(response, 200) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index d757fd90d..1cea1965e 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,9 +1,10 @@ from django.urls import path from dcim.views import CableCreateView, PathTraceView -from extras.views import ObjectChangeLogView +from extras.views import ObjectChangeLogView, ObjectJournalView +from utilities.views import SlugRedirectView from . import views -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * app_name = 'circuits' urlpatterns = [ @@ -14,19 +15,35 @@ urlpatterns = [ path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - path('providers//', views.ProviderView.as_view(), name='provider'), - path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), - path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), - path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path('providers//', views.ProviderView.as_view(), name='provider'), + path('providers//', SlugRedirectView.as_view(), kwargs={'model': Provider}), + path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), + path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), + path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), + + # Provider networks + path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'), + path('provider-networks/add/', views.ProviderNetworkEditView.as_view(), name='providernetwork_add'), + path('provider-networks/import/', views.ProviderNetworkBulkImportView.as_view(), name='providernetwork_import'), + path('provider-networks/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'), + path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'), + path('provider-networks//', views.ProviderNetworkView.as_view(), name='providernetwork'), + path('provider-networks//edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'), + path('provider-networks//delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'), + path('provider-networks//changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}), + path('provider-networks//journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}), # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), + path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), - path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), - path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), - path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'), + path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), + path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), @@ -38,6 +55,7 @@ urlpatterns = [ path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), + path('circuits//journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}), path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), # Circuit terminations diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 9fea26652..e7bb889e0 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,15 +1,15 @@ from django.contrib import messages from django.db import transaction +from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render -from django_tables2 import RequestConfig from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.tables import paginate_table from utilities.utils import count_related -from . import filters, forms, tables +from . import filtersets, forms, tables from .choices import CircuitTerminationSideChoices -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * # @@ -20,7 +20,7 @@ class ProviderListView(generic.ObjectListView): queryset = Provider.objects.annotate( count_circuits=count_related(Circuit, 'provider') ) - filterset = filters.ProviderFilterSet + filterset = filtersets.ProviderFilterSet filterset_form = forms.ProviderFilterForm table = tables.ProviderTable @@ -33,16 +33,11 @@ class ProviderView(generic.ObjectView): provider=instance ).prefetch_related( 'type', 'tenant', 'terminations__site' - ).annotate_sites() + ) circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(circuits_table) + paginate_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -52,7 +47,6 @@ class ProviderView(generic.ObjectView): class ProviderEditView(generic.ObjectEditView): queryset = Provider.objects.all() model_form = forms.ProviderForm - template_name = 'circuits/provider_edit.html' class ProviderDeleteView(generic.ObjectDeleteView): @@ -69,7 +63,7 @@ class ProviderBulkEditView(generic.BulkEditView): queryset = Provider.objects.annotate( count_circuits=count_related(Circuit, 'provider') ) - filterset = filters.ProviderFilterSet + filterset = filtersets.ProviderFilterSet table = tables.ProviderTable form = forms.ProviderBulkEditForm @@ -78,10 +72,70 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): queryset = Provider.objects.annotate( count_circuits=count_related(Circuit, 'provider') ) - filterset = filters.ProviderFilterSet + filterset = filtersets.ProviderFilterSet table = tables.ProviderTable +# +# Provider networks +# + +class ProviderNetworkListView(generic.ObjectListView): + queryset = ProviderNetwork.objects.all() + filterset = filtersets.ProviderNetworkFilterSet + filterset_form = forms.ProviderNetworkFilterForm + table = tables.ProviderNetworkTable + + +class ProviderNetworkView(generic.ObjectView): + queryset = ProviderNetwork.objects.all() + + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + Q(termination_a__provider_network=instance.pk) | + Q(termination_z__provider_network=instance.pk) + ).prefetch_related( + 'type', 'tenant', 'terminations__site' + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('termination_a') + circuits_table.columns.hide('termination_z') + paginate_table(circuits_table, request) + + return { + 'circuits_table': circuits_table, + } + + +class ProviderNetworkEditView(generic.ObjectEditView): + queryset = ProviderNetwork.objects.all() + model_form = forms.ProviderNetworkForm + + +class ProviderNetworkDeleteView(generic.ObjectDeleteView): + queryset = ProviderNetwork.objects.all() + + +class ProviderNetworkBulkImportView(generic.BulkImportView): + queryset = ProviderNetwork.objects.all() + model_form = forms.ProviderNetworkCSVForm + table = tables.ProviderNetworkTable + + +class ProviderNetworkBulkEditView(generic.BulkEditView): + queryset = ProviderNetwork.objects.all() + filterset = filtersets.ProviderNetworkFilterSet + table = tables.ProviderNetworkTable + form = forms.ProviderNetworkBulkEditForm + + +class ProviderNetworkBulkDeleteView(generic.BulkDeleteView): + queryset = ProviderNetwork.objects.all() + filterset = filtersets.ProviderNetworkFilterSet + table = tables.ProviderNetworkTable + + # # Circuit Types # @@ -93,6 +147,23 @@ class CircuitTypeListView(generic.ObjectListView): table = tables.CircuitTypeTable +class CircuitTypeView(generic.ObjectView): + queryset = CircuitType.objects.all() + + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + type=instance + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('type') + paginate_table(circuits_table, request) + + return { + 'circuits_table': circuits_table, + } + + class CircuitTypeEditView(generic.ObjectEditView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm @@ -108,6 +179,15 @@ class CircuitTypeBulkImportView(generic.BulkImportView): table = tables.CircuitTypeTable +class CircuitTypeBulkEditView(generic.BulkEditView): + queryset = CircuitType.objects.annotate( + circuit_count=count_related(Circuit, 'type') + ) + filterset = filtersets.CircuitTypeFilterSet + table = tables.CircuitTypeTable + form = forms.CircuitTypeBulkEditForm + + class CircuitTypeBulkDeleteView(generic.BulkDeleteView): queryset = CircuitType.objects.annotate( circuit_count=count_related(Circuit, 'type') @@ -121,9 +201,9 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' - ).annotate_sites() - filterset = filters.CircuitFilterSet + 'provider', 'type', 'tenant', 'termination_a', 'termination_z' + ) + filterset = filtersets.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable @@ -131,36 +211,10 @@ class CircuitListView(generic.ObjectListView): class CircuitView(generic.ObjectView): queryset = Circuit.objects.all() - def get_extra_context(self, request, instance): - - # A-side termination - termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( - 'site__region' - ).filter( - circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A - ).first() - if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'): - termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') - - # Z-side termination - termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( - 'site__region' - ).filter( - circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z - ).first() - if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'): - termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') - - return { - 'termination_a': termination_a, - 'termination_z': termination_z, - } - class CircuitEditView(generic.ObjectEditView): queryset = Circuit.objects.all() model_form = forms.CircuitForm - template_name = 'circuits/circuit_edit.html' class CircuitDeleteView(generic.ObjectDeleteView): @@ -177,7 +231,7 @@ class CircuitBulkEditView(generic.BulkEditView): queryset = Circuit.objects.prefetch_related( 'provider', 'type', 'tenant', 'terminations' ) - filterset = filters.CircuitFilterSet + filterset = filtersets.CircuitFilterSet table = tables.CircuitTable form = forms.CircuitBulkEditForm @@ -186,7 +240,7 @@ class CircuitBulkDeleteView(generic.BulkDeleteView): queryset = Circuit.objects.prefetch_related( 'provider', 'type', 'tenant', 'terminations' ) - filterset = filters.CircuitFilterSet + filterset = filtersets.CircuitFilterSet table = tables.CircuitTable @@ -221,16 +275,11 @@ class CircuitSwapTerminations(generic.ObjectEditView): if form.is_valid(): - termination_a = CircuitTermination.objects.filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A - ).first() - termination_z = CircuitTermination.objects.filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z - ).first() + termination_a = CircuitTermination.objects.filter(pk=circuit.termination_a_id).first() + termination_z = CircuitTermination.objects.filter(pk=circuit.termination_z_id).first() if termination_a and termination_z: # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint - print('swapping') with transaction.atomic(): termination_a.term_side = '_' termination_a.save() @@ -238,14 +287,24 @@ class CircuitSwapTerminations(generic.ObjectEditView): termination_z.save() termination_a.term_side = 'Z' termination_a.save() + circuit.refresh_from_db() + circuit.termination_a = termination_z + circuit.termination_z = termination_a + circuit.save() elif termination_a: termination_a.term_side = 'Z' termination_a.save() + circuit.refresh_from_db() + circuit.termination_a = None + circuit.save() else: termination_z.term_side = 'A' termination_z.save() + circuit.refresh_from_db() + circuit.termination_z = None + circuit.save() - messages.success(request, "Swapped terminations for circuit {}.".format(circuit)) + messages.success(request, f"Swapped terminations for circuit {circuit}.") return redirect('circuits:circuit', pk=circuit.pk) return render(request, 'circuits/circuit_terminations_swap.html', { diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index d63d32d68..80e003efc 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim import models -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer __all__ = [ 'NestedCableSerializer', @@ -27,7 +27,7 @@ __all__ = [ 'NestedPowerPanelSerializer', 'NestedPowerPortSerializer', 'NestedPowerPortTemplateSerializer', - 'NestedRackGroupSerializer', + 'NestedLocationSerializer', 'NestedRackReservationSerializer', 'NestedRackRoleSerializer', 'NestedRackSerializer', @@ -35,6 +35,7 @@ __all__ = [ 'NestedRearPortTemplateSerializer', 'NestedRegionSerializer', 'NestedSiteSerializer', + 'NestedSiteGroupSerializer', 'NestedVirtualChassisSerializer', ] @@ -50,7 +51,17 @@ class NestedRegionSerializer(WritableNestedSerializer): class Meta: model = models.Region - fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth'] + fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth'] + + +class NestedSiteGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') + site_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.SiteGroup + fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth'] class NestedSiteSerializer(WritableNestedSerializer): @@ -58,21 +69,21 @@ class NestedSiteSerializer(WritableNestedSerializer): class Meta: model = models.Site - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'display', 'name', 'slug'] # # Racks # -class NestedRackGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') +class NestedLocationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') rack_count = serializers.IntegerField(read_only=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: - model = models.RackGroup - fields = ['id', 'url', 'name', 'slug', 'rack_count', '_depth'] + model = models.Location + fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count', '_depth'] class NestedRackRoleSerializer(WritableNestedSerializer): @@ -81,7 +92,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer): class Meta: model = models.RackRole - fields = ['id', 'url', 'name', 'slug', 'rack_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count'] class NestedRackSerializer(WritableNestedSerializer): @@ -90,7 +101,7 @@ class NestedRackSerializer(WritableNestedSerializer): class Meta: model = models.Rack - fields = ['id', 'url', 'name', 'display_name', 'device_count'] + fields = ['id', 'url', 'display', 'name', 'display_name', 'device_count'] class NestedRackReservationSerializer(WritableNestedSerializer): @@ -99,7 +110,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer): class Meta: model = models.RackReservation - fields = ['id', 'url', 'user', 'units'] + fields = ['id', 'url', 'display', 'user', 'units'] def get_user(self, obj): return obj.user.username @@ -115,7 +126,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer): class Meta: model = models.Manufacturer - fields = ['id', 'url', 'name', 'slug', 'devicetype_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'devicetype_count'] class NestedDeviceTypeSerializer(WritableNestedSerializer): @@ -125,7 +136,7 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer): class Meta: model = models.DeviceType - fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] + fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] class NestedConsolePortTemplateSerializer(WritableNestedSerializer): @@ -133,7 +144,7 @@ class NestedConsolePortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.ConsolePortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer): @@ -141,7 +152,7 @@ class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.ConsoleServerPortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedPowerPortTemplateSerializer(WritableNestedSerializer): @@ -149,7 +160,7 @@ class NestedPowerPortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.PowerPortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedPowerOutletTemplateSerializer(WritableNestedSerializer): @@ -157,7 +168,7 @@ class NestedPowerOutletTemplateSerializer(WritableNestedSerializer): class Meta: model = models.PowerOutletTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedInterfaceTemplateSerializer(WritableNestedSerializer): @@ -165,7 +176,7 @@ class NestedInterfaceTemplateSerializer(WritableNestedSerializer): class Meta: model = models.InterfaceTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedRearPortTemplateSerializer(WritableNestedSerializer): @@ -173,7 +184,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.RearPortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedFrontPortTemplateSerializer(WritableNestedSerializer): @@ -181,7 +192,7 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.FrontPortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): @@ -189,7 +200,7 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): class Meta: model = models.DeviceBayTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] # @@ -203,7 +214,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer): class Meta: model = models.DeviceRole - fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count'] class NestedPlatformSerializer(WritableNestedSerializer): @@ -213,7 +224,7 @@ class NestedPlatformSerializer(WritableNestedSerializer): class Meta: model = models.Platform - fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count'] class NestedDeviceSerializer(WritableNestedSerializer): @@ -221,7 +232,7 @@ class NestedDeviceSerializer(WritableNestedSerializer): class Meta: model = models.Device - fields = ['id', 'url', 'name', 'display_name'] + fields = ['id', 'url', 'display', 'name', 'display_name'] class NestedConsoleServerPortSerializer(WritableNestedSerializer): @@ -230,7 +241,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer): class Meta: model = models.ConsoleServerPort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedConsolePortSerializer(WritableNestedSerializer): @@ -239,7 +250,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer): class Meta: model = models.ConsolePort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedPowerOutletSerializer(WritableNestedSerializer): @@ -248,7 +259,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer): class Meta: model = models.PowerOutlet - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedPowerPortSerializer(WritableNestedSerializer): @@ -257,7 +268,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer): class Meta: model = models.PowerPort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedInterfaceSerializer(WritableNestedSerializer): @@ -266,7 +277,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer): class Meta: model = models.Interface - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedRearPortSerializer(WritableNestedSerializer): @@ -275,7 +286,7 @@ class NestedRearPortSerializer(WritableNestedSerializer): class Meta: model = models.RearPort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedFrontPortSerializer(WritableNestedSerializer): @@ -284,7 +295,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer): class Meta: model = models.FrontPort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedDeviceBaySerializer(WritableNestedSerializer): @@ -293,7 +304,7 @@ class NestedDeviceBaySerializer(WritableNestedSerializer): class Meta: model = models.DeviceBay - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'display', 'device', 'name'] class NestedInventoryItemSerializer(WritableNestedSerializer): @@ -303,19 +314,19 @@ class NestedInventoryItemSerializer(WritableNestedSerializer): class Meta: model = models.InventoryItem - fields = ['id', 'url', 'device', 'name', '_depth'] + fields = ['id', 'url', 'display', 'device', 'name', '_depth'] # # Cables # -class NestedCableSerializer(serializers.ModelSerializer): +class NestedCableSerializer(BaseModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') class Meta: model = models.Cable - fields = ['id', 'url', 'label'] + fields = ['id', 'url', 'display', 'label'] # @@ -342,7 +353,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer): class Meta: model = models.PowerPanel - fields = ['id', 'url', 'name', 'powerfeed_count'] + fields = ['id', 'url', 'display', 'name', 'powerfeed_count'] class NestedPowerFeedSerializer(WritableNestedSerializer): @@ -350,4 +361,4 @@ class NestedPowerFeedSerializer(WritableNestedSerializer): class Meta: model = models.PowerFeed - fields = ['id', 'url', 'name', 'cable'] + fields = ['id', 'url', 'display', 'name', 'cable', '_occupied'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0b7f2f1b2..c9d69fd00 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -3,23 +3,16 @@ from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * -from dcim.models import ( - Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) -from dcim.utils import decompile_path_node -from extras.api.customfields import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer +from dcim.models import * from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN -from netbox.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.serializers import ( + NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) from tenancy.api.nested_serializers import NestedTenantSerializer @@ -32,6 +25,7 @@ from .nested_serializers import * class CableTerminationSerializer(serializers.ModelSerializer): cable_peer_type = serializers.SerializerMethodField(read_only=True) cable_peer = serializers.SerializerMethodField(read_only=True) + _occupied = serializers.SerializerMethodField(read_only=True) def get_cable_peer_type(self, obj): if obj._cable_peer is not None: @@ -49,8 +43,12 @@ class CableTerminationSerializer(serializers.ModelSerializer): return serializer(obj._cable_peer, context=context).data return None + @swagger_serializer_method(serializer_or_field=serializers.BooleanField) + def get__occupied(self, obj): + return obj._occupied -class ConnectedEndpointSerializer(ValidatedModelSerializer): + +class ConnectedEndpointSerializer(serializers.ModelSerializer): connected_endpoint_type = serializers.SerializerMethodField(read_only=True) connected_endpoint = serializers.SerializerMethodField(read_only=True) connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) @@ -82,23 +80,39 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): # Regions/sites # -class RegionSerializer(CustomFieldModelSerializer): +class RegionSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') parent = NestedRegionSerializer(required=False, allow_null=True) site_count = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = Region - fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'site_count', 'custom_fields', '_depth'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'site_count', '_depth', + ] -class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class SiteGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') + parent = NestedSiteGroupSerializer(required=False, allow_null=True) + site_count = serializers.IntegerField(read_only=True) + + class Meta: + model = SiteGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'site_count', '_depth', + ] + + +class SiteSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') status = ChoiceField(choices=SiteStatusChoices, required=False) region = NestedRegionSerializer(required=False, allow_null=True) + group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - time_zone = TimeZoneField(required=False) + time_zone = TimeZoneSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) @@ -109,10 +123,10 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Site fields = [ - 'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', - 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', + 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', ] @@ -120,31 +134,37 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Racks # -class RackGroupSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') +class LocationSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') site = NestedSiteSerializer() - parent = NestedRackGroupSerializer(required=False, allow_null=True) + parent = NestedLocationSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) + device_count = serializers.IntegerField(read_only=True) class Meta: - model = RackGroup - fields = ['id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'rack_count', '_depth'] + model = Location + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', + 'last_updated', 'rack_count', 'device_count', '_depth', + ] -class RackRoleSerializer(ValidatedModelSerializer): +class RackRoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole - fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'rack_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', + 'rack_count', + ] -class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class RackSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') site = NestedSiteSerializer() - group = NestedRackGroupSerializer(required=False, allow_null=True, default=None) + location = NestedLocationSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) @@ -157,21 +177,22 @@ class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Rack fields = [ - 'id', 'url', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', + 'id', 'url', 'display', 'name', 'facility_id', 'display_name', 'site', 'location', 'tenant', 'status', + 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', + 'powerfeed_count', ] - # Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This + # Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This # prevents facility_id from being interpreted as a required field. validators = [ - UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'name')) + UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'name')) ] def validate(self, data): - # Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta. + # Validate uniqueness of (location, facility_id) since we omitted the automatically-created validator from Meta. if data.get('facility_id', None): - validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id')) + validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'facility_id')) validator(data, self) # Enforce model validation @@ -189,9 +210,13 @@ class RackUnitSerializer(serializers.Serializer): face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) occupied = serializers.BooleanField(read_only=True) + display = serializers.SerializerMethodField(read_only=True) + + def get_display(self, obj): + return obj['name'] -class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class RackReservationSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') rack = NestedRackSerializer() user = NestedUserSerializer() @@ -199,7 +224,10 @@ class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializ class Meta: model = RackReservation - fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields'] + fields = [ + 'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', + 'custom_fields', + ] class RackElevationDetailFilterSerializer(serializers.Serializer): @@ -242,7 +270,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # Device types # -class ManufacturerSerializer(ValidatedModelSerializer): +class ManufacturerSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -251,11 +279,12 @@ class ManufacturerSerializer(ValidatedModelSerializer): class Meta: model = Manufacturer fields = [ - 'id', 'url', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count', + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'devicetype_count', 'inventoryitem_count', 'platform_count', ] -class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class DeviceTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) @@ -264,9 +293,9 @@ class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = DeviceType fields = [ - 'id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', + 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', + 'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', ] @@ -281,7 +310,9 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + ] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): @@ -295,7 +326,9 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + ] class PowerPortTemplateSerializer(ValidatedModelSerializer): @@ -309,7 +342,10 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'created', 'last_updated', + ] class PowerOutletTemplateSerializer(ValidatedModelSerializer): @@ -331,7 +367,10 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'created', 'last_updated', + ] class InterfaceTemplateSerializer(ValidatedModelSerializer): @@ -341,7 +380,10 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): class Meta: model = InterfaceTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', + 'last_updated', + ] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -351,7 +393,10 @@ class RearPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = RearPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created', + 'last_updated', + ] class FrontPortTemplateSerializer(ValidatedModelSerializer): @@ -362,7 +407,10 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = FrontPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', + 'description', 'created', 'last_updated', + ] class DeviceBayTemplateSerializer(ValidatedModelSerializer): @@ -371,14 +419,14 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'description'] + fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] # # Devices # -class DeviceRoleSerializer(ValidatedModelSerializer): +class DeviceRoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -386,11 +434,12 @@ class DeviceRoleSerializer(ValidatedModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', + 'last_updated', 'device_count', 'virtualmachine_count', ] -class PlatformSerializer(ValidatedModelSerializer): +class PlatformSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) @@ -399,18 +448,19 @@ class PlatformSerializer(ValidatedModelSerializer): class Meta: model = Platform fields = [ - 'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count', - 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] -class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class DeviceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() + location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False) @@ -424,10 +474,10 @@ class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Device fields = [ - 'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', + 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -460,10 +510,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): class Meta(DeviceSerializer.Meta): fields = [ - 'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', + 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -475,7 +525,11 @@ class DeviceNAPALMSerializer(serializers.Serializer): method = serializers.DictField() -class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +# +# Device components +# + +class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -483,17 +537,23 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial allow_blank=True, required=False ) + speed = ChoiceField( + choices=ConsolePortSpeedChoices, + allow_blank=True, + required=False + ) cable = NestedCableSerializer(read_only=True) class Meta: model = ConsoleServerPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -501,17 +561,23 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, allow_blank=True, required=False ) + speed = ChoiceField( + choices=ConsolePortSpeedChoices, + allow_blank=True, + required=False + ) cable = NestedCableSerializer(read_only=True) class Meta: model = ConsolePort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -534,13 +600,13 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', - 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -553,16 +619,17 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co class Meta: model = PowerPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', - 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) + parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -578,10 +645,11 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co class Meta: model = Interface fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', - 'count_ipaddresses', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', + 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', + '_occupied', ] def validate(self, data): @@ -598,7 +666,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co return super().validate(data) -class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): +class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -607,8 +675,8 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Val class Meta: model = RearPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', - 'cable_peer_type', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', + 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -620,10 +688,10 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): class Meta: model = RearPort - fields = ['id', 'url', 'name', 'label'] + fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): +class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -633,26 +701,30 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Va class Meta: model = FrontPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', - 'cable_peer', 'cable_peer_type', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', + 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class DeviceBaySerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) class Meta: model = DeviceBay - fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags'] + fields = [ + 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags', + 'custom_fields', 'created', 'last_updated', + ] # # Inventory items # -class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class InventoryItemSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator @@ -663,8 +735,8 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class Meta: model = InventoryItem fields = [ - 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'discovered', 'description', 'tags', '_depth', + 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', ] @@ -672,7 +744,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): # Cables # -class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class CableSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') termination_a_type = ContentTypeField( queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) @@ -688,7 +760,7 @@ class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Cable fields = [ - 'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', + 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'custom_fields', ] @@ -779,7 +851,7 @@ class CablePathSerializer(serializers.ModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer): interface_a = serializers.SerializerMethodField() - interface_b = NestedInterfaceSerializer(source='connected_endpoint') + interface_b = NestedInterfaceSerializer(source='_path.destination') connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) class Meta: @@ -802,24 +874,24 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): # Virtual chassis # -class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VirtualChassisSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis - fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] + fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] # # Power panels # -class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class PowerPanelSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') site = NestedSiteSerializer() - rack_group = NestedRackGroupSerializer( + location = NestedLocationSerializer( required=False, allow_null=True, default=None @@ -828,15 +900,10 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = PowerPanel - fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count'] + fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] -class PowerFeedSerializer( - TaggedObjectSerializer, - CableTerminationSerializer, - ConnectedEndpointSerializer, - CustomFieldModelSerializer -): +class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( @@ -865,8 +932,8 @@ class PowerFeedSerializer( class Meta: model = PowerFeed fields = [ - 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'comments', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', + 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 689cb7aa1..43f956cb2 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -7,10 +7,11 @@ router.APIRootView = views.DCIMRootView # Sites router.register('regions', views.RegionViewSet) +router.register('site-groups', views.SiteGroupViewSet) router.register('sites', views.SiteViewSet) # Racks -router.register('rack-groups', views.RackGroupViewSet) +router.register('locations', views.LocationViewSet) router.register('rack-roles', views.RackRoleViewSet) router.register('racks', views.RackViewSet) router.register('rack-reservations', views.RackReservationViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 379873ade..744d16e0a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -2,6 +2,7 @@ import socket from collections import OrderedDict from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.db.models import F from django.http import HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404 @@ -15,14 +16,8 @@ from rest_framework.routers import APIRootView from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit -from dcim import filters -from dcim.models import ( - Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from dcim import filtersets +from dcim.models import * from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet from ipam.models import Prefix, VLAN from netbox.api.views import ModelViewSet @@ -108,7 +103,23 @@ class RegionViewSet(CustomFieldModelViewSet): cumulative=True ) serializer_class = serializers.RegionSerializer - filterset_class = filters.RegionFilterSet + filterset_class = filtersets.RegionFilterSet + + +# +# Site groups +# + +class SiteGroupViewSet(CustomFieldModelViewSet): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + serializer_class = serializers.SiteGroupSerializer + filterset_class = filtersets.SiteGroupFilterSet # @@ -127,35 +138,41 @@ class SiteViewSet(CustomFieldModelViewSet): virtualmachine_count=count_related(VirtualMachine, 'cluster__site') ) serializer_class = serializers.SiteSerializer - filterset_class = filters.SiteFilterSet + filterset_class = filtersets.SiteFilterSet # -# Rack groups +# Locations # -class RackGroupViewSet(ModelViewSet): - queryset = RackGroup.objects.add_related_count( - RackGroup.objects.all(), +class LocationViewSet(CustomFieldModelViewSet): + queryset = Location.objects.add_related_count( + Location.objects.add_related_count( + Location.objects.all(), + Device, + 'location', + 'device_count', + cumulative=True + ), Rack, - 'group', + 'location', 'rack_count', cumulative=True ).prefetch_related('site') - serializer_class = serializers.RackGroupSerializer - filterset_class = filters.RackGroupFilterSet + serializer_class = serializers.LocationSerializer + filterset_class = filtersets.LocationFilterSet # # Rack roles # -class RackRoleViewSet(ModelViewSet): +class RackRoleViewSet(CustomFieldModelViewSet): queryset = RackRole.objects.annotate( rack_count=count_related(Rack, 'role') ) serializer_class = serializers.RackRoleSerializer - filterset_class = filters.RackRoleFilterSet + filterset_class = filtersets.RackRoleFilterSet # @@ -164,13 +181,13 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.prefetch_related( - 'site', 'group__site', 'role', 'tenant', 'tags' + 'site', 'location', 'role', 'tenant', 'tags' ).annotate( device_count=count_related(Device, 'rack'), powerfeed_count=count_related(PowerFeed, 'rack') ) serializer_class = serializers.RackSerializer - filterset_class = filters.RackFilterSet + filterset_class = filtersets.RackFilterSet @swagger_auto_schema( responses={200: serializers.RackUnitSerializer(many=True)}, @@ -227,25 +244,21 @@ class RackViewSet(CustomFieldModelViewSet): class RackReservationViewSet(ModelViewSet): queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer - filterset_class = filters.RackReservationFilterSet - - # Assign user from request - def perform_create(self, serializer): - serializer.save(user=self.request.user) + filterset_class = filtersets.RackReservationFilterSet # # Manufacturers # -class ManufacturerViewSet(ModelViewSet): +class ManufacturerViewSet(CustomFieldModelViewSet): queryset = Manufacturer.objects.annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), platform_count=count_related(Platform, 'manufacturer') ) serializer_class = serializers.ManufacturerSerializer - filterset_class = filters.ManufacturerFilterSet + filterset_class = filtersets.ManufacturerFilterSet # @@ -257,7 +270,7 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): device_count=count_related(Device, 'device_type') ) serializer_class = serializers.DeviceTypeSerializer - filterset_class = filters.DeviceTypeFilterSet + filterset_class = filtersets.DeviceTypeFilterSet brief_prefetch_fields = ['manufacturer'] @@ -268,75 +281,75 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): class ConsolePortTemplateViewSet(ModelViewSet): queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer - filterset_class = filters.ConsolePortTemplateFilterSet + filterset_class = filtersets.ConsolePortTemplateFilterSet class ConsoleServerPortTemplateViewSet(ModelViewSet): queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer - filterset_class = filters.ConsoleServerPortTemplateFilterSet + filterset_class = filtersets.ConsoleServerPortTemplateFilterSet class PowerPortTemplateViewSet(ModelViewSet): queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer - filterset_class = filters.PowerPortTemplateFilterSet + filterset_class = filtersets.PowerPortTemplateFilterSet class PowerOutletTemplateViewSet(ModelViewSet): queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer - filterset_class = filters.PowerOutletTemplateFilterSet + filterset_class = filtersets.PowerOutletTemplateFilterSet class InterfaceTemplateViewSet(ModelViewSet): queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer - filterset_class = filters.InterfaceTemplateFilterSet + filterset_class = filtersets.InterfaceTemplateFilterSet class FrontPortTemplateViewSet(ModelViewSet): queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.FrontPortTemplateSerializer - filterset_class = filters.FrontPortTemplateFilterSet + filterset_class = filtersets.FrontPortTemplateFilterSet class RearPortTemplateViewSet(ModelViewSet): queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.RearPortTemplateSerializer - filterset_class = filters.RearPortTemplateFilterSet + filterset_class = filtersets.RearPortTemplateFilterSet class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer - filterset_class = filters.DeviceBayTemplateFilterSet + filterset_class = filtersets.DeviceBayTemplateFilterSet # # Device roles # -class DeviceRoleViewSet(ModelViewSet): +class DeviceRoleViewSet(CustomFieldModelViewSet): queryset = DeviceRole.objects.annotate( device_count=count_related(Device, 'device_role'), virtualmachine_count=count_related(VirtualMachine, 'role') ) serializer_class = serializers.DeviceRoleSerializer - filterset_class = filters.DeviceRoleFilterSet + filterset_class = filtersets.DeviceRoleFilterSet # # Platforms # -class PlatformViewSet(ModelViewSet): +class PlatformViewSet(CustomFieldModelViewSet): queryset = Platform.objects.annotate( device_count=count_related(Device, 'platform'), virtualmachine_count=count_related(VirtualMachine, 'platform') ) serializer_class = serializers.PlatformSerializer - filterset_class = filters.PlatformFilterSet + filterset_class = filtersets.PlatformFilterSet # @@ -345,10 +358,10 @@ class PlatformViewSet(ModelViewSet): class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): queryset = Device.objects.prefetch_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', + 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) - filterset_class = filters.DeviceFilterSet + filterset_class = filtersets.DeviceFilterSet def get_serializer_class(self): """ @@ -493,7 +506,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.ConsolePortSerializer - filterset_class = filters.ConsolePortFilterSet + filterset_class = filtersets.ConsolePortFilterSet brief_prefetch_fields = ['device'] @@ -502,58 +515,58 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): 'device', '_path__destination', 'cable', '_cable_peer', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer - filterset_class = filters.ConsoleServerPortFilterSet + filterset_class = filtersets.ConsoleServerPortFilterSet brief_prefetch_fields = ['device'] class PowerPortViewSet(PathEndpointMixin, ModelViewSet): queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.PowerPortSerializer - filterset_class = filters.PowerPortFilterSet + filterset_class = filtersets.PowerPortFilterSet brief_prefetch_fields = ['device'] class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.PowerOutletSerializer - filterset_class = filters.PowerOutletFilterSet + filterset_class = filtersets.PowerOutletFilterSet brief_prefetch_fields = ['device'] class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer - filterset_class = filters.InterfaceFilterSet + filterset_class = filtersets.InterfaceFilterSet brief_prefetch_fields = ['device'] class FrontPortViewSet(PassThroughPortMixin, ModelViewSet): queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') serializer_class = serializers.FrontPortSerializer - filterset_class = filters.FrontPortFilterSet + filterset_class = filtersets.FrontPortFilterSet brief_prefetch_fields = ['device'] class RearPortViewSet(PassThroughPortMixin, ModelViewSet): queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') serializer_class = serializers.RearPortSerializer - filterset_class = filters.RearPortFilterSet + filterset_class = filtersets.RearPortFilterSet brief_prefetch_fields = ['device'] class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags') serializer_class = serializers.DeviceBaySerializer - filterset_class = filters.DeviceBayFilterSet + filterset_class = filtersets.DeviceBayFilterSet brief_prefetch_fields = ['device'] class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags') serializer_class = serializers.InventoryItemSerializer - filterset_class = filters.InventoryItemFilterSet + filterset_class = filtersets.InventoryItemFilterSet brief_prefetch_fields = ['device'] @@ -566,7 +579,7 @@ class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): _path__destination_id__isnull=False ) serializer_class = serializers.ConsolePortSerializer - filterset_class = filters.ConsoleConnectionFilterSet + filterset_class = filtersets.ConsoleConnectionFilterSet class PowerConnectionViewSet(ListModelMixin, GenericViewSet): @@ -574,17 +587,17 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): _path__destination_id__isnull=False ) serializer_class = serializers.PowerPortSerializer - filterset_class = filters.PowerConnectionFilterSet + filterset_class = filtersets.PowerConnectionFilterSet class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): queryset = Interface.objects.prefetch_related('device', '_path').filter( - # Avoid duplicate connections by only selecting the lower PK in a connected pair - _path__destination_id__isnull=False, - pk__lt=F('_path__destination_id') + _path__destination_type__app_label='dcim', + _path__destination_type__model='interface', + _path__destination_id__isnull=False ) serializer_class = serializers.InterfaceConnectionSerializer - filterset_class = filters.InterfaceConnectionFilterSet + filterset_class = filtersets.InterfaceConnectionFilterSet # @@ -597,7 +610,7 @@ class CableViewSet(ModelViewSet): 'termination_a', 'termination_b' ) serializer_class = serializers.CableSerializer - filterset_class = filters.CableFilterSet + filterset_class = filtersets.CableFilterSet # @@ -609,7 +622,7 @@ class VirtualChassisViewSet(ModelViewSet): member_count=count_related(Device, 'virtual_chassis') ) serializer_class = serializers.VirtualChassisSerializer - filterset_class = filters.VirtualChassisFilterSet + filterset_class = filtersets.VirtualChassisFilterSet brief_prefetch_fields = ['master'] @@ -619,12 +632,12 @@ class VirtualChassisViewSet(ModelViewSet): class PowerPanelViewSet(ModelViewSet): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) serializer_class = serializers.PowerPanelSerializer - filterset_class = filters.PowerPanelFilterSet + filterset_class = filtersets.PowerPanelFilterSet # @@ -636,7 +649,7 @@ class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): 'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags' ) serializer_class = serializers.PowerFeedSerializer - filterset_class = filters.PowerFeedFilterSet + filterset_class = filtersets.PowerFeedFilterSet # diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index b1f8db93c..49f3d8c88 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -221,6 +221,29 @@ class ConsolePortTypeChoices(ChoiceSet): ) +class ConsolePortSpeedChoices(ChoiceSet): + + SPEED_1200 = 1200 + SPEED_2400 = 2400 + SPEED_4800 = 4800 + SPEED_9600 = 9600 + SPEED_19200 = 19200 + SPEED_38400 = 38400 + SPEED_57600 = 57600 + SPEED_115200 = 115200 + + CHOICES = ( + (SPEED_1200, '1200 bps'), + (SPEED_2400, '2400 bps'), + (SPEED_4800, '4800 bps'), + (SPEED_9600, '9600 bps'), + (SPEED_19200, '19.2 kbps'), + (SPEED_38400, '38.4 kbps'), + (SPEED_57600, '57.6 kbps'), + (SPEED_115200, '115.2 kbps'), + ) + + # # PowerPorts # @@ -233,6 +256,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_IEC_C14 = 'iec-60320-c14' TYPE_IEC_C16 = 'iec-60320-c16' TYPE_IEC_C20 = 'iec-60320-c20' + TYPE_IEC_C22 = 'iec-60320-c22' # IEC 60309 TYPE_IEC_PNE4H = 'iec-60309-p-n-e-4h' TYPE_IEC_PNE6H = 'iec-60309-p-n-e-6h' @@ -318,6 +342,12 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_USB_MICRO_B = 'usb-micro-b' TYPE_USB_3_B = 'usb-3-b' TYPE_USB_3_MICROB = 'usb-3-micro-b' + # Direct current (DC) + TYPE_DC = 'dc-terminal' + # Proprietary + TYPE_SAF_D_GRID = 'saf-d-grid' + # Other + TYPE_HARDWIRED = 'hardwired' CHOICES = ( ('IEC 60320', ( @@ -326,6 +356,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_IEC_C14, 'C14'), (TYPE_IEC_C16, 'C16'), (TYPE_IEC_C20, 'C20'), + (TYPE_IEC_C22, 'C22'), )), ('IEC 60309', ( (TYPE_IEC_PNE4H, 'P+N+E 4H'), @@ -418,6 +449,15 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_USB_3_B, 'USB 3.0 Type B'), (TYPE_USB_3_MICROB, 'USB 3.0 Micro B'), )), + ('DC', ( + (TYPE_DC, 'DC Terminal'), + )), + ('Proprietary', ( + (TYPE_SAF_D_GRID, 'Saf-D-Grid'), + )), + ('Other', ( + (TYPE_HARDWIRED, 'Hardwired'), + )), ) @@ -433,6 +473,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_IEC_C13 = 'iec-60320-c13' TYPE_IEC_C15 = 'iec-60320-c15' TYPE_IEC_C19 = 'iec-60320-c19' + TYPE_IEC_C21 = 'iec-60320-c21' # IEC 60309 TYPE_IEC_PNE4H = 'iec-60309-p-n-e-4h' TYPE_IEC_PNE6H = 'iec-60309-p-n-e-6h' @@ -511,8 +552,13 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_USB_A = 'usb-a' TYPE_USB_MICROB = 'usb-micro-b' TYPE_USB_C = 'usb-c' + # Direct current (DC) + TYPE_DC = 'dc-terminal' # Proprietary TYPE_HDOT_CX = 'hdot-cx' + TYPE_SAF_D_GRID = 'saf-d-grid' + # Other + TYPE_HARDWIRED = 'hardwired' CHOICES = ( ('IEC 60320', ( @@ -521,6 +567,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_IEC_C13, 'C13'), (TYPE_IEC_C15, 'C15'), (TYPE_IEC_C19, 'C19'), + (TYPE_IEC_C21, 'C21'), )), ('IEC 60309', ( (TYPE_IEC_PNE4H, 'P+N+E 4H'), @@ -606,8 +653,15 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_USB_MICROB, 'USB Micro B'), (TYPE_USB_C, 'USB Type C'), )), + ('DC', ( + (TYPE_DC, 'DC Terminal'), + )), ('Proprietary', ( (TYPE_HDOT_CX, 'HDOT Cx'), + (TYPE_SAF_D_GRID, 'Saf-D-Grid'), + )), + ('Other', ( + (TYPE_HARDWIRED, 'Hardwired'), )), ) @@ -649,6 +703,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_10GE_XENPAK = '10gbase-x-xenpak' TYPE_10GE_X2 = '10gbase-x-x2' TYPE_25GE_SFP28 = '25gbase-x-sfp28' + TYPE_50GE_SFP56 = '50gbase-x-sfp56' TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp' TYPE_50GE_QSFP28 = '50gbase-x-sfp28' TYPE_100GE_CFP = '100gbase-x-cfp' @@ -754,6 +809,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_10GE_XENPAK, 'XENPAK (10GE)'), (TYPE_10GE_X2, 'X2 (10GE)'), (TYPE_25GE_SFP28, 'SFP28 (25GE)'), + (TYPE_50GE_SFP56, 'SFP56 (50GE)'), (TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'), (TYPE_50GE_QSFP28, 'QSFP28 (50GE)'), (TYPE_100GE_CFP, 'CFP (100GE)'), @@ -881,12 +937,19 @@ class PortTypeChoices(ChoiceSet): TYPE_8P6C = '8p6c' TYPE_8P4C = '8p4c' TYPE_8P2C = '8p2c' + TYPE_6P6C = '6p6c' + TYPE_6P4C = '6p4c' + TYPE_6P2C = '6p2c' + TYPE_4P4C = '4p4c' + TYPE_4P2C = '4p2c' TYPE_GG45 = 'gg45' TYPE_TERA4P = 'tera-4p' TYPE_TERA2P = 'tera-2p' TYPE_TERA1P = 'tera-1p' TYPE_110_PUNCH = '110-punch' TYPE_BNC = 'bnc' + TYPE_F = 'f' + TYPE_N = 'n' TYPE_MRJ21 = 'mrj21' TYPE_ST = 'st' TYPE_SC = 'sc' @@ -910,12 +973,19 @@ class PortTypeChoices(ChoiceSet): (TYPE_8P6C, '8P6C'), (TYPE_8P4C, '8P4C'), (TYPE_8P2C, '8P2C'), + (TYPE_6P6C, '6P6C'), + (TYPE_6P4C, '6P4C'), + (TYPE_6P2C, '6P2C'), + (TYPE_4P4C, '4P4C'), + (TYPE_4P2C, '4P2C'), (TYPE_GG45, 'GG45'), (TYPE_TERA4P, 'TERA 4P'), (TYPE_TERA2P, 'TERA 2P'), (TYPE_TERA1P, 'TERA 1P'), (TYPE_110_PUNCH, '110 Punch'), (TYPE_BNC, 'BNC'), + (TYPE_F, 'F Connector'), + (TYPE_N, 'N Connector'), (TYPE_MRJ21, 'MRJ21'), ), ), @@ -963,6 +1033,7 @@ class CableTypeChoices(ChoiceSet): TYPE_MMF_OM2 = 'mmf-om2' TYPE_MMF_OM3 = 'mmf-om3' TYPE_MMF_OM4 = 'mmf-om4' + TYPE_MMF_OM5 = 'mmf-om5' TYPE_SMF = 'smf' TYPE_SMF_OS1 = 'smf-os1' TYPE_SMF_OS2 = 'smf-os2' @@ -993,6 +1064,7 @@ class CableTypeChoices(ChoiceSet): (TYPE_MMF_OM2, 'Multimode Fiber (OM2)'), (TYPE_MMF_OM3, 'Multimode Fiber (OM3)'), (TYPE_MMF_OM4, 'Multimode Fiber (OM4)'), + (TYPE_MMF_OM5, 'Multimode Fiber (OM5)'), (TYPE_SMF, 'Singlemode Fiber'), (TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'), (TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 0fc69be3b..2a4d368f4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -2,6 +2,9 @@ from django.db.models import Q from .choices import InterfaceTypeChoices +# Exclude SVG images (unsupported by PIL) +DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,image/webp' + # # Racks @@ -26,7 +29,7 @@ REARPORT_POSITIONS_MAX = 1024 # INTERFACE_MTU_MIN = 1 -INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer +INTERFACE_MTU_MAX = 65536 VIRTUAL_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_VIRTUAL, diff --git a/netbox/dcim/filters.py b/netbox/dcim/filtersets.py similarity index 76% rename from netbox/dcim/filters.py rename to netbox/dcim/filtersets.py index 7c9235e29..b04c14ba9 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filtersets.py @@ -1,25 +1,21 @@ import django_filters from django.contrib.auth.models import User -from django.db.models import Count -from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet -from tenancy.filters import TenancyFilterSet +from extras.filters import TagFilter +from extras.filtersets import LocalConfigContextFilterSet +from netbox.filtersets import ( + BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, +) +from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant from utilities.choices import ColorChoices from utilities.filters import ( - BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, - NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, + MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from .choices import * from .constants import * -from .models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from .models import * __all__ = ( @@ -41,6 +37,7 @@ __all__ = ( 'InterfaceFilterSet', 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', + 'LocationFilterSet', 'ManufacturerFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', @@ -52,18 +49,18 @@ __all__ = ( 'PowerPortFilterSet', 'PowerPortTemplateFilterSet', 'RackFilterSet', - 'RackGroupFilterSet', 'RackReservationFilterSet', 'RackRoleFilterSet', 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', 'SiteFilterSet', + 'SiteGroupFilterSet', 'VirtualChassisFilterSet', ) -class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CustomFieldModelFilterSet): +class RegionFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label='Parent region (ID)', @@ -80,7 +77,24 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CustomFieldModelFi fields = ['id', 'name', 'slug', 'description'] -class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): +class SiteGroupFilterSet(OrganizationalModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + label='Parent site group (ID)', + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=SiteGroup.objects.all(), + to_field_name='slug', + label='Parent site group (slug)', + ) + + class Meta: + model = SiteGroup + fields = ['id', 'name', 'slug', 'description'] + + +class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -97,11 +111,22 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) + group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='group', + lookup_expr='in', + label='Group (ID)', + ) + group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + lookup_expr='in', + to_field_name='slug', + label='Group (slug)', + ) tag = TagFilter() class Meta: @@ -132,7 +157,7 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, return queryset.filter(qs_filter) -class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class LocationFilterSet(OrganizationalModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -146,6 +171,19 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -156,30 +194,41 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): to_field_name='slug', label='Site (slug)', ) - parent_id = django_filters.ModelMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - label='Rack group (ID)', + parent_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='parent', + lookup_expr='in', + label='Location (ID)', ) - parent = django_filters.ModelMultipleChoiceFilter( - field_name='parent__slug', - queryset=RackGroup.objects.all(), + parent = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='parent', + lookup_expr='in', to_field_name='slug', - label='Rack group (slug)', + label='Location (slug)', ) class Meta: - model = RackGroup + model = Location fields = ['id', 'name', 'slug', 'description'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) -class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): + +class RackRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = RackRole fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): +class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -197,6 +246,19 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -207,18 +269,18 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, to_field_name='slug', label='Site (slug)', ) - group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='group', + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) - group = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='group', + location = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', lookup_expr='in', to_field_name='slug', - label='Rack group (slug)', + label='Location (slug)', ) status = django_filters.MultipleChoiceFilter( choices=RackStatusChoices, @@ -264,7 +326,7 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, ) -class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet): +class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -284,18 +346,18 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel to_field_name='slug', label='Site (slug)', ) - group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='rack__group', + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='rack__location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) - group = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='rack__group', + location = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='rack__location', lookup_expr='in', to_field_name='slug', - label='Rack group (slug)', + label='Location (slug)', ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), @@ -324,14 +386,14 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel ) -class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class ManufacturerFilterSet(OrganizationalModelFilterSet): class Meta: model = Manufacturer fields = ['id', 'name', 'slug', 'description'] -class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): +class DeviceTypeFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -417,7 +479,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdat return queryset.exclude(devicebaytemplates__isnull=value) -class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): +class DeviceTypeComponentFilterSet(django_filters.FilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', @@ -425,70 +487,86 @@ class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): ) -class ConsolePortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): +class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate fields = ['id', 'name', 'type'] -class ConsoleServerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): +class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate fields = ['id', 'name', 'type'] -class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): +class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw'] -class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): +class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + feed_leg = django_filters.MultipleChoiceFilter( + choices=PowerOutletFeedLegChoices, + null_value=None + ) class Meta: model = PowerOutletTemplate fields = ['id', 'name', 'type', 'feed_leg'] -class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): +class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=InterfaceTypeChoices, + null_value=None + ) class Meta: model = InterfaceTemplate fields = ['id', 'name', 'type', 'mgmt_only'] -class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): +class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=PortTypeChoices, + null_value=None + ) class Meta: model = FrontPortTemplate fields = ['id', 'name', 'type'] -class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): +class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=PortTypeChoices, + null_value=None + ) class Meta: model = RearPortTemplate fields = ['id', 'name', 'type', 'positions'] -class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): +class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate fields = ['id', 'name'] -class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class DeviceRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = DeviceRole fields = ['id', 'name', 'slug', 'color', 'vm_role'] -class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class PlatformFilterSet(OrganizationalModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', queryset=Manufacturer.objects.all(), @@ -506,13 +584,7 @@ class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] -class DeviceFilterSet( - BaseFilterSet, - TenancyFilterSet, - LocalConfigContextFilterSet, - CustomFieldModelFilterSet, - CreatedUpdatedFilterSet -): +class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -566,6 +638,19 @@ class DeviceFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -576,11 +661,11 @@ class DeviceFilterSet( to_field_name='slug', label='Site name (slug)', ) - rack_group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='rack__group', + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', @@ -722,6 +807,19 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='device__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='device__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='device__site', queryset=Site.objects.all(), @@ -775,7 +873,7 @@ class PathEndpointFilterSet(django_filters.FilterSet): return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False)) -class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -783,15 +881,10 @@ class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina class Meta: model = ConsolePort - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] -class ConsoleServerPortFilterSet( - BaseFilterSet, - DeviceComponentFilterSet, - CableTerminationFilterSet, - PathEndpointFilterSet -): +class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -799,10 +892,10 @@ class ConsoleServerPortFilterSet( class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] -class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -810,21 +903,25 @@ class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class Meta: model = PowerPort - fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description'] + fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None ) + feed_leg = django_filters.MultipleChoiceFilter( + choices=PowerOutletFeedLegChoices, + null_value=None + ) class Meta: model = PowerOutlet - fields = ['id', 'name', 'feed_leg', 'description'] + fields = ['id', 'name', 'label', 'feed_leg', 'description'] -class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -845,6 +942,11 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati method='filter_kind', label='Kind of interface', ) + parent_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent', + queryset=Interface.objects.all(), + label='Parent interface (ID)', + ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), @@ -867,14 +969,14 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class Meta: model = Interface - fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] + fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] def filter_device(self, queryset, name, value): try: devices = Device.objects.filter(**{'{}__in'.format(name): value}) vc_interface_ids = [] for device in devices: - vc_interface_ids.extend(device.vc_interfaces.values_list('id', flat=True)) + vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() @@ -885,7 +987,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati try: devices = Device.objects.filter(pk__in=id_list) for device in devices: - vc_interface_ids += device.vc_interfaces.values_list('id', flat=True) + vc_interface_ids += device.vc_interfaces().values_list('id', flat=True) return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() @@ -917,28 +1019,36 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati }.get(value, queryset.none()) -class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=PortTypeChoices, + null_value=None + ) class Meta: model = FrontPort - fields = ['id', 'name', 'type', 'description'] + fields = ['id', 'name', 'label', 'type', 'description'] -class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=PortTypeChoices, + null_value=None + ) class Meta: model = RearPort - fields = ['id', 'name', 'type', 'positions', 'description'] + fields = ['id', 'name', 'label', 'type', 'positions', 'description'] -class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] -class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -996,7 +1106,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = InventoryItem - fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered'] + fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered'] def search(self, queryset, name, value): if not value.strip(): @@ -1011,7 +1121,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): return queryset.filter(qs_filter) -class VirtualChassisFilterSet(BaseFilterSet): +class VirtualChassisFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1039,6 +1149,19 @@ class VirtualChassisFilterSet(BaseFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='master__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='master__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='master__site', queryset=Site.objects.all(), @@ -1075,10 +1198,10 @@ class VirtualChassisFilterSet(BaseFilterSet): Q(members__name__icontains=value) | Q(domain__icontains=value) ) - return queryset.filter(qs_filter) + return queryset.filter(qs_filter).distinct() -class CableFilterSet(BaseFilterSet): +class CableFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1142,7 +1265,7 @@ class CableFilterSet(BaseFilterSet): return queryset -class ConnectionFilterSet: +class ConnectionFilterSet(BaseFilterSet): def filter_site(self, queryset, name, value): if not value.strip(): @@ -1155,7 +1278,7 @@ class ConnectionFilterSet: return queryset.filter(**{f'{name}__in': value}) -class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): +class ConsoleConnectionFilterSet(ConnectionFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1173,7 +1296,7 @@ class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): fields = ['name'] -class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): +class PowerConnectionFilterSet(ConnectionFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1191,7 +1314,7 @@ class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): fields = ['name'] -class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): +class InterfaceConnectionFilterSet(ConnectionFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1209,7 +1332,7 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): fields = [] -class PowerPanelFilterSet(BaseFilterSet): +class PowerPanelFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1227,6 +1350,19 @@ class PowerPanelFilterSet(BaseFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -1237,11 +1373,11 @@ class PowerPanelFilterSet(BaseFilterSet): to_field_name='slug', label='Site name (slug)', ) - rack_group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='rack_group', + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) tag = TagFilter() @@ -1258,13 +1394,7 @@ class PowerPanelFilterSet(BaseFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet( - BaseFilterSet, - CableTerminationFilterSet, - PathEndpointFilterSet, - CustomFieldModelFilterSet, - CreatedUpdatedFilterSet -): +class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1282,6 +1412,19 @@ class PowerFeedFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='power_panel__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='power_panel__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site', queryset=Site.objects.all(), @@ -1302,6 +1445,10 @@ class PowerFeedFilterSet( queryset=Rack.objects.all(), label='Rack (ID)', ) + status = django_filters.MultipleChoiceFilter( + choices=PowerFeedStatusChoices, + null_value=None + ) tag = TagFilter() class Meta: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f629641e0..2eca6da08 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -6,23 +6,24 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from netaddr import EUI from netaddr.core import AddrFormatError from timezone_field import TimeZoneFormField from circuits.models import Circuit, CircuitTermination, Provider from extras.forms import ( - AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, - LocalConfigContextFilterForm, + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldModelCSVForm, CustomFieldFilterForm, + CustomFieldModelForm, LocalConfigContextFilterForm, ) from extras.models import Tag from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN -from ipam.models import IPAddress, VLAN +from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyFilterForm, TenancyForm -from tenancy.models import Tenant, TenantGroup +from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm, + ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, @@ -30,12 +31,7 @@ from utilities.forms import ( from virtualization.models import Cluster, ClusterGroup from .choices import * from .constants import * -from .models import ( - Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, - Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, - Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, -) +from .models import * DEVICE_BY_PK_RE = r'{\d+\}' @@ -58,38 +54,60 @@ def get_device_by_name_or_pk(name): return device -class DeviceComponentFilterForm(BootstrapMixin, forms.Form): +class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): field_order = [ - 'q', 'region', 'site' + 'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id', ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - to_field_name='slug', + name = forms.CharField( required=False ) - site = DynamicModelMultipleChoiceField( + label = forms.CharField( + required=False + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Device') ) class InterfaceCommonForm(forms.Form): + mac_address = forms.CharField( + empty_value=None, + required=False, + label='MAC address' + ) + mtu = forms.IntegerField( + required=False, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, + label='MTU' + ) def clean(self): super().clean() @@ -119,7 +137,7 @@ class InterfaceCommonForm(forms.Form): }) -class ComponentForm(BootstrapMixin, forms.Form): +class ComponentForm(forms.Form): """ Subclass this form when facilitating the creation of one or more device component or component templates based on a name pattern. @@ -187,7 +205,7 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm): ) -class RegionCSVForm(CSVModelForm): +class RegionCSVForm(CustomFieldModelCSVForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, @@ -200,11 +218,86 @@ class RegionCSVForm(CSVModelForm): fields = Region.csv_headers +class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Region.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + class RegionFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site q = forms.CharField( required=False, - label='Search' + label=_('Search') + ) + + +# +# Site groups +# + +class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = SiteGroup + fields = ( + 'parent', 'name', 'slug', 'description', + ) + + +class SiteGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site group' + ) + + class Meta: + model = SiteGroup + fields = SiteGroup.csv_headers + + +class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class SiteGroupFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = SiteGroup + q = forms.CharField( + required=False, + label=_('Search') ) @@ -217,7 +310,16 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Region.objects.all(), required=False ) + group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) slug = SlugField() + time_zone = TimeZoneFormField( + choices=add_blank_choice(TimeZoneFormField().choices), + required=False, + widget=StaticSelect2() + ) comments = CommentField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -227,10 +329,20 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Site fields = [ - 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', ] + fieldsets = ( + ('Site', ( + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), + ('Contact Info', ( + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', + )), + ) widgets = { 'physical_address': SmallTextarea( attrs={ @@ -270,6 +382,12 @@ class SiteCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Assigned region' ) + group = CSVModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -302,6 +420,10 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Region.objects.all(), required=False ) + group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -324,35 +446,40 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class Meta: nullable_fields = [ - 'region', 'tenant', 'asn', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asn', 'description', 'time_zone', ] class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Site - field_order = ['q', 'status', 'region', 'tenant_group', 'tenant'] + field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) status = forms.MultipleChoiceField( choices=SiteStatusChoices, required=False, widget=StaticSelect2Multiple() ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') + ) + group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Group') ) tag = TagFilterField(model) # -# Rack groups +# Locations # -class RackGroupForm(BootstrapMixin, forms.ModelForm): +class LocationForm(BootstrapMixin, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -360,14 +487,22 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) parent = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -376,55 +511,85 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: - model = RackGroup + model = Location fields = ( - 'region', 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', ) -class RackGroupCSVForm(CSVModelForm): +class LocationCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', help_text='Assigned site' ) parent = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + queryset=Location.objects.all(), required=False, to_field_name='name', - help_text='Parent rack group', + help_text='Parent location', error_messages={ - 'invalid_choice': 'Rack group not found.', + 'invalid_choice': 'Location not found.', } ) class Meta: - model = RackGroup - fields = RackGroup.csv_headers + model = Location + fields = Location.csv_headers -class RackGroupFilterForm(BootstrapMixin, forms.Form): - region = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - to_field_name='slug', +class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Location.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), required=False ) - site = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - to_field_name='slug', + parent = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ - 'region': '$region' + 'site_id': '$site' } ) - parent = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), - to_field_name='slug', + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class LocationFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Location + q = forms.CharField( + required=False, + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), required=False, query_params={ - 'region': '$region', - 'site': '$site', - } + 'region_id': '$region_id' + }, + label=_('Site') + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'site_id': '$site_id', + }, + label=_('Parent') ) @@ -432,7 +597,7 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): # Rack roles # -class RackRoleForm(BootstrapMixin, forms.ModelForm): +class RackRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -442,7 +607,7 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): ] -class RackRoleCSVForm(CSVModelForm): +class RackRoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -453,6 +618,25 @@ class RackRoleCSVForm(CSVModelForm): } +class RackRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RackRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = forms.CharField( + max_length=6, # RGB color code + required=False, + widget=ColorSelect() + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] + + # # Racks # @@ -465,14 +649,22 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -491,9 +683,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Rack fields = [ - 'region', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', + 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -514,8 +706,8 @@ class RackCSVForm(CustomFieldModelCSVForm): queryset=Site.objects.all(), to_field_name='name' ) - group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), required=False, to_field_name='name' ) @@ -560,9 +752,9 @@ class RackCSVForm(CustomFieldModelCSVForm): if data: - # Limit group queryset by assigned site + # Limit location queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['group'].queryset = self.fields['group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -577,15 +769,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -653,38 +853,38 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class Meta: nullable_fields = [ - 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ] class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Rack - field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] + field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) - group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, - label='Rack group', null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Location') ) status = forms.MultipleChoiceField( choices=RackStatusChoices, @@ -701,11 +901,14 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = DynamicModelMultipleChoiceField( + role_id = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Role') + ) + asset_tag = forms.CharField( + required=False ) tag = TagFilterField(model) @@ -715,15 +918,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackElevationFilterForm(RackFilterForm): - field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] + field_order = [ + 'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', + ] id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), - label='Rack', + label=_('Rack'), required=False, - display_field='display_name', query_params={ - 'site': '$site', - 'group_id': '$group_id', + 'site_id': '$site_id', + 'location_id': '$location_id', } ) @@ -740,15 +944,23 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - rack_group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -756,10 +968,9 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - display_field='display_name', query_params={ 'site_id': '$site', - 'group_id': '$rack_group', + 'location_id': '$location', } ) units = NumericArrayField( @@ -780,8 +991,13 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = RackReservation fields = [ - 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', + 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', + 'description', 'tags', ] + fieldsets = ( + ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) class RackReservationCSVForm(CustomFieldModelCSVForm): @@ -790,11 +1006,11 @@ class RackReservationCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Parent site' ) - rack_group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's group (if any)" + help_text="Rack's location (if any)" ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), @@ -815,21 +1031,21 @@ class RackReservationCSVForm(CustomFieldModelCSVForm): class Meta: model = RackReservation - fields = ('site', 'rack_group', 'rack', 'units', 'tenant', 'description') + fields = ('site', 'location', 'rack', 'units', 'tenant', 'description') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) if data: - # Limit rack_group queryset by assigned site + # Limit location queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) # Limit rack queryset by assigned site and group params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) @@ -859,37 +1075,36 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField nullable_fields = [] -class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): +class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = RackReservation - field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant'] + field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Region') ) - group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.prefetch_related('site'), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.prefetch_related('site'), required=False, - label='Rack group', + label=_('Location'), null_option='None' ) user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, - display_field='username', - label='User', + label=_('User'), widget=APISelectMultiple( api_url='/api/users/users/', ) @@ -901,7 +1116,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): # Manufacturers # -class ManufacturerForm(BootstrapMixin, forms.ModelForm): +class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -911,13 +1126,27 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): ] -class ManufacturerCSVForm(CSVModelForm): +class ManufacturerCSVForm(CustomFieldModelCSVForm): class Meta: model = Manufacturer fields = Manufacturer.csv_headers +class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Device types # @@ -941,14 +1170,19 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', ] + fieldsets = ( + ('Device Type', ( + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags', + )), + ('Images', ('front_image', 'rear_image')), + ) widgets = { 'subdevice_role': StaticSelect2(), - # Exclude SVG images (unsupported by PIL) - 'front_image': forms.FileInput(attrs={ - 'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff' + 'front_image': forms.ClearableFileInput(attrs={ + 'accept': DEVICETYPE_IMAGE_FORMATS }), - 'rear_image': forms.FileInput(attrs={ - 'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff' + 'rear_image': forms.ClearableFileInput(attrs={ + 'accept': DEVICETYPE_IMAGE_FORMATS }) } @@ -994,12 +1228,12 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): model = DeviceType q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - manufacturer = DynamicModelMultipleChoiceField( + manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Manufacturer') ) subdevice_role = forms.MultipleChoiceField( choices=add_blank_choice(SubdeviceRoleChoices), @@ -1055,7 +1289,7 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): # Device component templates # -class ComponentTemplateCreateForm(ComponentForm): +class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm): """ Base form for the creation of device component templates (subclassed from ComponentTemplateModel). """ @@ -1068,7 +1302,6 @@ class ComponentTemplateCreateForm(ComponentForm): ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -1597,7 +1830,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', + 'device_type', 'name', 'label', 'type', 'description', ] @@ -1606,7 +1839,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', + 'device_type', 'name', 'label', 'type', 'description', ] @@ -1615,7 +1848,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] @@ -1629,7 +1862,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] @@ -1641,7 +1874,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', + 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] @@ -1651,14 +1884,13 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): ) rear_port = forms.ModelChoiceField( queryset=RearPortTemplate.objects.all(), - to_field_name='name', - required=False + to_field_name='name' ) class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', + 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', ] @@ -1670,7 +1902,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'type', 'positions', + 'device_type', 'name', 'type', 'positions', 'label', 'description', ] @@ -1679,7 +1911,7 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm): class Meta: model = DeviceBayTemplate fields = [ - 'device_type', 'name', + 'device_type', 'name', 'label', 'description', ] @@ -1687,7 +1919,7 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm): # Device roles # -class DeviceRoleForm(BootstrapMixin, forms.ModelForm): +class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -1697,7 +1929,7 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): ] -class DeviceRoleCSVForm(CSVModelForm): +class DeviceRoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -1708,11 +1940,35 @@ class DeviceRoleCSVForm(CSVModelForm): } +class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = forms.CharField( + max_length=6, # RGB color code + required=False, + widget=ColorSelect() + ) + vm_role = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='VM role' + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] + + # # Platforms # -class PlatformForm(BootstrapMixin, forms.ModelForm): +class PlatformForm(BootstrapMixin, CustomFieldModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -1731,7 +1987,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): } -class PlatformCSVForm(CSVModelForm): +class PlatformCSVForm(CustomFieldModelCSVForm): slug = SlugField() manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), @@ -1745,6 +2001,29 @@ class PlatformCSVForm(CSVModelForm): fields = Platform.csv_headers +class PlatformBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Platform.objects.all(), + widget=forms.MultipleHiddenInput + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + napalm_driver = forms.CharField( + max_length=50, + required=False + ) + # TODO: Bulk edit support for napalm_args + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['manufacturer', 'napalm_driver', 'description'] + + # # Devices # @@ -1757,16 +2036,23 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - rack_group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site' }, @@ -1777,10 +2063,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site', - 'group_id': '$rack_group', + 'location_id': '$location', } ) position = forms.IntegerField( @@ -1803,7 +2088,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -1846,9 +2130,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Device fields = [ - 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', - 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', - 'comments', 'tags', 'local_context_data' + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', + 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', + 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", @@ -1873,7 +2157,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ip_choices = [(None, '---------')] # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member - interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True) + interface_ids = self.instance.vc_interfaces(if_master=False).values_list('pk', flat=True) # Collect interface IPs interface_ips = IPAddress.objects.filter( @@ -1957,6 +2241,12 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm): choices=DeviceStatusChoices, help_text='Operational status' ) + virtual_chassis = CSVModelChoiceField( + queryset=VirtualChassis.objects.all(), + to_field_name='name', + required=False, + help_text='Virtual chassis' + ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', @@ -1967,6 +2257,10 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm): class Meta: fields = [] model = Device + help_texts = { + 'vc_position': 'Virtual chassis position', + 'vc_priority': 'Virtual chassis priority', + } def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -1984,17 +2278,17 @@ class DeviceCSVForm(BaseDeviceCSVForm): to_field_name='name', help_text='Assigned site' ) - rack_group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's group (if any)" + help_text="Assigned location (if any)" ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', required=False, - help_text="Assigned rack" + help_text="Assigned rack (if any)" ) face = CSVChoiceField( choices=DeviceFaceChoices, @@ -2005,7 +2299,8 @@ class DeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_group', 'rack', 'position', 'face', 'cluster', 'comments', + 'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', + 'comments', ] def __init__(self, data=None, *args, **kwargs): @@ -2013,14 +2308,14 @@ class DeviceCSVForm(BaseDeviceCSVForm): if data: - # Limit rack_group queryset by assigned site + # Limit location queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) # Limit rack queryset by assigned site and group params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) @@ -2040,7 +2335,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay', 'cluster', 'comments', + 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', ] def __init__(self, data=None, *args, **kwargs): @@ -2079,7 +2374,6 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), required=False, - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -2088,6 +2382,17 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=DeviceRole.objects.all(), required=False ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -2116,75 +2421,83 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm): model = Device field_order = [ - 'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant', - 'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip', + 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', + 'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip', ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site') ) - rack_group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, - label='Rack group', + null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Location') ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', null_option='None', query_params={ - 'site': '$site', - 'group_id': '$rack_group_id', - } + 'site_id': '$site_id', + 'location_id': '$location_id', + }, + label=_('Rack') ) - role = DynamicModelMultipleChoiceField( + role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), - to_field_name='slug', - required=False - ) - manufacturer = DynamicModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='slug', required=False, - label='Manufacturer' + label=_('Role') + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer') ) device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, - label='Model', - display_field='model', query_params={ - 'manufacturer': '$manufacturer' - } + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Model') ) - platform = DynamicModelMultipleChoiceField( + platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Platform') ) status = forms.MultipleChoiceField( choices=DeviceStatusChoices, required=False, widget=StaticSelect2Multiple() ) + asset_tag = forms.CharField( + required=False + ) mac_address = forms.CharField( required=False, label='MAC address' @@ -2252,16 +2565,15 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt # Device components # -class ComponentCreateForm(ComponentForm): +class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm): """ Base form for the creation of device components (models subclassed from ComponentModel). """ device = DynamicModelChoiceField( - queryset=Device.objects.all(), - display_field='display_name' + queryset=Device.objects.all() ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) tags = DynamicModelMultipleChoiceField( @@ -2270,7 +2582,7 @@ class ComponentCreateForm(ComponentForm): ) -class DeviceBulkAddComponentForm(ComponentForm): +class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldForm, ComponentForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -2297,10 +2609,15 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelect2Multiple() ) + speed = forms.MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False, + widget=StaticSelect2Multiple() + ) tag = TagFilterField(model) -class ConsolePortForm(BootstrapMixin, forms.ModelForm): +class ConsolePortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2309,7 +2626,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePort fields = [ - 'device', 'name', 'label', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2317,37 +2634,48 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class ConsolePortCreateForm(ComponentCreateForm): + model = ConsolePort type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, widget=StaticSelect2() ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'description', 'tags') + speed = forms.ChoiceField( + choices=add_blank_choice(ConsolePortSpeedChoices), + required=False, + widget=StaticSelect2() + ) + field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') class ConsolePortBulkCreateForm( - form_from_model(ConsolePort, ['type']), + form_from_model(ConsolePort, ['type', 'speed', 'mark_connected']), DeviceBulkAddComponentForm ): - field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags') + model = ConsolePort + field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags') class ConsolePortBulkEditForm( - form_from_model(ConsolePort, ['label', 'type', 'description']), + form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ConsolePort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class ConsolePortCSVForm(CSVModelForm): +class ConsolePortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -2357,6 +2685,13 @@ class ConsolePortCSVForm(CSVModelForm): required=False, help_text='Port type' ) + speed = CSVTypedChoiceField( + choices=ConsolePortSpeedChoices, + coerce=int, + empty_value=None, + required=False, + help_text='Port speed in bps' + ) class Meta: model = ConsolePort @@ -2375,10 +2710,15 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelect2Multiple() ) + speed = forms.MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False, + widget=StaticSelect2Multiple() + ) tag = TagFilterField(model) -class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): +class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2387,7 +2727,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'label', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2395,37 +2735,48 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortCreateForm(ComponentCreateForm): + model = ConsoleServerPort type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, widget=StaticSelect2() ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'description', 'tags') + speed = forms.ChoiceField( + choices=add_blank_choice(ConsolePortSpeedChoices), + required=False, + widget=StaticSelect2() + ) + field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') class ConsoleServerPortBulkCreateForm( - form_from_model(ConsoleServerPort, ['type']), + form_from_model(ConsoleServerPort, ['type', 'speed', 'mark_connected']), DeviceBulkAddComponentForm ): - field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags') + model = ConsoleServerPort + field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags') class ConsoleServerPortBulkEditForm( - form_from_model(ConsoleServerPort, ['label', 'type', 'description']), + form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class ConsoleServerPortCSVForm(CSVModelForm): +class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -2435,6 +2786,13 @@ class ConsoleServerPortCSVForm(CSVModelForm): required=False, help_text='Port type' ) + speed = CSVTypedChoiceField( + choices=ConsolePortSpeedChoices, + coerce=int, + empty_value=None, + required=False, + help_text='Port speed in bps' + ) class Meta: model = ConsoleServerPort @@ -2456,7 +2814,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class PowerPortForm(BootstrapMixin, forms.ModelForm): +class PowerPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2465,7 +2823,8 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort fields = [ - 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', + 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', + 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2473,6 +2832,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class PowerPortCreateForm(ComponentCreateForm): + model = PowerPort type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False, @@ -2489,33 +2849,39 @@ class PowerPortCreateForm(ComponentCreateForm): help_text="Allocated draw in watts" ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', + 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', + 'description', 'tags', ) class PowerPortBulkCreateForm( - form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw']), + form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'mark_connected']), DeviceBulkAddComponentForm ): + model = PowerPort field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') class PowerPortBulkEditForm( - form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'description']), + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=PowerPort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class PowerPortCSVForm(CSVModelForm): +class PowerPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -2546,7 +2912,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class PowerOutletForm(BootstrapMixin, forms.ModelForm): +class PowerOutletForm(BootstrapMixin, CustomFieldModelForm): power_port = forms.ModelChoiceField( queryset=PowerPort.objects.all(), required=False @@ -2559,7 +2925,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags', + 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2576,6 +2942,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class PowerOutletCreateForm(ComponentCreateForm): + model = PowerOutlet type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False, @@ -2589,7 +2956,10 @@ class PowerOutletCreateForm(ComponentCreateForm): choices=add_blank_choice(PowerOutletFeedLegChoices), required=False ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'description', 'tags') + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', + 'tags', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2602,17 +2972,18 @@ class PowerOutletCreateForm(ComponentCreateForm): class PowerOutletBulkCreateForm( - form_from_model(PowerOutlet, ['type', 'feed_leg']), + form_from_model(PowerOutlet, ['type', 'feed_leg', 'mark_connected']), DeviceBulkAddComponentForm ): + model = PowerOutlet field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags') class PowerOutletBulkEditForm( - form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'description']), + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=PowerOutlet.objects.all(), @@ -2624,9 +2995,13 @@ class PowerOutletBulkEditForm( disabled=True, widget=forms.HiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: - nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') + nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2640,7 +3015,7 @@ class PowerOutletBulkEditForm( self.fields['power_port'].widget.attrs['disabled'] = True -class PowerOutletCSVForm(CSVModelForm): +class PowerOutletCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -2720,25 +3095,39 @@ class InterfaceFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): +class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent interface' + ) + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='LAG interface', + query_params={ + 'type': 'lag', + } + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, label='Untagged VLAN', - display_field='display_name', - brief_mode=False, query_params={ - 'site_id': 'null', + 'group_id': '$vlan_group', } ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, label='Tagged VLANs', - display_field='display_name', - brief_mode=False, query_params={ - 'site_id': 'null', + 'group_id': '$vlan_group', } ) tags = DynamicModelMultipleChoiceField( @@ -2749,13 +3138,12 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', + 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', + 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect2(), - 'lag': StaticSelect2(), 'mode': StaticSelect2(), } labels = { @@ -2768,26 +3156,23 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.is_bound: - device = Device.objects.get(pk=self.data['device']) + device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device + + # Restrict parent/LAG interface assignment by device/VC + self.fields['parent'].widget.add_query_param('device_id', device.pk) + if device.virtual_chassis and device.virtual_chassis.master: + # Get available LAG interfaces by VirtualChassis master + self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) else: - device = self.instance.device + self.fields['lag'].widget.add_query_param('device_id', device.pk) - # Limit LAG choices to interfaces belonging to this device or a peer VC member - device_query = Q(device=device) - if device.virtual_chassis: - device_query |= Q(device__virtual_chassis=device.virtual_chassis) - self.fields['lag'].queryset = Interface.objects.filter( - device_query, - type=InterfaceTypeChoices.TYPE_LAG - ).exclude(pk=self.instance.pk) - - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Limit VLAN choices by device + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): + model = Interface type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect2(), @@ -2796,17 +3181,20 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, initial=True ) - lag = forms.ModelChoiceField( + parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent LAG', - widget=StaticSelect2(), + query_params={ + 'device_id': '$device', + } ) - mtu = forms.IntegerField( + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' + query_params={ + 'device_id': '$device', + 'type': 'lag', + } ) mac_address = forms.CharField( required=False, @@ -2824,58 +3212,43 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - display_field='display_name', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - display_field='display_name', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'description', - 'mgmt_only', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' + 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit LAG choices to interfaces belonging to this device or a peer VC member - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - device_query = Q(device=device) - if device.virtual_chassis: - device_query |= Q(device__virtual_chassis=device.virtual_chassis) - self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG) - - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Limit VLAN choices by device + device_id = self.initial.get('device') or self.data.get('device') + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id) class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only']), + form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']), DeviceBulkAddComponentForm ): - field_order = ('name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags') + model = Interface + field_order = ( + 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + ) class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode' + 'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', ]), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), @@ -2891,51 +3264,55 @@ class InterfaceBulkEditForm( required=False, widget=BulkEditNullBooleanSelect ) + parent = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False + ) + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'type': 'lag', + } + ) mgmt_only = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, label='Management only' ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - display_field='display_name', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - display_field='display_name', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) class Meta: - nullable_fields = ( - 'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' - ) + nullable_fields = [ + 'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' + ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - # Limit LAG choices to interfaces which belong to the parent device (or VC master) if 'device' in self.initial: device = Device.objects.filter(pk=self.initial['device']).first() - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[device, device.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Restrict parent/LAG interface assignment by device + self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['lag'].widget.add_query_param('device_id', device.pk) + + # Limit VLAN choices by device + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) + else: - # See 4523 + # See #4523 if 'pk' in self.initial: site = None interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site') @@ -2953,6 +3330,8 @@ class InterfaceBulkEditForm( self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + self.fields['parent'].choices = () + self.fields['parent'].widget.attrs['disabled'] = True self.fields['lag'].choices = () self.fields['lag'].widget.attrs['disabled'] = True @@ -2970,11 +3349,17 @@ class InterfaceBulkEditForm( self.cleaned_data['tagged_vlans'] = [] -class InterfaceCSVForm(CSVModelForm): +class InterfaceCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) + parent = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent interface' + ) lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -3010,13 +3395,18 @@ class InterfaceCSVForm(CSVModelForm): Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis), type=InterfaceTypeChoices.TYPE_LAG ) + self.fields['parent'].queryset = Interface.objects.filter( + Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis) + ) elif device: self.fields['lag'].queryset = Interface.objects.filter( device=device, type=InterfaceTypeChoices.TYPE_LAG ) + self.fields['parent'].queryset = Interface.objects.filter(device=device) else: self.fields['lag'].queryset = Interface.objects.none() + self.fields['parent'].queryset = Interface.objects.none() def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data @@ -3040,7 +3430,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class FrontPortForm(BootstrapMixin, forms.ModelForm): +class FrontPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3049,7 +3439,8 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = FrontPort fields = [ - 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'tags', + 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'mark_connected', 'description', + 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -3069,6 +3460,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic class FrontPortCreateForm(ComponentCreateForm): + model = FrontPort type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect2(), @@ -3078,7 +3470,9 @@ class FrontPortCreateForm(ComponentCreateForm): label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'rear_port_set', 'description', 'tags') + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'rear_port_set', 'mark_connected', 'description', 'tags', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -3136,10 +3530,10 @@ class FrontPortCreateForm(ComponentCreateForm): class FrontPortBulkEditForm( - form_from_model(FrontPort, ['label', 'type', 'description']), + form_from_model(FrontPort, ['label', 'type', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=FrontPort.objects.all(), @@ -3147,10 +3541,10 @@ class FrontPortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class FrontPortCSVForm(CSVModelForm): +class FrontPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3209,7 +3603,7 @@ class RearPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class RearPortForm(BootstrapMixin, forms.ModelForm): +class RearPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3218,7 +3612,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = RearPort fields = [ - 'device', 'name', 'label', 'type', 'positions', 'description', 'tags', + 'device', 'name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -3227,6 +3621,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): class RearPortCreateForm(ComponentCreateForm): + model = RearPort type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect2(), @@ -3237,21 +3632,24 @@ class RearPortCreateForm(ComponentCreateForm): initial=1, help_text='The number of front ports which may be mapped to each rear port' ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'positions', 'description', 'tags') + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags', + ) class RearPortBulkCreateForm( - form_from_model(RearPort, ['type', 'positions']), + form_from_model(RearPort, ['type', 'positions', 'mark_connected']), DeviceBulkAddComponentForm ): - field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'description', 'tags') + model = RearPort + field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') class RearPortBulkEditForm( - form_from_model(RearPort, ['label', 'type', 'description']), + form_from_model(RearPort, ['label', 'type', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=RearPort.objects.all(), @@ -3259,10 +3657,10 @@ class RearPortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class RearPortCSVForm(CSVModelForm): +class RearPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3289,7 +3687,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class DeviceBayForm(BootstrapMixin, forms.ModelForm): +class DeviceBayForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3306,6 +3704,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): class DeviceBayCreateForm(ComponentCreateForm): + model = DeviceBay field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') @@ -3331,6 +3730,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): + model = DeviceBay field_order = ('name_pattern', 'label_pattern', 'description', 'tags') @@ -3338,7 +3738,7 @@ class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=DeviceBay.objects.all(), @@ -3346,10 +3746,10 @@ class DeviceBayBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class DeviceBayCSVForm(CSVModelForm): +class DeviceBayCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3399,10 +3799,9 @@ class DeviceBayCSVForm(CSVModelForm): # Inventory items # -class InventoryItemForm(BootstrapMixin, forms.ModelForm): +class InventoryItemForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( - queryset=Device.objects.all(), - display_field='display_name' + queryset=Device.objects.all() ) parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), @@ -3429,6 +3828,7 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class InventoryItemCreateForm(ComponentCreateForm): + model = InventoryItem manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -3459,7 +3859,7 @@ class InventoryItemCreateForm(ComponentCreateForm): ) -class InventoryItemCSVForm(CSVModelForm): +class InventoryItemCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3469,16 +3869,38 @@ class InventoryItemCSVForm(CSVModelForm): to_field_name='name', required=False ) + parent = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Parent inventory item' + ) class Meta: model = InventoryItem fields = InventoryItem.csv_headers + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit parent choices to inventory items belonging to this device + device = None + if self.is_bound and 'device' in self.data: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + pass + if device: + self.fields['parent'].queryset = InventoryItem.objects.filter(device=device) + else: + self.fields['parent'].queryset = InventoryItem.objects.none() + class InventoryItemBulkCreateForm( form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), DeviceBulkAddComponentForm ): + model = InventoryItem field_order = ( 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'tags', @@ -3489,7 +3911,7 @@ class InventoryItemBulkEditForm( form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=InventoryItem.objects.all(), @@ -3501,15 +3923,15 @@ class InventoryItemBulkEditForm( ) class Meta: - nullable_fields = ('label', 'manufacturer', 'part_id', 'description') + nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem - manufacturer = DynamicModelMultipleChoiceField( + manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Manufacturer') ) serial = forms.CharField( required=False @@ -3539,31 +3961,46 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): label='Region', required=False ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, query_params={ - 'region_id': '$termination_b_region' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', + } + ) + termination_b_location = DynamicModelChoiceField( + queryset=Location.objects.all(), + label='Location', + required=False, + null_option='None', + query_params={ + 'site_id': '$termination_b_site' } ) termination_b_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), label='Rack', required=False, - display_field='display_name', null_option='None', query_params={ - 'site_id': '$termination_b_site' + 'site_id': '$termination_b_site', + 'location_id': '$termination_b_location', } ) termination_b_device = DynamicModelChoiceField( queryset=Device.objects.all(), label='Device', required=False, - display_field='display_name', query_params={ 'site_id': '$termination_b_site', + 'location_id': '$termination_b_location', 'rack_id': '$termination_b_rack', } ) @@ -3593,7 +4030,7 @@ class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=ConsolePort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3604,7 +4041,7 @@ class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=ConsoleServerPort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3615,7 +4052,7 @@ class ConnectCableToPowerPortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=PowerPort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3626,7 +4063,7 @@ class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=PowerOutlet.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3637,7 +4074,7 @@ class ConnectCableToInterfaceForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=Interface.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device', 'kind': 'physical', @@ -3649,7 +4086,7 @@ class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=FrontPort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3660,7 +4097,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=RearPort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3678,18 +4115,23 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) label='Region', required=False ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, query_params={ - 'region_id': '$termination_b_region' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', } ) termination_b_circuit = DynamicModelChoiceField( queryset=Circuit.objects.all(), label='Circuit', - display_field='cid', query_params={ 'provider_id': '$termination_b_provider', 'site_id': '$termination_b_site', @@ -3698,8 +4140,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) termination_b_id = DynamicModelChoiceField( queryset=CircuitTermination.objects.all(), label='Side', - display_field='term_side', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'circuit_id': '$termination_b_circuit' } @@ -3727,19 +4168,24 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): label='Region', required=False ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, query_params={ - 'region_id': '$termination_b_region' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', } ) - termination_b_rackgroup = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), - label='Rack Group', + termination_b_location = DynamicModelChoiceField( + queryset=Location.objects.all(), + label='Location', required=False, - display_field='cid', query_params={ 'site_id': '$termination_b_site' } @@ -3750,13 +4196,13 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): required=False, query_params={ 'site_id': '$termination_b_site', - 'rack_group_id': '$termination_b_rackgroup', + 'location_id': '$termination_b_location', } ) termination_b_id = DynamicModelChoiceField( queryset=PowerFeed.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'power_panel_id': '$termination_b_powerpanel' } @@ -3769,7 +4215,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Cable fields = [ - 'termination_b_rackgroup', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', + 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', ] @@ -3950,37 +4396,37 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo }) -class CableFilterForm(BootstrapMixin, forms.Form): +class CableFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Cable q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) - tenant = DynamicModelMultipleChoiceField( + tenant_id = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Tenant') ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', + label=_('Rack'), null_option='None', query_params={ - 'site': '$site' + 'site_id': '$site_id' } ) type = forms.MultipleChoiceField( @@ -4001,12 +4447,12 @@ class CableFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site', - 'tenant': '$tenant', + 'site_id': '$site_id', + 'tenant_id': '$tenant_id', 'rack_id': '$rack_id', - } + }, + label=_('Device') ) tag = TagFilterField(model) @@ -4016,74 +4462,74 @@ class CableFilterForm(BootstrapMixin, forms.Form): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Device') ) class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Device') ) class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Device') ) @@ -4106,18 +4552,25 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, null_option='None', - display_field='display_name', query_params={ 'site_id': '$site' } @@ -4125,7 +4578,6 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): members = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site', 'rack_id': '$rack', @@ -4144,7 +4596,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags', + 'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', ] def save(self, *args, **kwargs): @@ -4248,25 +4700,31 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, null_option='None', - display_field='display_name', query_params={ 'site_id': '$site' } ) device = DynamicModelChoiceField( queryset=Device.objects.all(), - display_field='display_name', query_params={ 'site_id': '$site', 'rack_id': '$rack', @@ -4310,39 +4768,30 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm): fields = VirtualChassis.csv_headers -class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): +class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VirtualChassis + field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } - ) - tenant_group = DynamicModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - to_field_name='slug', - required=False, - null_option='None' - ) - tenant = DynamicModelMultipleChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - required=False, - null_option='None', - query_params={ - 'group': '$tenant_group' - } + 'region_id': '$region_id' + }, + label=_('Site') ) tag = TagFilterField(model) @@ -4359,14 +4808,22 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - rack_group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -4380,8 +4837,11 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerPanel fields = [ - 'region', 'site', 'rack_group', 'name', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'tags', ] + fieldsets = ( + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), + ) class PowerPanelCSVForm(CustomFieldModelCSVForm): @@ -4390,8 +4850,8 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Name of parent site' ) - rack_group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), required=False, to_field_name='name' ) @@ -4407,7 +4867,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm): # Limit group queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -4422,15 +4882,23 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - rack_group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -4438,36 +4906,41 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE ) class Meta: - nullable_fields = ['rack_group'] + nullable_fields = ['location'] class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): model = PowerPanel q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) - rack_group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, - label='Rack group (ID)', null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Location') ) tag = TagFilterField(model) @@ -4484,6 +4957,13 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'sites__powerpanel': '$power_panel' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, @@ -4491,7 +4971,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'powerpanel': '$power_panel' }, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) power_panel = DynamicModelChoiceField( @@ -4503,7 +4984,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site' } @@ -4517,9 +4997,14 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerFeed fields = [ - 'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'comments', 'tags', + 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', + 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] + fieldsets = ( + ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), + ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), + ) widgets = { 'status': StaticSelect2(), 'type': StaticSelect2(), @@ -4539,11 +5024,11 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Upstream power panel' ) - rack_group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's group (if any)" + help_text="Rack's location (if any)" ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), @@ -4585,14 +5070,14 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm): params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params) - # Limit rack_group queryset by site + # Limit location queryset by site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) # Limit rack queryset by site and group params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) @@ -4609,7 +5094,6 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - display_field='display_name' ) status = forms.ChoiceField( choices=add_blank_choice(PowerFeedStatusChoices), @@ -4644,6 +5128,10 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd max_utilization = forms.IntegerField( required=False ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -4651,7 +5139,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd class Meta: nullable_fields = [ - 'rackgroup', 'comments', + 'location', 'comments', ] @@ -4659,38 +5147,43 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): model = PowerFeed q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) power_panel_id = DynamicModelMultipleChoiceField( queryset=PowerPanel.objects.all(), required=False, - label='Power panel', null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Power panel') ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Rack') ) status = forms.MultipleChoiceField( choices=PowerFeedStatusChoices, diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index 06b5bdec0..fd5f9cfab 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -2,12 +2,10 @@ from django.core.management.base import BaseCommand from django.core.management.color import no_style from django.db import connection -from circuits.models import CircuitTermination from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort from dcim.signals import create_cablepath ENDPOINT_MODELS = ( - CircuitTermination, ConsolePort, ConsoleServerPort, Interface, diff --git a/netbox/dcim/migrations/0123_region_custom_field_data.py b/netbox/dcim/migrations/0123_region_custom_field_data.py deleted file mode 100644 index 38fe2ebb6..000000000 --- a/netbox/dcim/migrations/0123_region_custom_field_data.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.1.7 on 2021-07-14 14:28 - -import django.core.serializers.json -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0122_standardize_name_length'), - ] - - operations = [ - migrations.AddField( - model_name='region', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - ] diff --git a/netbox/dcim/migrations/0123_standardize_models.py b/netbox/dcim/migrations/0123_standardize_models.py new file mode 100644 index 000000000..af6e8d545 --- /dev/null +++ b/netbox/dcim/migrations/0123_standardize_models.py @@ -0,0 +1,417 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0122_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleserverport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='devicebay', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicerole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='interface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='inventoryitem', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='manufacturer', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='platform', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlet', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlet', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlet', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='powerport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rackrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackgroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/dcim/migrations/0124_mark_connected.py b/netbox/dcim/migrations/0124_mark_connected.py new file mode 100644 index 000000000..d7bf40a24 --- /dev/null +++ b/netbox/dcim/migrations/0124_mark_connected.py @@ -0,0 +1,51 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0123_standardize_models'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='consoleserverport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='frontport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='interface', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='powerfeed', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='poweroutlet', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='powerport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='rearport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + ] diff --git a/netbox/dcim/migrations/0125_console_port_speed.py b/netbox/dcim/migrations/0125_console_port_speed.py new file mode 100644 index 000000000..1a7f455d6 --- /dev/null +++ b/netbox/dcim/migrations/0125_console_port_speed.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0124_mark_connected'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='speed', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='speed', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0126_rename_rackgroup_location.py b/netbox/dcim/migrations/0126_rename_rackgroup_location.py new file mode 100644 index 000000000..8755097bd --- /dev/null +++ b/netbox/dcim/migrations/0126_rename_rackgroup_location.py @@ -0,0 +1,39 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0125_console_port_speed'), + ] + + operations = [ + migrations.RenameModel( + old_name='RackGroup', + new_name='Location', + ), + migrations.AlterModelOptions( + name='rack', + options={'ordering': ('site', 'location', '_name', 'pk')}, + ), + migrations.AlterField( + model_name='location', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='dcim.site'), + ), + migrations.RenameField( + model_name='powerpanel', + old_name='rack_group', + new_name='location', + ), + migrations.RenameField( + model_name='rack', + old_name='group', + new_name='location', + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together={('location', 'facility_id'), ('location', 'name')}, + ), + ] diff --git a/netbox/dcim/migrations/0127_device_location.py b/netbox/dcim/migrations/0127_device_location.py new file mode 100644 index 000000000..479f9cea9 --- /dev/null +++ b/netbox/dcim/migrations/0127_device_location.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0126_rename_rackgroup_location'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.location'), + ), + ] diff --git a/netbox/dcim/migrations/0128_device_location_populate.py b/netbox/dcim/migrations/0128_device_location_populate.py new file mode 100644 index 000000000..06a172ac3 --- /dev/null +++ b/netbox/dcim/migrations/0128_device_location_populate.py @@ -0,0 +1,24 @@ +from django.db import migrations +from django.db.models import Subquery, OuterRef + + +def populate_device_location(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + Device.objects.filter(rack__isnull=False).update( + location_id=Subquery( + Device.objects.filter(pk=OuterRef('pk')).values('rack__location_id')[:1] + ) + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0127_device_location'), + ] + + operations = [ + migrations.RunPython( + code=populate_device_location + ), + ] diff --git a/netbox/dcim/migrations/0129_interface_parent.py b/netbox/dcim/migrations/0129_interface_parent.py new file mode 100644 index 000000000..37e722f0a --- /dev/null +++ b/netbox/dcim/migrations/0129_interface_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0128_device_location_populate'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='dcim.interface'), + ), + ] diff --git a/netbox/dcim/migrations/0130_sitegroup.py b/netbox/dcim/migrations/0130_sitegroup.py new file mode 100644 index 000000000..3b3bdcf10 --- /dev/null +++ b/netbox/dcim/migrations/0130_sitegroup.py @@ -0,0 +1,39 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0129_interface_parent'), + ] + + operations = [ + migrations.CreateModel( + name='SiteGroup', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.sitegroup')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='site', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.sitegroup'), + ), + ] diff --git a/netbox/dcim/migrations/0131_consoleport_speed.py b/netbox/dcim/migrations/0131_consoleport_speed.py new file mode 100644 index 000000000..350162218 --- /dev/null +++ b/netbox/dcim/migrations/0131_consoleport_speed.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0130_sitegroup'), + ] + + operations = [ + migrations.AlterField( + model_name='consoleport', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 513c07438..ee19d553d 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -34,12 +34,13 @@ __all__ = ( 'PowerPort', 'PowerPortTemplate', 'Rack', - 'RackGroup', + 'Location', 'RackReservation', 'RackRole', 'RearPort', 'RearPortTemplate', 'Region', 'Site', + 'SiteGroup', 'VirtualChassis', ) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 6a530bb49..e7040376c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -6,14 +6,13 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models from django.db.models import Sum from django.urls import reverse -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -31,8 +30,8 @@ __all__ = ( # Cables # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cable(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Cable(PrimaryModel): """ A physical connection between two endpoints. """ @@ -107,7 +106,6 @@ class Cable(ChangeLoggedModel, CustomFieldModel): blank=True, null=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -244,6 +242,16 @@ class Cable(ChangeLoggedModel, CustomFieldModel): ): raise ValidationError("A front port cannot be connected to it corresponding rear port") + # A CircuitTermination attached to a ProviderNetwork cannot have a Cable + if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None: + raise ValidationError({ + 'termination_a_id': "Circuit terminations attached to a provider network may not be cabled." + }) + if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None: + raise ValidationError({ + 'termination_b_id': "Circuit terminations attached to a provider network may not be cabled." + }) + # Check for an existing Cable connected to either termination object if self.termination_a.cable not in (None, self): raise ValidationError("{} already has a cable attached (#{})".format( @@ -305,7 +313,7 @@ class Cable(ChangeLoggedModel, CustomFieldModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] -class CablePath(models.Model): +class CablePath(BigIDModel): """ A CablePath instance represents the physical path from an origin to a destination, including all intermediate elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do @@ -386,6 +394,8 @@ class CablePath(models.Model): """ Create a new CablePath instance as traced from the given path origin. """ + from circuits.models import CircuitTermination + if origin is None or origin.cable is None: return None @@ -433,6 +443,23 @@ class CablePath(models.Model): # No corresponding FrontPort found for the RearPort break + # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) + elif isinstance(peer_termination, CircuitTermination): + path.append(object_to_path_node(peer_termination)) + # Get peer CircuitTermination + node = peer_termination.get_peer_termination() + if node: + path.append(object_to_path_node(node)) + if node.provider_network: + destination = node.provider_network + break + elif node.site and not node.cable: + destination = node.site + break + else: + # No peer CircuitTermination exists; halt the trace + break + # Anything else marks the end of the path else: destination = peer_termination @@ -478,19 +505,43 @@ class CablePath(models.Model): return path + @property + def last_node(self): + """ + Return either the destination or the last node within the path. + """ + return self.destination or path_node_to_object(self.path[-1]) + + def get_cable_ids(self): + """ + Return all Cable IDs within the path. + """ + cable_ct = ContentType.objects.get_for_model(Cable).pk + cable_ids = [] + + for node in self.path: + ct, id = decompile_path_node(node) + if ct == cable_ct: + cable_ids.append(id) + + return cable_ids + def get_total_length(self): """ - Return the sum of the length of each cable in the path. + Return a tuple containing the sum of the length of each cable in the path + and a flag indicating whether the length is definitive. """ - cable_ids = [ - # Starting from the first element, every third element in the path should be a Cable - decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3) - ] - return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total'] + cable_ids = self.get_cable_ids() + cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False) + total_length = cables.aggregate(total=Sum('_abs_length'))['total'] + is_definitive = len(cables) == len(cable_ids) + + return total_length, is_definitive def get_split_nodes(self): """ Return all available next segments in a split cable path. """ rearport = path_node_to_object(self.path[-1]) + return FrontPort.objects.filter(rear_port=rearport) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index e52fe2602..9eb7785bf 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -4,11 +4,11 @@ from django.db import models from dcim.choices import * from dcim.constants import * -from extras.models import ObjectChange +from extras.utils import extras_features +from netbox.models import ChangeLoggedModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface -from utilities.utils import serialize_object from .device_components import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, ) @@ -26,7 +26,7 @@ __all__ = ( ) -class ComponentTemplateModel(models.Model): +class ComponentTemplateModel(ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -73,15 +73,10 @@ class ComponentTemplateModel(models.Model): except ObjectDoesNotExist: # The parent DeviceType has already been deleted device_type = None - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=device_type, - object_data=serialize_object(self) - ) + return super().to_objectchange(action, related_object=device_type) +@extras_features('webhooks') class ConsolePortTemplate(ComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -105,6 +100,7 @@ class ConsolePortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class ConsoleServerPortTemplate(ComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -128,6 +124,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class PowerPortTemplate(ComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -174,6 +171,7 @@ class PowerPortTemplate(ComponentTemplateModel): }) +@extras_features('webhooks') class PowerOutletTemplate(ComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -225,6 +223,7 @@ class PowerOutletTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class InterfaceTemplate(ComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -259,6 +258,7 @@ class InterfaceTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class FrontPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -290,19 +290,24 @@ class FrontPortTemplate(ComponentTemplateModel): def clean(self): super().clean() - # Validate rear port assignment - if self.rear_port.device_type != self.device_type: - raise ValidationError( - "Rear port ({}) must belong to the same device type".format(self.rear_port) - ) + try: - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError( - "Invalid rear port position ({}); rear port {} has only {} positions".format( - self.rear_port_position, self.rear_port.name, self.rear_port.positions + # Validate rear port assignment + if self.rear_port.device_type != self.device_type: + raise ValidationError( + "Rear port ({}) must belong to the same device type".format(self.rear_port) ) - ) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError( + "Invalid rear port position ({}); rear port {} has only {} positions".format( + self.rear_port_position, self.rear_port.name, self.rear_port.positions + ) + ) + + except RearPortTemplate.DoesNotExist: + pass def instantiate(self, device): if self.rear_port: @@ -319,6 +324,7 @@ class FrontPortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class RearPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -349,6 +355,7 @@ class RearPortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 40063234f..6b67ad302 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -6,19 +6,17 @@ from django.db import models from django.db.models import Sum from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField -from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.fields import NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar -from utilities.utils import serialize_object __all__ = ( @@ -37,7 +35,7 @@ __all__ = ( ) -class ComponentModel(models.Model): +class ComponentModel(PrimaryModel): """ An abstract model inherited by any model which has a parent Device. """ @@ -81,17 +79,11 @@ class ComponentModel(models.Model): except ObjectDoesNotExist: # The parent Device has already been deleted device = None - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=device, - object_data=serialize_object(self) - ) + return super().to_objectchange(action, related_object=device) @property - def parent(self): - return getattr(self, 'device', None) + def parent_object(self): + return self.device class CableTermination(models.Model): @@ -125,6 +117,10 @@ class CableTermination(models.Model): ct_field='_cable_peer_type', fk_field='_cable_peer_id' ) + mark_connected = models.BooleanField( + default=False, + help_text="Treat as if a cable is connected" + ) # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted. _cabled_as_a = GenericRelation( @@ -141,14 +137,30 @@ class CableTermination(models.Model): class Meta: abstract = True + def clean(self): + super().clean() + + if self.mark_connected and self.cable_id: + raise ValidationError({ + "mark_connected": "Cannot mark as connected with a cable attached." + }) + def get_cable_peer(self): return self._cable_peer + @property + def _occupied(self): + return bool(self.mark_connected or self.cable_id) + + @property + def parent_object(self): + raise NotImplementedError("CableTermination models must implement parent_object()") + class PathEndpoint(models.Model): """ An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically, - these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination. + these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed. `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the @@ -172,10 +184,11 @@ class PathEndpoint(models.Model): # Construct the complete path path = [self, *self._path.get_path()] - while (len(path) + 1) % 3: + if self._path.destination: + path.append(self._path.destination) + while len(path) % 3: # Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort) - path.append(None) - path.append(self._path.destination) + path.insert(-1, None) # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) @@ -198,8 +211,8 @@ class PathEndpoint(models.Model): # Console ports # -@extras_features('export_templates', 'webhooks', 'custom_links') -class ConsolePort(CableTermination, PathEndpoint, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ConsolePort(ComponentModel, CableTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -209,9 +222,15 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text='Physical port type' ) - tags = TaggableManager(through=TaggedItem) + speed = models.PositiveIntegerField( + choices=ConsolePortSpeedChoices, + blank=True, + null=True, + help_text='Port speed in bits per second' + ) - csv_headers = ['device', 'name', 'label', 'type', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description'] + clone_fields = ['device', 'type', 'speed'] class Meta: ordering = ('device', '_name') @@ -226,6 +245,8 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.type, + self.speed, + self.mark_connected, self.description, ) @@ -234,8 +255,8 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): # Console server ports # -@extras_features('webhooks', 'custom_links') -class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -245,9 +266,15 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text='Physical port type' ) - tags = TaggableManager(through=TaggedItem) + speed = models.PositiveIntegerField( + choices=ConsolePortSpeedChoices, + blank=True, + null=True, + help_text='Port speed in bits per second' + ) - csv_headers = ['device', 'name', 'label', 'type', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description'] + clone_fields = ['device', 'type', 'speed'] class Meta: ordering = ('device', '_name') @@ -262,6 +289,8 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.type, + self.speed, + self.mark_connected, self.description, ) @@ -270,8 +299,8 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): # Power ports # -@extras_features('export_templates', 'webhooks', 'custom_links') -class PowerPort(CableTermination, PathEndpoint, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class PowerPort(ComponentModel, CableTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -293,9 +322,11 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): validators=[MinValueValidator(1)], help_text="Allocated power draw (watts)" ) - tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] + csv_headers = [ + 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', + ] + clone_fields = ['device', 'maximum_draw', 'allocated_draw'] class Meta: ordering = ('device', '_name') @@ -310,6 +341,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.get_type_display(), + self.mark_connected, self.maximum_draw, self.allocated_draw, self.description, @@ -379,8 +411,8 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): # Power outlets # -@extras_features('webhooks', 'custom_links') -class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class PowerOutlet(ComponentModel, CableTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -403,9 +435,9 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text="Phase (for three-phase feeds)" ) - tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description'] + clone_fields = ['device', 'type', 'power_port', 'feed_leg'] class Meta: ordering = ('device', '_name') @@ -420,6 +452,7 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.get_type_display(), + self.mark_connected, self.power_port.name if self.power_port else None, self.get_feed_leg_display(), self.description, @@ -454,7 +487,10 @@ class BaseInterface(models.Model): mtu = models.PositiveIntegerField( blank=True, null=True, - validators=[MinValueValidator(1), MaxValueValidator(65536)], + validators=[ + MinValueValidator(INTERFACE_MTU_MIN), + MaxValueValidator(INTERFACE_MTU_MAX) + ], verbose_name='MTU' ) mode = models.CharField( @@ -478,9 +514,13 @@ class BaseInterface(models.Model): return super().save(*args, **kwargs) + @property + def count_ipaddresses(self): + return self.ip_addresses.count() -@extras_features('export_templates', 'webhooks', 'custom_links') -class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -491,6 +531,14 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): max_length=100, blank=True ) + parent = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name='Parent interface' + ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -528,11 +576,12 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='interface' ) - tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'device', 'name', 'label', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', + 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', + 'mgmt_only', 'description', 'mode', ] + clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only'] class Meta: ordering = ('device', CollateAsChar('_name')) @@ -546,9 +595,11 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): self.device.identifier if self.device else None, self.name, self.label, + self.parent.name if self.parent else None, self.lag.name if self.lag else None, self.get_type_display(), self.enabled, + self.mark_connected, self.mac_address, self.mtu, self.mgmt_only, @@ -560,14 +611,38 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): super().clean() # Virtual interfaces cannot be connected - if self.type in NONCONNECTABLE_IFACE_TYPES and ( - self.cable or getattr(self, 'circuit_termination', False) - ): + if not self.is_connectable and self.cable: raise ValidationError({ - 'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " - "Disconnect the interface or choose a suitable type." + 'type': f"{self.get_type_display()} interfaces cannot have a cable attached." }) + # Non-connectable interfaces cannot be marked as connected + if not self.is_connectable and self.mark_connected: + raise ValidationError({ + 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." + }) + + # An interface's parent must belong to the same device or virtual chassis + if self.parent and self.parent.device != self.device: + if self.device.virtual_chassis is None: + raise ValidationError({ + 'parent': f"The selected parent interface ({self.parent}) belongs to a different device " + f"({self.parent.device})." + }) + elif self.parent.device.virtual_chassis != self.parent.virtual_chassis: + raise ValidationError({ + 'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which " + f"is not part of virtual chassis {self.device.virtual_chassis}." + }) + + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + + # A physical interface cannot have a parent interface + if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: + raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) + # An interface's LAG must belong to the same device or virtual chassis if self.lag and self.lag.device != self.device: if self.device.virtual_chassis is None: @@ -589,16 +664,12 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # Validate untagged VLAN - if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: + if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: raise ValidationError({ 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " "device, or it must be global".format(self.untagged_vlan) }) - @property - def parent(self): - return self.device - @property def is_connectable(self): return self.type not in NONCONNECTABLE_IFACE_TYPES @@ -615,17 +686,13 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): def is_lag(self): return self.type == InterfaceTypeChoices.TYPE_LAG - @property - def count_ipaddresses(self): - return self.ip_addresses.count() - # # Pass-through ports # -@extras_features('webhooks', 'custom_links') -class FrontPort(CableTermination, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class FrontPort(ComponentModel, CableTermination): """ A pass-through port on the front of a Device. """ @@ -645,9 +712,11 @@ class FrontPort(CableTermination, ComponentModel): MaxValueValidator(REARPORT_POSITIONS_MAX) ] ) - tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description'] + csv_headers = [ + 'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description', + ] + clone_fields = ['device', 'type'] class Meta: ordering = ('device', '_name') @@ -665,6 +734,7 @@ class FrontPort(CableTermination, ComponentModel): self.name, self.label, self.get_type_display(), + self.mark_connected, self.rear_port.name, self.rear_port_position, self.description, @@ -687,8 +757,8 @@ class FrontPort(CableTermination, ComponentModel): }) -@extras_features('webhooks', 'custom_links') -class RearPort(CableTermination, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class RearPort(ComponentModel, CableTermination): """ A pass-through port on the rear of a Device. """ @@ -703,9 +773,9 @@ class RearPort(CableTermination, ComponentModel): MaxValueValidator(REARPORT_POSITIONS_MAX) ] ) - tags = TaggableManager(through=TaggedItem) + clone_fields = ['device', 'type', 'positions'] - csv_headers = ['device', 'name', 'label', 'type', 'positions', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description'] class Meta: ordering = ('device', '_name') @@ -731,6 +801,7 @@ class RearPort(CableTermination, ComponentModel): self.name, self.label, self.get_type_display(), + self.mark_connected, self.positions, self.description, ) @@ -740,7 +811,7 @@ class RearPort(CableTermination, ComponentModel): # Device bays # -@extras_features('webhooks', 'custom_links') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -752,9 +823,9 @@ class DeviceBay(ComponentModel): blank=True, null=True ) - tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'installed_device', 'description'] + clone_fields = ['device'] class Meta: ordering = ('device', '_name') @@ -800,7 +871,7 @@ class DeviceBay(ComponentModel): # Inventory items # -@extras_features('export_templates', 'webhooks', 'custom_links') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. @@ -845,13 +916,12 @@ class InventoryItem(MPTTModel, ComponentModel): help_text='This item was automatically discovered' ) - tags = TaggableManager(through=TaggedItem) - objects = TreeManager() csv_headers = [ 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', ] + clone_fields = ['device', 'parent', 'manufacturer', 'part_id'] class Meta: ordering = ('device__id', 'parent__id', '_name') diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 29818ab98..95c3c50db 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -9,13 +9,13 @@ from django.db import models from django.db.models import F, ProtectedError from django.urls import reverse from django.utils.safestring import mark_safe -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem +from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet @@ -36,8 +36,8 @@ __all__ = ( # Device Types # -@extras_features('export_templates', 'webhooks') -class Manufacturer(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ @@ -65,7 +65,7 @@ class Manufacturer(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) + return reverse('dcim:manufacturer', args=[self.pk]) def to_csv(self): return ( @@ -75,8 +75,8 @@ class Manufacturer(ChangeLoggedModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class DeviceType(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class DeviceType(PrimaryModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -135,7 +135,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -184,6 +183,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): { 'name': c.name, 'type': c.type, + 'label': c.label, + 'description': c.description, } for c in self.consoleporttemplates.all() ] @@ -192,6 +193,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): { 'name': c.name, 'type': c.type, + 'label': c.label, + 'description': c.description, } for c in self.consoleserverporttemplates.all() ] @@ -202,6 +205,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): 'type': c.type, 'maximum_draw': c.maximum_draw, 'allocated_draw': c.allocated_draw, + 'label': c.label, + 'description': c.description, } for c in self.powerporttemplates.all() ] @@ -212,6 +217,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): 'type': c.type, 'power_port': c.power_port.name if c.power_port else None, 'feed_leg': c.feed_leg, + 'label': c.label, + 'description': c.description, } for c in self.poweroutlettemplates.all() ] @@ -221,6 +228,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): 'name': c.name, 'type': c.type, 'mgmt_only': c.mgmt_only, + 'label': c.label, + 'description': c.description, } for c in self.interfacetemplates.all() ] @@ -231,6 +240,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): 'type': c.type, 'rear_port': c.rear_port.name, 'rear_port_position': c.rear_port_position, + 'label': c.label, + 'description': c.description, } for c in self.frontporttemplates.all() ] @@ -240,6 +251,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): 'name': c.name, 'type': c.type, 'positions': c.positions, + 'label': c.label, + 'description': c.description, } for c in self.rearporttemplates.all() ] @@ -247,6 +260,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): data['device-bays'] = [ { 'name': c.name, + 'label': c.label, + 'description': c.description, } for c in self.devicebaytemplates.all() ] @@ -338,7 +353,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # Devices # -class DeviceRole(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to @@ -375,6 +391,9 @@ class DeviceRole(ChangeLoggedModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('dcim:devicerole', args=[self.pk]) + def to_csv(self): return ( self.name, @@ -385,7 +404,8 @@ class DeviceRole(ChangeLoggedModel): ) -class Platform(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by @@ -435,7 +455,7 @@ class Platform(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?platform={}".format(reverse('dcim:device_list'), self.slug) + return reverse('dcim:platform', args=[self.pk]) def to_csv(self): return ( @@ -448,8 +468,8 @@ class Platform(ChangeLoggedModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -514,6 +534,13 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): on_delete=models.PROTECT, related_name='devices' ) + location = models.ForeignKey( + to='dcim.Location', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) rack = models.ForeignKey( to='dcim.Rack', on_delete=models.PROTECT, @@ -591,16 +618,15 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): object_id_field='assigned_object_id', related_query_name='device' ) - tags = TaggableManager(through=TaggedItem) objects = ConfigContextModelQuerySet.as_manager() csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', + 'site', 'location', 'rack_name', 'position', 'face', 'comments', ] clone_fields = [ - 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', + 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster', ] class Meta: @@ -637,11 +663,21 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): def clean(self): super().clean() - # Validate site/rack combination + # Validate site/location/rack combination if self.rack and self.site != self.rack.site: raise ValidationError({ 'rack': f"Rack {self.rack} does not belong to site {self.site}.", }) + if self.location and self.site != self.location.site: + raise ValidationError({ + 'location': f"Location {self.location} does not belong to site {self.site}.", + }) + if self.rack and self.location and self.rack.location != self.location: + raise ValidationError({ + 'rack': f"Rack {self.rack} does not belong to location {self.location}.", + }) + elif self.rack: + self.location = self.rack.location if self.rack is None: if self.face: @@ -696,7 +732,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): pass # Validate primary IP addresses - vc_interfaces = self.vc_interfaces.all() + vc_interfaces = self.vc_interfaces(if_master=False) if self.primary_ip4: if self.primary_ip4.family != 4: raise ValidationError({ @@ -796,7 +832,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): self.asset_tag, self.get_status_display(), self.site.name, - self.rack.group.name if self.rack and self.rack.group else None, + self.rack.location.name if self.rack and self.rack.location else None, self.rack.name if self.rack else None, self.position, self.get_face_display(), @@ -834,20 +870,25 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): else: return None + @property + def interfaces_count(self): + return self.vc_interfaces().count() + def get_vc_master(self): """ If this Device is a VirtualChassis member, return the VC master. Otherwise, return None. """ return self.virtual_chassis.master if self.virtual_chassis else None - @property - def vc_interfaces(self): + def vc_interfaces(self, if_master=True): """ Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another Device belonging to the same VirtualChassis. + + :param if_master: If True, return VC member interfaces only if this Device is the VC master. """ filter = Q(device=self) - if self.virtual_chassis and self.virtual_chassis.master == self: + if self.virtual_chassis and (self.virtual_chassis.master == self or not if_master): filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False) return Interface.objects.filter(filter) @@ -881,8 +922,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Virtual chassis # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VirtualChassis(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ @@ -900,7 +941,6 @@ class VirtualChassis(ChangeLoggedModel, CustomFieldModel): max_length=30, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 1215ced4c..03e77eea9 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -2,12 +2,11 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.validators import ExclusionValidator from .device_components import CableTermination, PathEndpoint @@ -22,8 +21,8 @@ __all__ = ( # Power # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerPanel(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -31,8 +30,8 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): to='Site', on_delete=models.PROTECT ) - rack_group = models.ForeignKey( - to='RackGroup', + location = models.ForeignKey( + to='dcim.Location', on_delete=models.PROTECT, blank=True, null=True @@ -40,11 +39,10 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): name = models.CharField( max_length=100 ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() - csv_headers = ['site', 'rack_group', 'name'] + csv_headers = ['site', 'location', 'name'] class Meta: ordering = ['site', 'name'] @@ -59,22 +57,22 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): def to_csv(self): return ( self.site.name, - self.rack_group.name if self.rack_group else None, + self.location.name if self.location else None, self.name, ) def clean(self): super().clean() - # RackGroup must belong to assigned Site - if self.rack_group and self.rack_group.site != self.site: - raise ValidationError("Rack group {} ({}) is in a different site than {}".format( - self.rack_group, self.rack_group.site, self.site - )) + # Location must belong to assigned Site + if self.location and self.location.site != self.site: + raise ValidationError( + f"Location {self.location} ({self.location.site}) is in a different site than {self.site}" + ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): """ An electrical circuit delivered from a PowerPanel. """ @@ -132,17 +130,16 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() csv_headers = [ - 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', + 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', + 'voltage', 'amperage', 'max_utilization', 'comments', ] clone_fields = [ - 'power_panel', 'rack', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', - 'available_power', + 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', + 'max_utilization', 'available_power', ] class Meta: @@ -159,11 +156,12 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo return ( self.power_panel.site.name, self.power_panel.name, - self.rack.group.name if self.rack and self.rack.group else None, + self.rack.location.name if self.rack and self.rack.location else None, self.rack.name if self.rack else None, self.name, self.get_status_display(), self.get_type_display(), + self.mark_connected, self.get_supply_display(), self.get_phase_display(), self.voltage, @@ -199,7 +197,7 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo super().save(*args, **kwargs) @property - def parent(self): + def parent_object(self): return self.power_panel def get_type_class(self): diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index dfaf7da61..c4416ca28 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -10,26 +10,22 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Sum from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.elevations import RackElevationSVG -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet -from utilities.mptt import TreeManager -from utilities.utils import array_to_string, serialize_object +from utilities.utils import array_to_string from .device_components import PowerOutlet, PowerPort from .devices import Device from .power import PowerFeed __all__ = ( 'Rack', - 'RackGroup', 'RackReservation', 'RackRole', ) @@ -39,84 +35,8 @@ __all__ = ( # Racks # -@extras_features('export_templates') -class RackGroup(MPTTModel, ChangeLoggedModel): - """ - Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For - example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that - campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor. - """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.CASCADE, - related_name='rack_groups' - ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = TreeManager() - - csv_headers = ['site', 'parent', 'name', 'slug', 'description'] - - class Meta: - ordering = ['site', 'name'] - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] - - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) - - def to_csv(self): - return ( - self.site, - self.parent.name if self.parent else '', - self.name, - self.slug, - self.description, - ) - - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - - def clean(self): - super().clean() - - # Parent RackGroup (if any) must belong to the same Site - if self.parent and self.parent.site != self.site: - raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})") - - -class RackRole(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. """ @@ -147,7 +67,7 @@ class RackRole(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?role={}".format(reverse('dcim:rack_list'), self.slug) + return reverse('dcim:rackrole', args=[self.pk]) def to_csv(self): return ( @@ -158,11 +78,11 @@ class RackRole(ChangeLoggedModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Rack(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Rack(PrimaryModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. - Each Rack is assigned to a Site and (optionally) a RackGroup. + Each Rack is assigned to a Site and (optionally) a Location. """ name = models.CharField( max_length=100 @@ -184,13 +104,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel): on_delete=models.PROTECT, related_name='racks' ) - group = models.ForeignKey( - to='dcim.RackGroup', + location = models.ForeignKey( + to='dcim.Location', on_delete=models.SET_NULL, related_name='racks', blank=True, - null=True, - help_text='Assigned group' + null=True ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -269,25 +188,24 @@ class Rack(ChangeLoggedModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() csv_headers = [ - 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', + 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ] clone_fields = [ - 'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', + 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', ] class Meta: - ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique + ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique unique_together = ( - # Name and facility_id must be unique *only* within a RackGroup - ('group', 'name'), - ('group', 'facility_id'), + # Name and facility_id must be unique *only* within a Location + ('location', 'name'), + ('location', 'facility_id'), ) def __str__(self): @@ -299,9 +217,9 @@ class Rack(ChangeLoggedModel, CustomFieldModel): def clean(self): super().clean() - # Validate group/site assignment - if self.site and self.group and self.group.site != self.site: - raise ValidationError(f"Assigned rack group must belong to parent site ({self.site}).") + # Validate location/site assignment + if self.site and self.location and self.location.site != self.site: + raise ValidationError(f"Assigned location must belong to parent site ({self.site}).") # Validate outer dimensions and unit if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: @@ -324,17 +242,17 @@ class Rack(ChangeLoggedModel, CustomFieldModel): min_height ) }) - # Validate that Rack was assigned a group of its same site, if applicable - if self.group: - if self.group.site != self.site: + # Validate that Rack was assigned a Location of its same site, if applicable + if self.location: + if self.location.site != self.site: raise ValidationError({ - 'group': "Rack group must be from the same site, {}.".format(self.site) + 'location': f"Location must be from the same site, {self.site}." }) def to_csv(self): return ( self.site.name, - self.group.name if self.group else None, + self.location.name if self.location else None, self.name, self.facility_id, self.tenant.name if self.tenant else None, @@ -549,8 +467,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return int(allocated_draw_total / available_power_total * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class RackReservation(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. """ @@ -576,11 +494,10 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel): description = models.CharField( max_length=200 ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() - csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] + csv_headers = ['site', 'location', 'rack', 'units', 'tenant', 'user', 'description'] class Meta: ordering = ['created', 'pk'] @@ -621,7 +538,7 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel): def to_csv(self): return ( self.rack.site.name, - self.rack.group if self.rack.group else None, + self.rack.location if self.rack.location else None, self.rack.name, ','.join([str(u) for u in self.units]), self.tenant.name if self.tenant else None, diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 7641e8899..7ab37567a 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -1,23 +1,24 @@ from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey -from taggit.managers import TaggableManager +from mptt.models import TreeForeignKey from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * +from django.core.exceptions import ValidationError from dcim.fields import ASNField -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet -from utilities.mptt import TreeManager -from utilities.utils import serialize_object __all__ = ( + 'Location', 'Region', 'Site', + 'SiteGroup', ) @@ -26,9 +27,11 @@ __all__ = ( # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Region(MPTTModel, ChangeLoggedModel, CustomFieldModel): +class Region(NestedGroupModel): """ - Sites can be grouped within geographic Regions. + A region represents a geographic collection of sites. For example, you might create regions representing countries, + states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are + also considered to be members of its parent and ancestor region(s). """ parent = TreeForeignKey( to='self', @@ -51,18 +54,10 @@ class Region(MPTTModel, ChangeLoggedModel, CustomFieldModel): blank=True ) - objects = TreeManager() - csv_headers = ['name', 'slug', 'parent', 'description'] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): - return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + return reverse('dcim:region', args=[self.pk]) def to_csv(self): return ( @@ -78,22 +73,65 @@ class Region(MPTTModel, ChangeLoggedModel, CustomFieldModel): Q(region__in=self.get_descendants()) ).count() - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) + +# +# Site groups +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class SiteGroup(NestedGroupModel): + """ + A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and + within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be + nested recursively to form a hierarchy. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + csv_headers = ['name', 'slug', 'parent', 'description'] + + def get_absolute_url(self): + return reverse('dcim:sitegroup', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.slug, + self.parent.name if self.parent else None, + self.description, ) + def get_site_count(self): + return Site.objects.filter( + Q(group=self) | + Q(group__in=self.get_descendants()) + ).count() + # # Sites # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Site(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). @@ -123,6 +161,13 @@ class Site(ChangeLoggedModel, CustomFieldModel): blank=True, null=True ) + group = models.ForeignKey( + to='dcim.SiteGroup', + on_delete=models.SET_NULL, + related_name='sites', + blank=True, + null=True + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -188,16 +233,16 @@ class Site(ChangeLoggedModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() csv_headers = [ - 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'comments', ] clone_fields = [ - 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', + 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] @@ -208,7 +253,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): return self.name def get_absolute_url(self): - return reverse('dcim:site', args=[self.slug]) + return reverse('dcim:site', args=[self.pk]) def to_csv(self): return ( @@ -216,6 +261,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): self.slug, self.get_status_display(), self.region.name if self.region else None, + self.group.name if self.group else None, self.tenant.name if self.tenant else None, self.facility, self.asn, @@ -233,3 +279,70 @@ class Site(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return SiteStatusChoices.CSS_CLASSES.get(self.status) + + +# +# Locations +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Location(NestedGroupModel): + """ + A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a + site, or a room within a building, for example. + """ + name = models.CharField( + max_length=100 + ) + slug = models.SlugField( + max_length=100 + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='locations' + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) + + csv_headers = ['site', 'parent', 'name', 'slug', 'description'] + clone_fields = ['site', 'parent', 'description'] + + class Meta: + ordering = ['site', 'name'] + unique_together = [ + ['site', 'name'], + ['site', 'slug'], + ] + + def get_absolute_url(self): + return reverse('dcim:location', args=[self.pk]) + + def to_csv(self): + return ( + self.site, + self.parent.name if self.parent else '', + self.name, + self.slug, + self.description, + ) + + def clean(self): + super().clean() + + # Parent Location (if any) must belong to the same Site + if self.parent and self.parent.site != self.site: + raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})") diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 277e3f060..33a868f2c 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -7,7 +7,7 @@ from django.db import transaction from django.dispatch import receiver from .choices import CableStatusChoices -from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis +from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis def create_cablepath(node): @@ -31,42 +31,37 @@ def rebuild_paths(obj): with transaction.atomic(): for cp in cable_paths: - invalidate_obj(cp.origin) cp.delete() - create_cablepath(cp.origin) + if cp.origin: + invalidate_obj(cp.origin) + create_cablepath(cp.origin) # -# Site/rack/device assignment +# Location/rack/device assignment # -@receiver(post_save, sender=RackGroup) -def handle_rackgroup_site_change(instance, created, **kwargs): +@receiver(post_save, sender=Location) +def handle_location_site_change(instance, created, **kwargs): """ - Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child + Update child objects if Site assignment has changed. We intentionally recurse through each child object instead of calling update() on the QuerySet to ensure the proper change records get created for each. """ if not created: - for rackgroup in instance.get_children(): - rackgroup.site = instance.site - rackgroup.save() - for rack in Rack.objects.filter(group=instance).exclude(site=instance.site): - rack.site = instance.site - rack.save() - for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site): - powerpanel.site = instance.site - powerpanel.save() + instance.get_descendants().update(site=instance.site) + locations = instance.get_descendants(include_self=True).values_list('pk', flat=True) + Rack.objects.filter(location__in=locations).update(site=instance.site) + Device.objects.filter(location__in=locations).update(site=instance.site) + PowerPanel.objects.filter(location__in=locations).update(site=instance.site) @receiver(post_save, sender=Rack) def handle_rack_site_change(instance, created, **kwargs): """ - Update child Devices if Site assignment has changed. + Update child Devices if Site or Location assignment has changed. """ if not created: - for device in Device.objects.filter(rack=instance).exclude(site=instance.site): - device.site = instance.site - device.save() + Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location) # @@ -151,14 +146,12 @@ def nullify_connected_endpoints(instance, **kwargs): # Disassociate the Cable from its termination points if instance.termination_a is not None: logger.debug(f"Nullifying termination A for cable {instance}") - instance.termination_a.cable = None - instance.termination_a._cable_peer = None - instance.termination_a.save() + model = instance.termination_a._meta.model + model.objects.filter(pk=instance.termination_a.pk).update(_cable_peer_type=None, _cable_peer_id=None) if instance.termination_b is not None: logger.debug(f"Nullifying termination B for cable {instance}") - instance.termination_b.cable = None - instance.termination_b._cable_peer = None - instance.termination_b.save() + model = instance.termination_b._meta.model + model.objects.filter(pk=instance.termination_b.pk).update(_cable_peer_type=None, _cable_peer_id=None) # Delete and retrace any dependent cable paths for cablepath in CablePath.objects.filter(path__contains=instance): diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index cdb79f4e1..9509ec2bc 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -26,9 +26,10 @@ class CableTable(BaseTable): orderable=False, verbose_name='Side A' ) - termination_a = tables.LinkColumn( + termination_a = tables.Column( accessor=Accessor('termination_a'), orderable=False, + linkify=True, verbose_name='Termination A' ) termination_b_parent = tables.TemplateColumn( @@ -37,9 +38,10 @@ class CableTable(BaseTable): orderable=False, verbose_name='Side B' ) - termination_b = tables.LinkColumn( + termination_b = tables.Column( accessor=Accessor('termination_b'), orderable=False, + linkify=True, verbose_name='Termination B' ) status = ChoiceFieldColumn() diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 52f4449af..b5d1b64ed 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -6,7 +6,7 @@ from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) -from tenancy.tables import COL_TENANT +from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, @@ -44,25 +44,36 @@ __all__ = ( ) +def get_cabletermination_row_class(record): + if record.mark_connected: + return 'success' + elif record.cable: + return record.cable.get_status_class() + return '' + + # # Device roles # class DeviceRoleTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) device_count = LinkedCountColumn( viewname='dcim:device_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='Devices' ) vm_count = LinkedCountColumn( viewname='virtualization:virtualmachine_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='VMs' ) color = ColorColumn() vm_role = BooleanColumn() - actions = ButtonsColumn(DeviceRole, pk_field='slug') + actions = ButtonsColumn(DeviceRole) class Meta(BaseTable.Meta): model = DeviceRole @@ -76,17 +87,20 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) device_count = LinkedCountColumn( viewname='dcim:device_list', - url_params={'platform': 'slug'}, + url_params={'platform_id': 'pk'}, verbose_name='Devices' ) vm_count = LinkedCountColumn( viewname='virtualization:virtualmachine_list', - url_params={'platform': 'slug'}, + url_params={'platform_id': 'pk'}, verbose_name='VMs' ) - actions = ButtonsColumn(Platform, pk_field='slug') + actions = ButtonsColumn(Platform) class Meta(BaseTable.Meta): model = Platform @@ -110,23 +124,26 @@ class DeviceTable(BaseTable): template_code=DEVICE_LINK ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() site = tables.Column( linkify=True ) + location = tables.Column( + linkify=True + ) rack = tables.Column( linkify=True ) device_role = ColoredLabelColumn( verbose_name='Role' ) - device_type = tables.LinkColumn( - viewname='dcim:devicetype', - args=[Accessor('device_type__pk')], - verbose_name='Type', - text=lambda record: record.device_type.display_name + manufacturer = tables.Column( + accessor=Accessor('device_type__manufacturer'), + linkify=True + ) + device_type = tables.Column( + linkify=True, + verbose_name='Type' ) if settings.PREFER_IPV4: primary_ip = tables.Column( @@ -148,13 +165,11 @@ class DeviceTable(BaseTable): linkify=True, verbose_name='IPv6 Address' ) - cluster = tables.LinkColumn( - viewname='virtualization:cluster', - args=[Accessor('cluster__pk')] + cluster = tables.Column( + linkify=True ) - virtual_chassis = tables.LinkColumn( - viewname='dcim:virtualchassis', - args=[Accessor('virtual_chassis__pk')] + virtual_chassis = tables.Column( + linkify=True ) vc_position = tables.Column( verbose_name='VC Position' @@ -169,12 +184,13 @@ class DeviceTable(BaseTable): class Meta(BaseTable.Meta): model = Device fields = ( - 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site', - 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', - 'vc_position', 'vc_priority', 'tags', + 'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', + 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags', ) default_columns = ( - 'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip', + 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', + 'primary_ip', ) @@ -183,9 +199,7 @@ class DeviceImportTable(BaseTable): template_code=DEVICE_LINK ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() site = tables.Column( linkify=True ) @@ -218,9 +232,6 @@ class DeviceComponentTable(BaseTable): linkify=True, order_by=('_name',) ) - cable = tables.Column( - linkify=True - ) class Meta(BaseTable.Meta): order_by = ('device', 'name') @@ -230,17 +241,23 @@ class CableTerminationTable(BaseTable): cable = tables.Column( linkify=True ) + cable_color = ColorColumn( + accessor='cable.color', + orderable=False, + verbose_name='Cable Color' + ) cable_peer = tables.TemplateColumn( accessor='_cable_peer', template_code=CABLETERMINATION, orderable=False, verbose_name='Cable Peer' ) + mark_connected = BooleanColumn() class PathEndpointTable(CableTerminationTable): connection = tables.TemplateColumn( - accessor='_path.destination', + accessor='_path.last_node', template_code=CABLETERMINATION, verbose_name='Connection', orderable=False @@ -248,6 +265,12 @@ class PathEndpointTable(CableTerminationTable): class ConsolePortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_consoleports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:consoleport_list' ) @@ -255,14 +278,16 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', ) - default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description') class DeviceConsolePortTable(ConsolePortTable): name = tables.TemplateColumn( template_code=' {{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = ButtonsColumn( @@ -274,29 +299,40 @@ class DeviceConsolePortTable(ConsolePortTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions' + 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', 'actions' ) - default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_consoleserverports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:consoleserverport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags') - default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', + ) + default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description') class DeviceConsoleServerPortTable(ConsoleServerPortTable): name = tables.TemplateColumn( template_code=' ' '{{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = ButtonsColumn( @@ -308,15 +344,22 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions' + 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class PowerPortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_powerports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:powerport_list' ) @@ -324,8 +367,8 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', - 'cable_peer', 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', + 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -334,6 +377,7 @@ class DevicePowerPortTable(PowerPortTable): name = tables.TemplateColumn( template_code=' ' '{{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = ButtonsColumn( @@ -345,19 +389,25 @@ class DevicePowerPortTable(PowerPortTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer', - 'connection', 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', + 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class PowerOutletTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_poweroutlets', + 'args': [Accessor('device_id')], + } + ) power_port = tables.Column( linkify=True ) @@ -368,8 +418,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer', - 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', + 'cable_color', 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -377,6 +427,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): class DevicePowerOutletTable(PowerOutletTable): name = tables.TemplateColumn( template_code=' {{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = ButtonsColumn( @@ -388,14 +439,14 @@ class DevicePowerOutletTable(PowerOutletTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'connection', - 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', + 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } @@ -415,6 +466,12 @@ class BaseInterfaceTable(BaseTable): class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_interfaces', + 'args': [Accessor('device_id')], + } + ) mgmt_only = BooleanColumn() tags = TagColumn( url_name='dcim:interface_list' @@ -424,7 +481,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') @@ -434,8 +492,13 @@ class DeviceInterfaceTable(InterfaceTable): template_code=' {{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) + parent = tables.Column( + linkify=True, + verbose_name='Parent' + ) lag = tables.Column( linkify=True, verbose_name='LAG' @@ -449,20 +512,28 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description', - 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', + 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', 'actions', ) + order_by = ('name',) default_columns = ( - 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable', - 'connection', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', + 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '', + 'class': get_cabletermination_row_class, 'data-name': lambda record: record.name, } class FrontPortTable(DeviceComponentTable, CableTerminationTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_frontports', + 'args': [Accessor('device_id')], + } + ) rear_port_position = tables.Column( verbose_name='Position' ) @@ -476,8 +547,8 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', - 'cable_peer', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', + 'cable', 'cable_color', 'cable_peer', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') @@ -486,6 +557,7 @@ class DeviceFrontPortTable(FrontPortTable): name = tables.TemplateColumn( template_code=' ' '{{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = ButtonsColumn( @@ -497,26 +569,35 @@ class DeviceFrontPortTable(FrontPortTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', - 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', + 'cable_color', 'cable_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class RearPortTable(DeviceComponentTable, CableTerminationTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_rearports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:rearport_list' ) class Meta(DeviceComponentTable.Meta): model = RearPort - fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', + 'cable_color', 'cable_peer', 'tags', + ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') @@ -524,6 +605,7 @@ class DeviceRearPortTable(RearPortTable): name = tables.TemplateColumn( template_code=' ' '{{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = ButtonsColumn( @@ -535,17 +617,24 @@ class DeviceRearPortTable(RearPortTable): class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class DeviceBayTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_devicebays', + 'args': [Accessor('device_id')], + } + ) status = tables.TemplateColumn( template_code=DEVICEBAY_STATUS ) @@ -566,6 +655,7 @@ class DeviceDeviceBayTable(DeviceBayTable): name = tables.TemplateColumn( template_code=' {{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = ButtonsColumn( @@ -585,6 +675,12 @@ class DeviceDeviceBayTable(DeviceBayTable): class InventoryItemTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_inventory', + 'args': [Accessor('device_id')], + } + ) manufacturer = tables.Column( linkify=True ) @@ -594,7 +690,7 @@ class InventoryItemTable(DeviceComponentTable): ) cable = None # Override DeviceComponentTable - class Meta(DeviceComponentTable.Meta): + class Meta(BaseTable.Meta): model = InventoryItem fields = ( 'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', @@ -607,6 +703,7 @@ class DeviceInventoryItemTable(InventoryItemTable): name = tables.TemplateColumn( template_code='' '{{ value }}', + order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) actions = ButtonsColumn( @@ -614,7 +711,7 @@ class DeviceInventoryItemTable(InventoryItemTable): buttons=('edit', 'delete') ) - class Meta(DeviceComponentTable.Meta): + class Meta(BaseTable.Meta): model = InventoryItem fields = ( 'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index c4d618cd7..0a445171d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -26,7 +26,9 @@ __all__ = ( class ManufacturerTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) devicetype_count = tables.Column( verbose_name='Device Types' ) @@ -37,7 +39,7 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - actions = ButtonsColumn(Manufacturer, pk_field='slug') + actions = ButtonsColumn(Manufacturer) class Meta(BaseTable.Meta): model = Manufacturer diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ae5c2a5c8..1c4d6e921 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,10 +1,8 @@ import django_tables2 as tables -from django_tables2.utils import Accessor from dcim.models import PowerFeed, PowerPanel from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn from .devices import CableTerminationTable -from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION __all__ = ( 'PowerFeedTable', @@ -18,10 +16,11 @@ __all__ = ( class PowerPanelTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + name = tables.Column( + linkify=True + ) + site = tables.Column( + linkify=True ) powerfeed_count = LinkedCountColumn( viewname='dcim:powerfeed_list', @@ -34,8 +33,8 @@ class PowerPanelTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPanel - fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags') - default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') + fields = ('pk', 'name', 'site', 'location', 'powerfeed_count', 'tags') + default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') # @@ -46,7 +45,9 @@ class PowerPanelTable(BaseTable): # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) power_panel = tables.Column( linkify=True ) @@ -69,7 +70,8 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'cable', 'cable_peer', 'connection', 'available_power', 'tags', + 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', + 'tags', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 775e90076..ef0517dfc 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -1,53 +1,21 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import Rack, RackGroup, RackReservation, RackRole -from tenancy.tables import COL_TENANT +from dcim.models import Rack, RackReservation, RackRole +from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, - ToggleColumn, + ToggleColumn, UtilizationColumn, ) -from .template_code import MPTT_LINK, RACKGROUP_ELEVATIONS, UTILIZATION_GRAPH __all__ = ( 'RackTable', 'RackDetailTable', - 'RackGroupTable', 'RackReservationTable', 'RackRoleTable', ) -# -# Rack groups -# - -class RackGroupTable(BaseTable): - pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False, - attrs={'td': {'class': 'text-nowrap'}} - ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')], - verbose_name='Site' - ) - rack_count = tables.Column( - verbose_name='Racks' - ) - actions = ButtonsColumn( - model=RackGroup, - prepend_template=RACKGROUP_ELEVATIONS - ) - - class Meta(BaseTable.Meta): - model = RackGroup - fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions') - - # # Rack roles # @@ -75,15 +43,13 @@ class RackTable(BaseTable): order_by=('_name',), linkify=True ) - group = tables.Column( + location = tables.Column( linkify=True ) site = tables.Column( linkify=True ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() status = ChoiceFieldColumn() role = ColoredLabelColumn() u_height = tables.TemplateColumn( @@ -94,10 +60,10 @@ class RackTable(BaseTable): class Meta(BaseTable.Meta): model = Rack fields = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', ) - default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height') + default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height') class RackDetailTable(RackTable): @@ -106,13 +72,11 @@ class RackDetailTable(RackTable): url_params={'rack_id': 'pk'}, verbose_name='Devices' ) - get_utilization = tables.TemplateColumn( - template_code=UTILIZATION_GRAPH, + get_utilization = UtilizationColumn( orderable=False, verbose_name='Space' ) - get_power_utilization = tables.TemplateColumn( - template_code=UTILIZATION_GRAPH, + get_power_utilization = UtilizationColumn( orderable=False, verbose_name='Power' ) @@ -122,11 +86,11 @@ class RackDetailTable(RackTable): class Meta(RackTable.Meta): fields = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', ) default_columns = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', ) @@ -145,9 +109,7 @@ class RackReservationTable(BaseTable): accessor=Accessor('rack__site'), linkify=True ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() rack = tables.Column( linkify=True ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 50a5e5ec7..47c912354 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,13 +1,17 @@ import django_tables2 as tables -from dcim.models import Region, Site -from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn -from .template_code import MPTT_LINK +from dcim.models import Location, Region, Site, SiteGroup +from tenancy.tables import TenantColumn +from utilities.tables import ( + BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, +) +from .template_code import LOCATION_ELEVATIONS __all__ = ( + 'LocationTable', 'RegionTable', 'SiteTable', + 'SiteGroupTable', ) @@ -17,12 +21,12 @@ __all__ = ( class RegionTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False, - attrs={'td': {'class': 'text-nowrap'}} + name = MPTTColumn( + linkify=True ) - site_count = tables.Column( + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'region_id': 'pk'}, verbose_name='Sites' ) actions = ButtonsColumn(Region) @@ -33,22 +37,45 @@ class RegionTable(BaseTable): default_columns = ('pk', 'name', 'site_count', 'description', 'actions') +# +# Site groups +# + +class SiteGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'group_id': 'pk'}, + verbose_name='Sites' + ) + actions = ButtonsColumn(SiteGroup) + + class Meta(BaseTable.Meta): + model = SiteGroup + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + + # # Sites # class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn( - order_by=('_name',) + name = tables.Column( + linkify=True ) status = ChoiceFieldColumn() region = tables.Column( linkify=True ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT + group = tables.Column( + linkify=True ) + tenant = TenantColumn() tags = TagColumn( url_name='dcim:site_list' ) @@ -56,8 +83,41 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site fields = ( - 'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description', + 'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'tags', ) - default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description') + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') + + +# +# Locations +# + +class LocationTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + site = tables.Column( + linkify=True + ) + rack_count = LinkedCountColumn( + viewname='dcim:rack_list', + url_params={'location_id': 'pk'}, + verbose_name='Racks' + ) + device_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'location_id': 'pk'}, + verbose_name='Devices' + ) + actions = ButtonsColumn( + model=Location, + prepend_template=LOCATION_ELEVATIONS + ) + + class Meta(BaseTable.Meta): + model = Location + fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 7a52b85b0..2582a7117 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,10 +1,12 @@ CABLETERMINATION = """ {% if value %} - {{ value.parent }} + {% if value.parent_object %} + {{ value.parent_object }} - {{ value }} + {% endif %} + {{ value }} {% else %} - — + — {% endif %} """ @@ -56,13 +58,6 @@ INTERFACE_TAGGED_VLANS = """ {% endif %} """ -MPTT_LINK = """ -{% for i in record.get_ancestors %} - -{% endfor %} -{{ record.name }} -""" - POWERFEED_CABLE = """ {{ value }} @@ -71,22 +66,17 @@ POWERFEED_CABLE = """ """ POWERFEED_CABLETERMINATION = """ -{{ value.parent }} +{{ value.parent_object }} {{ value }} """ -RACKGROUP_ELEVATIONS = """ - +LOCATION_ELEVATIONS = """ + """ -UTILIZATION_GRAPH = """ -{% load helpers %} -{% utilization_graph value %} -""" - # # Device component buttons # @@ -103,16 +93,20 @@ CONSOLEPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - -

- + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -128,16 +122,20 @@ CONSOLESERVERPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -153,15 +151,19 @@ POWERPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -177,9 +179,13 @@ POWEROUTLET_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - + {% if not record.mark_connected %} + + + + {% else %} + + {% endif %} {% endif %} """ @@ -200,17 +206,21 @@ INTERFACE_BUTTONS = """ {% elif record.is_connectable and perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -226,19 +236,23 @@ FRONTPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -254,17 +268,21 @@ REARPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index ad1ca930c..9b5448e95 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -4,12 +4,7 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * -from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel, - Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, -) +from dcim.models import * from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterType @@ -64,7 +59,7 @@ class Mixins: class RegionTest(APIViewTestCases.APIViewTestCase): model = Region - brief_fields = ['_depth', 'id', 'name', 'site_count', 'slug', 'url'] + brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] create_data = [ { 'name': 'Region 4', @@ -91,9 +86,38 @@ class RegionTest(APIViewTestCases.APIViewTestCase): Region.objects.create(name='Region 3', slug='region-3') +class SiteGroupTest(APIViewTestCases.APIViewTestCase): + model = SiteGroup + brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] + create_data = [ + { + 'name': 'Site Group 4', + 'slug': 'site-group-4', + }, + { + 'name': 'Site Group 5', + 'slug': 'site-group-5', + }, + { + 'name': 'Site Group 6', + 'slug': 'site-group-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + SiteGroup.objects.create(name='Site Group 2', slug='site-group-2') + SiteGroup.objects.create(name='Site Group 3', slug='site-group-3') + + class SiteTest(APIViewTestCases.APIViewTestCase): model = Site - brief_fields = ['id', 'name', 'slug', 'url'] + brief_fields = ['display', 'id', 'name', 'slug', 'url'] bulk_update_data = { 'status': 'planned', } @@ -102,14 +126,19 @@ class SiteTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): regions = ( - Region.objects.create(name='Test Region 1', slug='test-region-1'), - Region.objects.create(name='Test Region 2', slug='test-region-2'), + Region.objects.create(name='Region 1', slug='region-1'), + Region.objects.create(name='Region 2', slug='region-2'), + ) + + groups = ( + SiteGroup.objects.create(name='Site Group 1', slug='site-group-1'), + SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'), ) sites = ( - Site(region=regions[0], name='Site 1', slug='site-1'), - Site(region=regions[0], name='Site 2', slug='site-2'), - Site(region=regions[0], name='Site 3', slug='site-3'), + Site(region=regions[0], group=groups[0], name='Site 1', slug='site-1'), + Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'), + Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) @@ -118,26 +147,29 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'name': 'Site 4', 'slug': 'site-4', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, { 'name': 'Site 5', 'slug': 'site-5', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, { 'name': 'Site 6', 'slug': 'site-6', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, ] -class RackGroupTest(APIViewTestCases.APIViewTestCase): - model = RackGroup - brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url'] +class LocationTest(APIViewTestCases.APIViewTestCase): + model = Location + brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url'] bulk_update_data = { 'description': 'New description', } @@ -151,40 +183,40 @@ class RackGroupTest(APIViewTestCases.APIViewTestCase): ) Site.objects.bulk_create(sites) - parent_rack_groups = ( - RackGroup.objects.create(site=sites[0], name='Parent Rack Group 1', slug='parent-rack-group-1'), - RackGroup.objects.create(site=sites[1], name='Parent Rack Group 2', slug='parent-rack-group-2'), + parent_locations = ( + Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'), + Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'), ) - RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1', parent=parent_rack_groups[0]) - RackGroup.objects.create(site=sites[0], name='Rack Group 2', slug='rack-group-2', parent=parent_rack_groups[0]) - RackGroup.objects.create(site=sites[0], name='Rack Group 3', slug='rack-group-3', parent=parent_rack_groups[0]) + Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0]) + Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0]) + Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0]) cls.create_data = [ { - 'name': 'Test Rack Group 4', - 'slug': 'test-rack-group-4', + 'name': 'Test Location 4', + 'slug': 'test-location-4', 'site': sites[1].pk, - 'parent': parent_rack_groups[1].pk, + 'parent': parent_locations[1].pk, }, { - 'name': 'Test Rack Group 5', - 'slug': 'test-rack-group-5', + 'name': 'Test Location 5', + 'slug': 'test-location-5', 'site': sites[1].pk, - 'parent': parent_rack_groups[1].pk, + 'parent': parent_locations[1].pk, }, { - 'name': 'Test Rack Group 6', - 'slug': 'test-rack-group-6', + 'name': 'Test Location 6', + 'slug': 'test-location-6', 'site': sites[1].pk, - 'parent': parent_rack_groups[1].pk, + 'parent': parent_locations[1].pk, }, ] class RackRoleTest(APIViewTestCases.APIViewTestCase): model = RackRole - brief_fields = ['id', 'name', 'rack_count', 'slug', 'url'] + brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url'] create_data = [ { 'name': 'Rack Role 4', @@ -219,7 +251,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): class RackTest(APIViewTestCases.APIViewTestCase): model = Rack - brief_fields = ['device_count', 'display_name', 'id', 'name', 'url'] + brief_fields = ['device_count', 'display', 'display_name', 'id', 'name', 'url'] bulk_update_data = { 'status': 'planned', } @@ -233,9 +265,9 @@ class RackTest(APIViewTestCases.APIViewTestCase): ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1'), - RackGroup.objects.create(site=sites[1], name='Rack Group 2', slug='rack-group-2'), + locations = ( + Location.objects.create(site=sites[0], name='Location 1', slug='location-1'), + Location.objects.create(site=sites[1], name='Location 2', slug='location-2'), ) rack_roles = ( @@ -245,9 +277,9 @@ class RackTest(APIViewTestCases.APIViewTestCase): RackRole.objects.bulk_create(rack_roles) racks = ( - Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'), - Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'), - Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'), + Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 1'), + Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 2'), + Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 3'), ) Rack.objects.bulk_create(racks) @@ -255,19 +287,19 @@ class RackTest(APIViewTestCases.APIViewTestCase): { 'name': 'Test Rack 4', 'site': sites[1].pk, - 'group': rack_groups[1].pk, + 'location': locations[1].pk, 'role': rack_roles[1].pk, }, { 'name': 'Test Rack 5', 'site': sites[1].pk, - 'group': rack_groups[1].pk, + 'location': locations[1].pk, 'role': rack_roles[1].pk, }, { 'name': 'Test Rack 6', 'site': sites[1].pk, - 'group': rack_groups[1].pk, + 'location': locations[1].pk, 'role': rack_roles[1].pk, }, ] @@ -307,7 +339,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase): model = RackReservation - brief_fields = ['id', 'units', 'url', 'user'] + brief_fields = ['display', 'id', 'units', 'url', 'user'] bulk_update_data = { 'description': 'New description', } @@ -317,40 +349,36 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): user = User.objects.create(username='user1', is_active=True) site = Site.objects.create(name='Test Site 1', slug='test-site-1') - cls.racks = ( + racks = ( Rack(site=site, name='Rack 1'), Rack(site=site, name='Rack 2'), ) - Rack.objects.bulk_create(cls.racks) + Rack.objects.bulk_create(racks) rack_reservations = ( - RackReservation(rack=cls.racks[0], units=[1, 2, 3], user=user, description='Reservation #1'), - RackReservation(rack=cls.racks[0], units=[4, 5, 6], user=user, description='Reservation #2'), - RackReservation(rack=cls.racks[0], units=[7, 8, 9], user=user, description='Reservation #3'), + RackReservation(rack=racks[0], units=[1, 2, 3], user=user, description='Reservation #1'), + RackReservation(rack=racks[0], units=[4, 5, 6], user=user, description='Reservation #2'), + RackReservation(rack=racks[0], units=[7, 8, 9], user=user, description='Reservation #3'), ) RackReservation.objects.bulk_create(rack_reservations) - def setUp(self): - super().setUp() - - # We have to set creation data under setUp() because we need access to the test user. - self.create_data = [ + cls.create_data = [ { - 'rack': self.racks[1].pk, + 'rack': racks[1].pk, 'units': [10, 11, 12], - 'user': self.user.pk, + 'user': user.pk, 'description': 'Reservation #4', }, { - 'rack': self.racks[1].pk, + 'rack': racks[1].pk, 'units': [13, 14, 15], - 'user': self.user.pk, + 'user': user.pk, 'description': 'Reservation #5', }, { - 'rack': self.racks[1].pk, + 'rack': racks[1].pk, 'units': [16, 17, 18], - 'user': self.user.pk, + 'user': user.pk, 'description': 'Reservation #6', }, ] @@ -358,7 +386,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): class ManufacturerTest(APIViewTestCases.APIViewTestCase): model = Manufacturer - brief_fields = ['devicetype_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Manufacturer 4', @@ -390,7 +418,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase): class DeviceTypeTest(APIViewTestCases.APIViewTestCase): model = DeviceType - brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] + brief_fields = ['device_count', 'display', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] bulk_update_data = { 'part_number': 'ABC123', } @@ -432,7 +460,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -469,7 +497,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsoleServerPortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -506,7 +534,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerPortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -543,7 +571,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerOutletTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -580,7 +608,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): model = InterfaceTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -620,7 +648,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): model = FrontPortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -691,7 +719,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): model = RearPortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -731,7 +759,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -771,7 +799,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceRoleTest(APIViewTestCases.APIViewTestCase): model = DeviceRole - brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ { 'name': 'Device Role 4', @@ -806,7 +834,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): class PlatformTest(APIViewTestCases.APIViewTestCase): model = Platform - brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ { 'name': 'Platform 4', @@ -838,7 +866,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): class DeviceTest(APIViewTestCases.APIViewTestCase): model = Device - brief_fields = ['display_name', 'id', 'name', 'url'] + brief_fields = ['display', 'display_name', 'id', 'name', 'url'] bulk_update_data = { 'status': 'failed', } @@ -979,7 +1007,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1018,7 +1046,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1057,7 +1085,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1096,7 +1124,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1135,7 +1163,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1183,8 +1211,9 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase { 'device': device.pk, 'name': 'Interface 6', - 'type': '1000base-t', + 'type': 'virtual', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'parent': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, @@ -1193,7 +1222,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1251,7 +1280,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): class RearPortTest(APIViewTestCases.APIViewTestCase): model = RearPort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1293,7 +1322,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay - brief_fields = ['device', 'id', 'name', 'url'] + brief_fields = ['device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1356,7 +1385,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase): class InventoryItemTest(APIViewTestCases.APIViewTestCase): model = InventoryItem - brief_fields = ['_depth', 'device', 'id', 'name', 'url'] + brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1394,7 +1423,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): class CableTest(APIViewTestCases.APIViewTestCase): model = Cable - brief_fields = ['id', 'label', 'url'] + brief_fields = ['display', 'id', 'label', 'url'] bulk_update_data = { 'length': 100, 'length_unit': 'm', @@ -1579,7 +1608,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): class PowerPanelTest(APIViewTestCases.APIViewTestCase): model = PowerPanel - brief_fields = ['id', 'name', 'powerfeed_count', 'url'] + brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url'] @classmethod def setUpTestData(cls): @@ -1588,17 +1617,17 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase): Site.objects.create(name='Site 2', slug='site-2'), ) - rack_groups = ( - RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=sites[0]), - RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=sites[0]), - RackGroup.objects.create(name='Rack Group 4', slug='rack-group-3', site=sites[1]), + locations = ( + Location.objects.create(name='Location 1', slug='location-1', site=sites[0]), + Location.objects.create(name='Location 2', slug='location-2', site=sites[0]), + Location.objects.create(name='Location 3', slug='location-3', site=sites[0]), + Location.objects.create(name='Location 4', slug='location-3', site=sites[1]), ) power_panels = ( - PowerPanel(site=sites[0], rack_group=rack_groups[0], name='Power Panel 1'), - PowerPanel(site=sites[0], rack_group=rack_groups[1], name='Power Panel 2'), - PowerPanel(site=sites[0], rack_group=rack_groups[2], name='Power Panel 3'), + PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'), + PowerPanel(site=sites[0], location=locations[1], name='Power Panel 2'), + PowerPanel(site=sites[0], location=locations[2], name='Power Panel 3'), ) PowerPanel.objects.bulk_create(power_panels) @@ -1606,29 +1635,29 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase): { 'name': 'Power Panel 4', 'site': sites[0].pk, - 'rack_group': rack_groups[0].pk, + 'location': locations[0].pk, }, { 'name': 'Power Panel 5', 'site': sites[0].pk, - 'rack_group': rack_groups[1].pk, + 'location': locations[1].pk, }, { 'name': 'Power Panel 6', 'site': sites[0].pk, - 'rack_group': rack_groups[2].pk, + 'location': locations[2].pk, }, ] cls.bulk_update_data = { 'site': sites[1].pk, - 'rack_group': rack_groups[3].pk + 'location': locations[3].pk } class PowerFeedTest(APIViewTestCases.APIViewTestCase): model = PowerFeed - brief_fields = ['cable', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'planned', } @@ -1636,20 +1665,20 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-1') - rackgroup = RackGroup.objects.create(site=site, name='Rack Group 1', slug='rack-group-1') + location = Location.objects.create(site=site, name='Location 1', slug='location-1') rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000') racks = ( - Rack(site=site, group=rackgroup, role=rackrole, name='Rack 1'), - Rack(site=site, group=rackgroup, role=rackrole, name='Rack 2'), - Rack(site=site, group=rackgroup, role=rackrole, name='Rack 3'), - Rack(site=site, group=rackgroup, role=rackrole, name='Rack 4'), + Rack(site=site, location=location, role=rackrole, name='Rack 1'), + Rack(site=site, location=location, role=rackrole, name='Rack 2'), + Rack(site=site, location=location, role=rackrole, name='Rack 3'), + Rack(site=site, location=location, role=rackrole, name='Rack 4'), ) Rack.objects.bulk_create(racks) power_panels = ( - PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 1'), - PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 2'), + PowerPanel(site=site, location=location, name='Power Panel 1'), + PowerPanel(site=site, location=location, name='Power Panel 2'), ) PowerPanel.objects.bulk_create(power_panels) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 37d7014f1..c0fc89f83 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -229,40 +229,6 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_105_interface_to_circuittermination(self): - """ - [IF1] --C1-- [CT1A] - """ - interface1 = Interface.objects.create(device=self.device, name='Interface 1') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - - # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) - cable1.save() - path1 = self.assertPathExists( - origin=interface1, - destination=circuittermination1, - path=(cable1,), - is_active=True - ) - path2 = self.assertPathExists( - origin=circuittermination1, - destination=interface1, - path=(cable1,), - is_active=True - ) - self.assertEqual(CablePath.objects.count(), 2) - interface1.refresh_from_db() - circuittermination1.refresh_from_db() - self.assertPathIsSet(interface1, path1) - self.assertPathIsSet(circuittermination1, path2) - - # Delete cable 1 - cable1.delete() - - # Check that all CablePaths have been deleted - self.assertEqual(CablePath.objects.count(), 0) - def test_201_single_path_via_pass_through(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] @@ -820,6 +786,294 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 1) + def test_208_circuittermination(self): + """ + [IF1] --C1-- [CT1] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + + # Check for incomplete path + self.assertPathExists( + origin=interface1, + destination=None, + path=(cable1, circuittermination1), + is_active=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_209_circuit_to_interface(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [IF2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + + # Check for partial path from interface1 + self.assertPathExists( + origin=interface1, + destination=None, + path=(cable1, circuittermination1), + is_active=False + ) + + # Create CT2 + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Check for partial path to site + self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + + # Create cable 2 + cable2 = Cable(termination_a=circuittermination2, termination_b=interface2) + cable2.save() + + # Check for complete path in each direction + self.assertPathExists( + origin=interface1, + destination=interface2, + path=(cable1, circuittermination1, circuittermination2, cable2), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface1, + path=(cable2, circuittermination2, circuittermination1, cable1), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsNotSet(interface2) + + def test_210_circuit_to_site(self): + """ + [IF1] --C1-- [CT1] [CT2] --> [Site2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + site2 = Site.objects.create(name='Site 2', slug='site-2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + self.assertPathExists( + origin=interface1, + destination=site2, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_211_circuit_to_providernetwork(self): + """ + [IF1] --C1-- [CT1] [CT2] --> [PN1] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider) + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + self.assertPathExists( + origin=interface1, + destination=providernetwork, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_212_multiple_paths_via_circuit(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [CT1] [CT2] --C4-- [RP2] [FP2:1] --C5-- [IF3] + [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Create cables + cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1 + cable1.save() + cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2 + cable2.save() + cable3 = Cable(termination_a=rearport1, termination_b=circuittermination1) # RP1 -> CT1 + cable3.save() + cable4 = Cable(termination_a=rearport2, termination_b=circuittermination2) # RP2 -> CT2 + cable4.save() + cable5 = Cable(termination_a=interface3, termination_b=frontport2_1) # IF3 -> FP2:1 + cable5.save() + cable6 = Cable(termination_a=interface4, termination_b=frontport2_2) # IF4 -> FP2:2 + cable6.save() + self.assertPathExists( + origin=interface1, + destination=interface3, + path=( + cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_1, cable5 + ), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface4, + path=( + cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_2, cable6 + ), + is_active=True + ) + self.assertPathExists( + origin=interface3, + destination=interface1, + path=( + cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_1, cable1 + ), + is_active=True + ) + self.assertPathExists( + origin=interface4, + destination=interface2, + path=( + cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_2, cable2 + ), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cables 3-4 + cable3.delete() + cable4.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + + def test_213_multiple_circuits_to_interface(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [CT3] [CT4] --C3-- [IF2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + circuit2 = Circuit.objects.create(provider=self.circuit.provider, type=self.circuit.type, cid='Circuit 2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='A') + circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') + + # Create cables + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + cable2 = Cable(termination_a=circuittermination2, termination_b=circuittermination3) + cable2.save() + cable3 = Cable(termination_a=circuittermination4, termination_b=interface2) + cable3.save() + + # Check for paths + self.assertPathExists( + origin=interface1, + destination=interface2, + path=( + cable1, circuittermination1, circuittermination2, cable2, circuittermination3, circuittermination4, + cable3 + ), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface1, + path=( + cable3, circuittermination4, circuittermination3, cable2, circuittermination2, circuittermination1, + cable1 + ), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + path2 = self.assertPathExists( + origin=interface2, + destination=self.site, + path=(cable3, circuittermination4, circuittermination3), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filtersets.py similarity index 80% rename from netbox/dcim/tests/test_filters.py rename to netbox/dcim/tests/test_filtersets.py index a76788e65..154ec0847 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2,20 +2,15 @@ from django.contrib.auth.models import User from django.test import TestCase from dcim.choices import * -from dcim.filters import * -from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerPortTemplate, PowerOutlet, - PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from dcim.filtersets import * +from dcim.models import * from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup +from utilities.testing import ChangeLoggedFilterSetTests from virtualization.models import Cluster, ClusterType -class RegionTestCase(TestCase): +class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Region.objects.all() filterset = RegionFilterSet @@ -41,10 +36,6 @@ class RegionTestCase(TestCase): for region in child_regions: region.save() - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Region 1', 'Region 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -65,7 +56,53 @@ class RegionTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class SiteTestCase(TestCase): +class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = SiteGroup.objects.all() + filterset = SiteGroupFilterSet + + @classmethod + def setUpTestData(cls): + + sitegroups = ( + SiteGroup(name='Site Group 1', slug='site-group-1', description='A'), + SiteGroup(name='Site Group 2', slug='site-group-2', description='B'), + SiteGroup(name='Site Group 3', slug='site-group-3', description='C'), + ) + for sitegroup in sitegroups: + sitegroup.save() + + child_sitegroups = ( + SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]), + SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]), + SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]), + SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]), + SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]), + SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]), + ) + for sitegroup in child_sitegroups: + sitegroup.save() + + def test_name(self): + params = {'name': ['Site Group 1', 'Site Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['site-group-1', 'site-group-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent(self): + parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + +class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Site.objects.all() filterset = SiteFilterSet @@ -80,6 +117,14 @@ class SiteTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -96,16 +141,12 @@ class SiteTestCase(TestCase): Tenant.objects.bulk_create(tenants) sites = ( - Site(name='Site 1', slug='site-1', region=regions[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), - Site(name='Site 2', slug='site-2', region=regions[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), - Site(name='Site 3', slug='site-3', region=regions[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), ) Site.objects.bulk_create(sites) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Site 1', 'Site 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -153,6 +194,13 @@ class SiteTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + groups = SiteGroup.objects.all()[:2] + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].slug, groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant(self): tenants = Tenant.objects.all()[:2] params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} @@ -168,9 +216,9 @@ class SiteTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RackGroupTestCase(TestCase): - queryset = RackGroup.objects.all() - filterset = RackGroupFilterSet +class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = Location.objects.all() + filterset = LocationFilterSet @classmethod def setUpTestData(cls): @@ -183,39 +231,43 @@ class RackGroupTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) - parent_rack_groups = ( - RackGroup(name='Parent Rack Group 1', slug='parent-rack-group-1', site=sites[0]), - RackGroup(name='Parent Rack Group 2', slug='parent-rack-group-2', site=sites[1]), - RackGroup(name='Parent Rack Group 3', slug='parent-rack-group-3', site=sites[2]), + parent_locations = ( + Location(name='Parent Location 1', slug='parent-location-1', site=sites[0]), + Location(name='Parent Location 2', slug='parent-location-2', site=sites[1]), + Location(name='Parent Location 3', slug='parent-location-3', site=sites[2]), ) - for rackgroup in parent_rack_groups: - rackgroup.save() + for location in parent_locations: + location.save() - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0], parent=parent_rack_groups[0], description='A'), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1], parent=parent_rack_groups[1], description='B'), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2], parent=parent_rack_groups[2], description='C'), + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'), + Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'), + Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'), ) - for rackgroup in rack_groups: - rackgroup.save() - - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + for location in locations: + location.save() def test_name(self): - params = {'name': ['Rack Group 1', 'Rack Group 2']} + params = {'name': ['Location 1', 'Location 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_slug(self): - params = {'slug': ['rack-group-1', 'rack-group-2']} + params = {'slug': ['location-1', 'location-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_description(self): @@ -229,6 +281,13 @@ class RackGroupTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -237,14 +296,14 @@ class RackGroupTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_parent(self): - parent_groups = RackGroup.objects.filter(name__startswith='Parent')[:2] + parent_groups = Location.objects.filter(name__startswith='Parent')[:2] params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RackRoleTestCase(TestCase): +class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackRole.objects.all() filterset = RackRoleFilterSet @@ -258,10 +317,6 @@ class RackRoleTestCase(TestCase): ) RackRole.objects.bulk_create(rack_roles) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Rack Role 1', 'Rack Role 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -275,7 +330,7 @@ class RackRoleTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RackTestCase(TestCase): +class RackTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Rack.objects.all() filterset = RackFilterSet @@ -290,20 +345,28 @@ class RackTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]), + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), ) - for rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() rack_roles = ( RackRole(name='Rack Role 1', slug='rack-role-1'), @@ -328,16 +391,12 @@ class RackTestCase(TestCase): Tenant.objects.bulk_create(tenants) racks = ( - Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), + Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), + Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), + Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), ) Rack.objects.bulk_create(racks) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Rack 1', 'Rack 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -388,6 +447,13 @@ class RackTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -395,11 +461,11 @@ class RackTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_group(self): - groups = RackGroup.objects.all()[:2] - params = {'group_id': [groups[0].pk, groups[1].pk]} + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'group': [groups[0].slug, groups[1].slug]} + params = {'location': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_status(self): @@ -434,7 +500,7 @@ class RackTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RackReservationTestCase(TestCase): +class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() filterset = RackReservationFilterSet @@ -448,18 +514,18 @@ class RackReservationTestCase(TestCase): ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]), + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), ) - for rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() racks = ( - Rack(name='Rack 1', site=sites[0], group=rack_groups[0]), - Rack(name='Rack 2', site=sites[1], group=rack_groups[1]), - Rack(name='Rack 3', site=sites[2], group=rack_groups[2]), + Rack(name='Rack 1', site=sites[0], location=locations[0]), + Rack(name='Rack 2', site=sites[1], location=locations[1]), + Rack(name='Rack 3', site=sites[2], location=locations[2]), ) Rack.objects.bulk_create(racks) @@ -492,10 +558,6 @@ class RackReservationTestCase(TestCase): ) RackReservation.objects.bulk_create(reservations) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -503,11 +565,11 @@ class RackReservationTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_group(self): - groups = RackGroup.objects.all()[:2] - params = {'group_id': [groups[0].pk, groups[1].pk]} + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'group': [groups[0].slug, groups[1].slug]} + params = {'location': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_user(self): @@ -532,7 +594,7 @@ class RackReservationTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ManufacturerTestCase(TestCase): +class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Manufacturer.objects.all() filterset = ManufacturerFilterSet @@ -546,10 +608,6 @@ class ManufacturerTestCase(TestCase): ) Manufacturer.objects.bulk_create(manufacturers) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Manufacturer 1', 'Manufacturer 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -563,7 +621,7 @@ class ManufacturerTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceTypeTestCase(TestCase): +class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceType.objects.all() filterset = DeviceTypeFilterSet @@ -619,10 +677,6 @@ class DeviceTypeTestCase(TestCase): DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_model(self): params = {'model': ['Model 1', 'Model 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -699,7 +753,7 @@ class DeviceTypeTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class ConsolePortTemplateTestCase(TestCase): +class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() filterset = ConsolePortTemplateFilterSet @@ -721,10 +775,6 @@ class ConsolePortTemplateTestCase(TestCase): ConsolePortTemplate(device_type=device_types[2], name='Console Port 3'), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Console Port 1', 'Console Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -735,7 +785,7 @@ class ConsolePortTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConsoleServerPortTemplateTestCase(TestCase): +class ConsoleServerPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsoleServerPortTemplate.objects.all() filterset = ConsoleServerPortTemplateFilterSet @@ -757,10 +807,6 @@ class ConsoleServerPortTemplateTestCase(TestCase): ConsoleServerPortTemplate(device_type=device_types[2], name='Console Server Port 3'), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Console Server Port 1', 'Console Server Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -771,7 +817,7 @@ class ConsoleServerPortTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class PowerPortTemplateTestCase(TestCase): +class PowerPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerPortTemplate.objects.all() filterset = PowerPortTemplateFilterSet @@ -793,10 +839,6 @@ class PowerPortTemplateTestCase(TestCase): PowerPortTemplate(device_type=device_types[2], name='Power Port 3', maximum_draw=300, allocated_draw=150), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Power Port 1', 'Power Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -815,7 +857,7 @@ class PowerPortTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class PowerOutletTemplateTestCase(TestCase): +class PowerOutletTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerOutletTemplate.objects.all() filterset = PowerOutletTemplateFilterSet @@ -837,10 +879,6 @@ class PowerOutletTemplateTestCase(TestCase): PowerOutletTemplate(device_type=device_types[2], name='Power Outlet 3', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Power Outlet 1', 'Power Outlet 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -851,12 +889,11 @@ class PowerOutletTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_feed_leg(self): - # TODO: Support filtering for multiple values - params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class InterfaceTemplateTestCase(TestCase): +class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = InterfaceTemplate.objects.all() filterset = InterfaceTemplateFilterSet @@ -878,10 +915,6 @@ class InterfaceTemplateTestCase(TestCase): InterfaceTemplate(device_type=device_types[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_SFP, mgmt_only=False), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -892,9 +925,8 @@ class InterfaceTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - # TODO: Support filtering for multiple values - params = {'type': InterfaceTypeChoices.TYPE_1GE_FIXED} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mgmt_only(self): params = {'mgmt_only': 'true'} @@ -903,7 +935,7 @@ class InterfaceTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class FrontPortTemplateTestCase(TestCase): +class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPortTemplate.objects.all() filterset = FrontPortTemplateFilterSet @@ -932,10 +964,6 @@ class FrontPortTemplateTestCase(TestCase): FrontPortTemplate(device_type=device_types[2], name='Front Port 3', rear_port=rear_ports[2], type=PortTypeChoices.TYPE_BNC), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Front Port 1', 'Front Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -946,12 +974,11 @@ class FrontPortTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - # TODO: Support filtering for multiple values - params = {'type': PortTypeChoices.TYPE_8P8C} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RearPortTemplateTestCase(TestCase): +class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RearPortTemplate.objects.all() filterset = RearPortTemplateFilterSet @@ -973,10 +1000,6 @@ class RearPortTemplateTestCase(TestCase): RearPortTemplate(device_type=device_types[2], name='Rear Port 3', type=PortTypeChoices.TYPE_BNC, positions=3), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Rear Port 1', 'Rear Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -987,16 +1010,15 @@ class RearPortTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - # TODO: Support filtering for multiple values - params = {'type': PortTypeChoices.TYPE_8P8C} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_positions(self): params = {'positions': [1, 2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceBayTemplateTestCase(TestCase): +class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceBayTemplate.objects.all() filterset = DeviceBayTemplateFilterSet @@ -1018,10 +1040,6 @@ class DeviceBayTemplateTestCase(TestCase): DeviceBayTemplate(device_type=device_types[2], name='Device Bay 3'), )) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Device Bay 1', 'Device Bay 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1032,7 +1050,7 @@ class DeviceBayTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceRoleTestCase(TestCase): +class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceRole.objects.all() filterset = DeviceRoleFilterSet @@ -1046,10 +1064,6 @@ class DeviceRoleTestCase(TestCase): ) DeviceRole.objects.bulk_create(device_roles) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Device Role 1', 'Device Role 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1069,7 +1083,7 @@ class DeviceRoleTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PlatformTestCase(TestCase): +class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Platform.objects.all() filterset = PlatformFilterSet @@ -1090,10 +1104,6 @@ class PlatformTestCase(TestCase): ) Platform.objects.bulk_create(platforms) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Platform 1', 'Platform 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1118,7 +1128,7 @@ class PlatformTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceTestCase(TestCase): +class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Device.objects.all() filterset = DeviceFilterSet @@ -1161,25 +1171,33 @@ class DeviceTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]), + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), ) - for rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() racks = ( - Rack(name='Rack 1', site=sites[0], group=rack_groups[0]), - Rack(name='Rack 2', site=sites[1], group=rack_groups[1]), - Rack(name='Rack 3', site=sites[2], group=rack_groups[2]), + Rack(name='Rack 1', site=sites[0], location=locations[0]), + Rack(name='Rack 2', site=sites[1], location=locations[1]), + Rack(name='Rack 3', site=sites[2], location=locations[2]), ) Rack.objects.bulk_create(racks) @@ -1207,9 +1225,9 @@ class DeviceTestCase(TestCase): Tenant.objects.bulk_create(tenants) devices = ( - Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), - Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), - Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), ) Device.objects.bulk_create(devices) @@ -1263,10 +1281,6 @@ class DeviceTestCase(TestCase): Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Device 1', 'Device 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1324,6 +1338,13 @@ class DeviceTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1331,9 +1352,9 @@ class DeviceTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_rackgroup(self): - rack_groups = RackGroup.objects.all()[:2] - params = {'rack_group_id': [rack_groups[0].pk, rack_groups[1].pk]} + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_rack(self): @@ -1449,7 +1470,7 @@ class DeviceTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConsolePortTestCase(TestCase): +class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePort.objects.all() filterset = ConsolePortFilterSet @@ -1463,10 +1484,19 @@ class ConsolePortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1488,9 +1518,9 @@ class ConsolePortTestCase(TestCase): ConsoleServerPort.objects.bulk_create(console_server_ports) console_ports = ( - ConsolePort(device=devices[0], name='Console Port 1', description='First'), - ConsolePort(device=devices[1], name='Console Port 2', description='Second'), - ConsolePort(device=devices[2], name='Console Port 3', description='Third'), + ConsolePort(device=devices[0], name='Console Port 1', label='A', description='First'), + ConsolePort(device=devices[1], name='Console Port 2', label='B', description='Second'), + ConsolePort(device=devices[2], name='Console Port 3', label='C', description='Third'), ) ConsolePort.objects.bulk_create(console_ports) @@ -1499,14 +1529,14 @@ class ConsolePortTestCase(TestCase): Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save() # Third port is not connected - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Console Port 1', 'Console Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1524,6 +1554,13 @@ class ConsolePortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1545,7 +1582,7 @@ class ConsolePortTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class ConsoleServerPortTestCase(TestCase): +class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsoleServerPort.objects.all() filterset = ConsoleServerPortFilterSet @@ -1559,10 +1596,19 @@ class ConsoleServerPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1584,9 +1630,9 @@ class ConsoleServerPortTestCase(TestCase): ConsolePort.objects.bulk_create(console_ports) console_server_ports = ( - ConsoleServerPort(device=devices[0], name='Console Server Port 1', description='First'), - ConsoleServerPort(device=devices[1], name='Console Server Port 2', description='Second'), - ConsoleServerPort(device=devices[2], name='Console Server Port 3', description='Third'), + ConsoleServerPort(device=devices[0], name='Console Server Port 1', label='A', description='First'), + ConsoleServerPort(device=devices[1], name='Console Server Port 2', label='B', description='Second'), + ConsoleServerPort(device=devices[2], name='Console Server Port 3', label='C', description='Third'), ) ConsoleServerPort.objects.bulk_create(console_server_ports) @@ -1595,14 +1641,14 @@ class ConsoleServerPortTestCase(TestCase): Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save() # Third port is not connected - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Console Server Port 1', 'Console Server Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1620,6 +1666,13 @@ class ConsoleServerPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1641,7 +1694,7 @@ class ConsoleServerPortTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PowerPortTestCase(TestCase): +class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerPort.objects.all() filterset = PowerPortFilterSet @@ -1655,10 +1708,19 @@ class PowerPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1680,9 +1742,9 @@ class PowerPortTestCase(TestCase): PowerOutlet.objects.bulk_create(power_outlets) power_ports = ( - PowerPort(device=devices[0], name='Power Port 1', maximum_draw=100, allocated_draw=50, description='First'), - PowerPort(device=devices[1], name='Power Port 2', maximum_draw=200, allocated_draw=100, description='Second'), - PowerPort(device=devices[2], name='Power Port 3', maximum_draw=300, allocated_draw=150, description='Third'), + PowerPort(device=devices[0], name='Power Port 1', label='A', maximum_draw=100, allocated_draw=50, description='First'), + PowerPort(device=devices[1], name='Power Port 2', label='B', maximum_draw=200, allocated_draw=100, description='Second'), + PowerPort(device=devices[2], name='Power Port 3', label='C', maximum_draw=300, allocated_draw=150, description='Third'), ) PowerPort.objects.bulk_create(power_ports) @@ -1691,14 +1753,14 @@ class PowerPortTestCase(TestCase): Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save() # Third port is not connected - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Power Port 1', 'Power Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1724,6 +1786,13 @@ class PowerPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1745,7 +1814,7 @@ class PowerPortTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PowerOutletTestCase(TestCase): +class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerOutlet.objects.all() filterset = PowerOutletFilterSet @@ -1759,10 +1828,19 @@ class PowerOutletTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1784,9 +1862,9 @@ class PowerOutletTestCase(TestCase): PowerPort.objects.bulk_create(power_ports) power_outlets = ( - PowerOutlet(device=devices[0], name='Power Outlet 1', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'), - PowerOutlet(device=devices[1], name='Power Outlet 2', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'), - PowerOutlet(device=devices[2], name='Power Outlet 3', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'), + PowerOutlet(device=devices[0], name='Power Outlet 1', label='A', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'), + PowerOutlet(device=devices[1], name='Power Outlet 2', label='B', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'), + PowerOutlet(device=devices[2], name='Power Outlet 3', label='C', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'), ) PowerOutlet.objects.bulk_create(power_outlets) @@ -1795,22 +1873,21 @@ class PowerOutletTestCase(TestCase): Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save() # Third port is not connected - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Power Outlet 1', 'Power Outlet 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_feed_leg(self): - # TODO: Support filtering for multiple values - params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_connected(self): params = {'connected': True} @@ -1825,6 +1902,13 @@ class PowerOutletTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1846,7 +1930,7 @@ class PowerOutletTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class InterfaceTestCase(TestCase): +class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet @@ -1860,10 +1944,19 @@ class InterfaceTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1879,12 +1972,12 @@ class InterfaceTestCase(TestCase): Device.objects.bulk_create(devices) interfaces = ( - Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), - Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), - Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'), - Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), - Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), - Interface(device=devices[3], name='Interface 6', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), + Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), + Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), + Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'), + Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), + Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), + Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), ) Interface.objects.bulk_create(interfaces) @@ -1893,14 +1986,14 @@ class InterfaceTestCase(TestCase): Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save() # Third pair is not connected - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_connected(self): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) @@ -1931,6 +2024,34 @@ class InterfaceTestCase(TestCase): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_parent(self): + # Create child interfaces + parent_interface = Interface.objects.first() + child_interfaces = ( + Interface(device=parent_interface.device, name='Child 1', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=parent_interface.device, name='Child 2', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=parent_interface.device, name='Child 3', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL), + ) + Interface.objects.bulk_create(child_interfaces) + + params = {'parent_id': [parent_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_lag(self): + # Create LAG members + device = Device.objects.first() + lag_interface = Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG) + lag_interface.save() + lag_members = ( + Interface(device=device, name='Member 1', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device, name='Member 2', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device, name='Member 3', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(lag_members) + + params = {'lag_id': [lag_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -1938,6 +2059,13 @@ class InterfaceTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1973,7 +2101,7 @@ class InterfaceTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class FrontPortTestCase(TestCase): +class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() filterset = FrontPortFilterSet @@ -1987,10 +2115,19 @@ class FrontPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2016,12 +2153,12 @@ class FrontPortTestCase(TestCase): RearPort.objects.bulk_create(rear_ports) front_ports = ( - FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'), - FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'), - FrontPort(device=devices[2], name='Front Port 3', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'), - FrontPort(device=devices[3], name='Front Port 4', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1), - FrontPort(device=devices[3], name='Front Port 5', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1), - FrontPort(device=devices[3], name='Front Port 6', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1), + FrontPort(device=devices[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'), + FrontPort(device=devices[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'), + FrontPort(device=devices[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'), + FrontPort(device=devices[3], name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1), + FrontPort(device=devices[3], name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1), + FrontPort(device=devices[3], name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1), ) FrontPort.objects.bulk_create(front_ports) @@ -2030,18 +2167,17 @@ class FrontPortTestCase(TestCase): Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save() # Third port is not connected - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Front Port 1', 'Front Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): - # TODO: Test for multiple values - params = {'type': PortTypeChoices.TYPE_8P8C} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_description(self): params = {'description': ['First', 'Second']} @@ -2054,6 +2190,13 @@ class FrontPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2075,7 +2218,7 @@ class FrontPortTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RearPortTestCase(TestCase): +class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RearPort.objects.all() filterset = RearPortFilterSet @@ -2089,10 +2232,19 @@ class RearPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2108,12 +2260,12 @@ class RearPortTestCase(TestCase): Device.objects.bulk_create(devices) rear_ports = ( - RearPort(device=devices[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'), - RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'), - RearPort(device=devices[2], name='Rear Port 3', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'), - RearPort(device=devices[3], name='Rear Port 4', type=PortTypeChoices.TYPE_FC, positions=4), - RearPort(device=devices[3], name='Rear Port 5', type=PortTypeChoices.TYPE_FC, positions=5), - RearPort(device=devices[3], name='Rear Port 6', type=PortTypeChoices.TYPE_FC, positions=6), + RearPort(device=devices[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'), + RearPort(device=devices[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'), + RearPort(device=devices[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'), + RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4), + RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5), + RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6), ) RearPort.objects.bulk_create(rear_ports) @@ -2122,18 +2274,17 @@ class RearPortTestCase(TestCase): Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save() # Third port is not connected - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Rear Port 1', 'Rear Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): - # TODO: Test for multiple values - params = {'type': PortTypeChoices.TYPE_8P8C} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_positions(self): params = {'positions': [1, 2]} @@ -2150,6 +2301,13 @@ class RearPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2171,7 +2329,7 @@ class RearPortTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceBayTestCase(TestCase): +class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceBay.objects.all() filterset = DeviceBayFilterSet @@ -2185,10 +2343,19 @@ class DeviceBayTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2203,20 +2370,20 @@ class DeviceBayTestCase(TestCase): Device.objects.bulk_create(devices) device_bays = ( - DeviceBay(device=devices[0], name='Device Bay 1', description='First'), - DeviceBay(device=devices[1], name='Device Bay 2', description='Second'), - DeviceBay(device=devices[2], name='Device Bay 3', description='Third'), + DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'), + DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'), + DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'), ) DeviceBay.objects.bulk_create(device_bays) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Device Bay 1', 'Device Bay 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2228,6 +2395,13 @@ class DeviceBayTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2243,7 +2417,7 @@ class DeviceBayTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class InventoryItemTestCase(TestCase): +class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = InventoryItem.objects.all() filterset = InventoryItemFilterSet @@ -2268,10 +2442,18 @@ class InventoryItemTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2283,9 +2465,9 @@ class InventoryItemTestCase(TestCase): Device.objects.bulk_create(devices) inventory_items = ( - InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), - InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), - InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), + InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), + InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), + InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), ) for i in inventory_items: i.save() @@ -2298,14 +2480,14 @@ class InventoryItemTestCase(TestCase): for i in child_inventory_items: i.save() - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Inventory Item 1', 'Inventory Item 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_part_id(self): params = {'part_id': ['1001', '1002']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2328,6 +2510,13 @@ class InventoryItemTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2362,7 +2551,7 @@ class InventoryItemTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class VirtualChassisTestCase(TestCase): +class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualChassis.objects.all() filterset = VirtualChassisFilterSet @@ -2381,10 +2570,18 @@ class VirtualChassisTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2409,10 +2606,6 @@ class VirtualChassisTestCase(TestCase): Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=virtual_chassis[1]) Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[2]) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_domain(self): params = {'domain': ['Domain 1', 'Domain 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2435,6 +2628,13 @@ class VirtualChassisTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2443,7 +2643,7 @@ class VirtualChassisTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class CableTestCase(TestCase): +class CableTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Cable.objects.all() filterset = CableFilterSet @@ -2508,10 +2708,6 @@ class CableTestCase(TestCase): Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_label(self): params = {'label': ['Cable 1', 'Cable 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2567,7 +2763,7 @@ class CableTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class PowerPanelTestCase(TestCase): +class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerPanel.objects.all() filterset = PowerPanelFilterSet @@ -2582,32 +2778,36 @@ class PowerPanelTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]), + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), ) - for rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() power_panels = ( - PowerPanel(name='Power Panel 1', site=sites[0], rack_group=rack_groups[0]), - PowerPanel(name='Power Panel 2', site=sites[1], rack_group=rack_groups[1]), - PowerPanel(name='Power Panel 3', site=sites[2], rack_group=rack_groups[2]), + PowerPanel(name='Power Panel 1', site=sites[0], location=locations[0]), + PowerPanel(name='Power Panel 2', site=sites[1], location=locations[1]), + PowerPanel(name='Power Panel 3', site=sites[2], location=locations[2]), ) PowerPanel.objects.bulk_create(power_panels) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Power Panel 1', 'Power Panel 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2619,6 +2819,13 @@ class PowerPanelTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2626,13 +2833,13 @@ class PowerPanelTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_rack_group(self): - rack_groups = RackGroup.objects.all()[:2] - params = {'rack_group_id': [rack_groups[0].pk, rack_groups[1].pk]} + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class PowerFeedTestCase(TestCase): +class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerFeed.objects.all() filterset = PowerFeedFilterSet @@ -2647,10 +2854,18 @@ class PowerFeedTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2687,18 +2902,13 @@ class PowerFeedTestCase(TestCase): Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save() Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save() - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Power Feed 1', 'Power Feed 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_status(self): - # TODO: Test for multiple values - params = {'status': PowerFeedStatusChoices.STATUS_ACTIVE} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'status': [PowerFeedStatusChoices.STATUS_ACTIVE, PowerFeedStatusChoices.STATUS_FAILED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY} @@ -2731,6 +2941,13 @@ class PowerFeedTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 184681e90..ae280365e 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -7,39 +7,66 @@ from dcim.models import * from tenancy.models import Tenant -class RackGroupTestCase(TestCase): +class LocationTestCase(TestCase): - def test_change_rackgroup_site(self): + def test_change_location_site(self): """ - Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology: + Check that all child Locations and Racks get updated when a Location is moved to a new Site. Topology: Site A - - RackGroup A1 - - RackGroup A2 + - Location A1 + - Location A2 - Rack 2 + - Device 2 - Rack 1 + - Device 1 """ + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + device_role = DeviceRole.objects.create( + name='Device Role 1', slug='device-role-1', color='ff0000' + ) + site_a = Site.objects.create(name='Site A', slug='site-a') site_b = Site.objects.create(name='Site B', slug='site-b') - rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1') - rackgroup_a1.save() - rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2') - rackgroup_a2.save() + location_a1 = Location(site=site_a, name='Location A1', slug='location-a1') + location_a1.save() + location_a2 = Location(site=site_a, parent=location_a1, name='Location A2', slug='location-a2') + location_a2.save() - rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1') - rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2') + rack1 = Rack.objects.create(site=site_a, location=location_a1, name='Rack 1') + rack2 = Rack.objects.create(site=site_a, location=location_a2, name='Rack 2') - powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1') + device1 = Device.objects.create( + site=site_a, + location=location_a1, + name='Device 1', + device_type=device_type, + device_role=device_role + ) + device2 = Device.objects.create( + site=site_a, + location=location_a2, + name='Device 2', + device_type=device_type, + device_role=device_role + ) - # Move RackGroup A1 to Site B - rackgroup_a1.site = site_b - rackgroup_a1.save() + powerpanel1 = PowerPanel.objects.create(site=site_a, location=location_a1, name='Power Panel 1') - # Check that all objects within RackGroup A1 now belong to Site B - self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b) - self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b) + # Move Location A1 to Site B + location_a1.site = site_b + location_a1.save() + + # Check that all objects within Location A1 now belong to Site B + self.assertEqual(Location.objects.get(pk=location_a1.pk).site, site_b) + self.assertEqual(Location.objects.get(pk=location_a2.pk).site, site_b) self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b) self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b) + self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b) + self.assertEqual(Device.objects.get(pk=device2.pk).site, site_b) self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b) @@ -55,12 +82,12 @@ class RackTestCase(TestCase): name='TestSite2', slug='test-site-2' ) - self.group1 = RackGroup.objects.create( + self.location1 = Location.objects.create( name='TestGroup1', slug='test-group-1', site=self.site1 ) - self.group2 = RackGroup.objects.create( + self.location2 = Location.objects.create( name='TestGroup2', slug='test-group-2', site=self.site2 @@ -69,7 +96,7 @@ class RackTestCase(TestCase): name='TestRack1', facility_id='A101', site=self.site1, - group=self.group1, + location=self.location1, u_height=42 ) self.manufacturer = Manufacturer.objects.create( @@ -134,19 +161,19 @@ class RackTestCase(TestCase): with self.assertRaises(ValidationError): rack1.clean() - def test_rack_group_site(self): + def test_location_site(self): - rack_invalid_group = Rack( + rack_invalid_location = Rack( name='TestRack2', facility_id='A102', site=self.site1, u_height=42, - group=self.group2 + location=self.location2 ) - rack_invalid_group.save() + rack_invalid_location.save() with self.assertRaises(ValidationError): - rack_invalid_group.clean() + rack_invalid_location.clean() def test_mount_single_device(self): @@ -452,10 +479,13 @@ class CableTestCase(TestCase): device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 ) self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') + provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider) self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') - self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A') - self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z') + self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') + self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2') + self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A') + self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z') + self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A') def test_cable_creation(self): """ @@ -525,6 +555,14 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() + def test_cable_cannot_terminate_to_a_provider_network_circuittermination(self): + """ + Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork + """ + cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3) + with self.assertRaises(ValidationError): + cable.clean() + def test_rearport_connections(self): """ Test various combinations of RearPort connections. diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 86518af84..c4e552b99 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -12,20 +12,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import VLAN -from utilities.testing import ViewTestCases - - -def create_test_device(name): - """ - Convenience method for creating a Device (e.g. for component testing). - """ - site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') - manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') - devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) - devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) - - return device +from utilities.testing import ViewTestCases, create_tags, create_test_device class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -57,6 +44,44 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Region 6,region-6,Sixth region", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = SiteGroup + + @classmethod + def setUpTestData(cls): + + # Create three SiteGroups + sitegroups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for sitegroup in sitegroups: + sitegroup.save() + + cls.form_data = { + 'name': 'Site Group X', + 'slug': 'site-group-x', + 'parent': sitegroups[2].pk, + 'description': 'A new site group', + } + + cls.csv_data = ( + "name,slug,description", + "Site Group 4,site-group-4,Fourth site group", + "Site Group 5,site-group-5,Fifth site group", + "Site Group 6,site-group-6,Sixth site group", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Site @@ -71,19 +96,27 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + ) + for group in groups: + group.save() + Site.objects.bulk_create([ - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[0]), - Site(name='Site 3', slug='site-3', region=regions[0]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]), + Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Site X', 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, 'region': regions[1].pk, + 'group': groups[1].pk, 'tenant': None, 'facility': 'Facility X', 'asn': 65001, @@ -110,6 +143,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'status': SiteStatusChoices.STATUS_PLANNED, 'region': regions[1].pk, + 'group': groups[1].pk, 'tenant': None, 'asn': 65009, 'time_zone': pytz.timezone('US/Eastern'), @@ -117,8 +151,8 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): - model = RackGroup +class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = Location @classmethod def setUpTestData(cls): @@ -126,28 +160,32 @@ class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site = Site(name='Site 1', slug='site-1') site.save() - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=site), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=site), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=site), + locations = ( + Location(name='Location 1', slug='location-1', site=site), + Location(name='Location 2', slug='location-2', site=site), + Location(name='Location 3', slug='location-3', site=site), ) - for rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() cls.form_data = { - 'name': 'Rack Group X', - 'slug': 'rack-group-x', + 'name': 'Location X', + 'slug': 'location-x', 'site': site.pk, - 'description': 'A new rack group', + 'description': 'A new location', } cls.csv_data = ( "site,name,slug,description", - "Site 1,Rack Group 4,rack-group-4,Fourth rack group", - "Site 1,Rack Group 5,rack-group-5,Fifth rack group", - "Site 1,Rack Group 6,rack-group-6,Sixth rack group", + "Site 1,Location 4,location-4,Fourth location", + "Site 1,Location 5,location-5,Fifth location", + "Site 1,Location 6,location-6,Sixth location", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RackRole @@ -175,6 +213,11 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Rack Role 6,rack-role-6,0000ff", ) + cls.bulk_edit_data = { + 'color': '00ff00', + 'description': 'New description', + } + class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = RackReservation @@ -187,10 +230,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): site = Site.objects.create(name='Site 1', slug='site-1') - rack_group = RackGroup(name='Rack Group 1', slug='rack-group-1', site=site) - rack_group.save() + location = Location(name='Location 1', slug='location-1', site=site) + location.save() - rack = Rack(name='Rack 1', site=site, group=rack_group) + rack = Rack(name='Rack 1', site=site, location=location) rack.save() RackReservation.objects.bulk_create([ @@ -199,7 +242,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'rack': rack.pk, @@ -211,10 +254,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'site,rack_group,rack,units,description', - 'Site 1,Rack Group 1,Rack 1,"10,11,12",Reservation 1', - 'Site 1,Rack Group 1,Rack 1,"13,14,15",Reservation 2', - 'Site 1,Rack Group 1,Rack 1,"16,17,18",Reservation 3', + 'site,location,rack,units,description', + 'Site 1,Location 1,Rack 1,"10,11,12",Reservation 1', + 'Site 1,Location 1,Rack 1,"13,14,15",Reservation 2', + 'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3', ) cls.bulk_edit_data = { @@ -236,12 +279,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Site.objects.bulk_create(sites) - rackgroups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]) ) - for rackgroup in rackgroups: - rackgroup.save() + for location in locations: + location.save() rackroles = ( RackRole(name='Rack Role 1', slug='rack-role-1'), @@ -255,13 +298,13 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): Rack(name='Rack 3', site=sites[0]), )) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Rack X', 'facility_id': 'Facility X', 'site': sites[1].pk, - 'group': rackgroups[1].pk, + 'location': locations[1].pk, 'tenant': None, 'status': RackStatusChoices.STATUS_PLANNED, 'role': rackroles[1].pk, @@ -279,15 +322,15 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "site,group,name,width,u_height", + "site,location,name,width,u_height", "Site 1,,Rack 4,19,42", - "Site 1,Rack Group 1,Rack 5,19,42", - "Site 2,Rack Group 2,Rack 6,19,42", + "Site 1,Location 1,Rack 5,19,42", + "Site 2,Location 2,Rack 6,19,42", ) cls.bulk_edit_data = { 'site': sites[1].pk, - 'group': rackgroups[1].pk, + 'location': locations[1].pk, 'tenant': None, 'status': RackStatusChoices.STATUS_DEPRECATED, 'role': rackroles[1].pk, @@ -336,6 +379,10 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Manufacturer 6,manufacturer-6,Sixth manufacturer", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + # TODO: Change base class to PrimaryObjectViewTestCase # Blocked by absence of bulk import view for DeviceTypes @@ -366,7 +413,7 @@ class DeviceTypeTestCase( DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'manufacturer': manufacturers[1].pk, @@ -885,6 +932,11 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Device Role 6,device-role-6,0000ff", ) + cls.bulk_edit_data = { + 'color': '00ff00', + 'description': 'New description', + } + class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Platform @@ -916,6 +968,11 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Platform 6,platform-6,Sixth platform", ) + cls.bulk_edit_data = { + 'napalm_driver': 'ios', + 'description': 'New description', + } + class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Device @@ -929,11 +986,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Site.objects.bulk_create(sites) - rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1') - rack_group.save() + location = Location(site=sites[0], name='Location 1', slug='location-1') + location.save() racks = ( - Rack(name='Rack 1', site=sites[0], group=rack_group), + Rack(name='Rack 1', site=sites[0], location=location), Rack(name='Rack 2', site=sites[1]), ) Rack.objects.bulk_create(racks) @@ -964,7 +1021,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + VirtualChassis.objects.create(name='Virtual Chassis 1') cls.form_data = { 'device_type': devicetypes[1].pk, @@ -991,10 +1050,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Rack Group 1,Rack 1,10,front", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Rack Group 1,Rack 1,20,front", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Rack Group 1,Rack 1,30,front", + "device_role,manufacturer,device_type,status,name,site,location,rack,position,face,virtual_chassis,vc_position,vc_priority", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front,Virtual Chassis 1,1,10", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front,Virtual Chassis 1,2,20", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30", ) cls.bulk_edit_data = { @@ -1144,7 +1203,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): ConsolePort(device=device, name='Console Port 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1176,6 +1235,18 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + consoleport = ConsolePort.objects.first() + consoleserverport = ConsoleServerPort.objects.create( + device=consoleport.device, + name='Console Server Port 1' + ) + Cable(termination_a=consoleport, termination_b=consoleserverport).save() + + response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk})) + self.assertHttpStatus(response, 200) + class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsoleServerPort @@ -1190,7 +1261,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): ConsoleServerPort(device=device, name='Console Server Port 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1220,6 +1291,18 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Server Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + consoleserverport = ConsoleServerPort.objects.first() + consoleport = ConsolePort.objects.create( + device=consoleserverport.device, + name='Console Port 1' + ) + Cable(termination_a=consoleserverport, termination_b=consoleport).save() + + response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk})) + self.assertHttpStatus(response, 200) + class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerPort @@ -1234,7 +1317,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): PowerPort(device=device, name='Power Port 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1270,6 +1353,18 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + powerport = PowerPort.objects.first() + poweroutlet = PowerOutlet.objects.create( + device=powerport.device, + name='Power Outlet 1' + ) + Cable(termination_a=powerport, termination_b=poweroutlet).save() + + response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk})) + self.assertHttpStatus(response, 200) + class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerOutlet @@ -1290,7 +1385,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1326,6 +1421,15 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Outlet 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + poweroutlet = PowerOutlet.objects.first() + powerport = PowerPort.objects.first() + Cable(termination_a=poweroutlet, termination_b=powerport).save() + + response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk})) + self.assertHttpStatus(response, 200) + class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = Interface @@ -1350,7 +1454,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VLAN.objects.bulk_create(vlans) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1360,7 +1464,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'enabled': False, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), - 'mtu': 2000, + 'mtu': 65000, 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -1405,6 +1509,14 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Interface 6,1000base-t", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + interface1, interface2 = Interface.objects.all()[:2] + Cable(termination_a=interface1, termination_b=interface2).save() + + response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk})) + self.assertHttpStatus(response, 200) + class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort @@ -1429,7 +1541,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1464,6 +1576,18 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Front Port 6,8p8c,Rear Port 6,1", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + frontport = FrontPort.objects.first() + interface = Interface.objects.create( + device=frontport.device, + name='Interface 1' + ) + Cable(termination_a=frontport, termination_b=interface).save() + + response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk})) + self.assertHttpStatus(response, 200) + class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = RearPort @@ -1478,7 +1602,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): RearPort(device=device, name='Rear Port 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1510,6 +1634,18 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Rear Port 6,8p8c,1", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + rearport = RearPort.objects.first() + interface = Interface.objects.create( + device=rearport.device, + name='Interface 1' + ) + Cable(termination_a=rearport, termination_b=interface).save() + + response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk})) + self.assertHttpStatus(response, 200) + class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay @@ -1527,7 +1663,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): DeviceBay(device=device, name='Device Bay 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1567,7 +1703,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): InventoryItem.objects.create(device=device, name='Inventory Item 2') InventoryItem.objects.create(device=device, name='Inventory Item 3') - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1600,10 +1736,10 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - "device,name", - "Device 1,Inventory Item 4", - "Device 1,Inventory Item 5", - "Device 1,Inventory Item 6", + "device,name,parent", + "Device 1,Inventory Item 4,Inventory Item 1", + "Device 1,Inventory Item 5,Inventory Item 2", + "Device 1,Inventory Item 6,Inventory Item 3", ) @@ -1657,7 +1793,7 @@ class CableTestCase( Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save() Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save() - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') interface_ct = ContentType.objects.get_for_model(Interface) cls.form_data = { @@ -1771,38 +1907,38 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Site.objects.bulk_create(sites) - rackgroups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), ) - for rackgroup in rackgroups: - rackgroup.save() + for location in locations: + location.save() PowerPanel.objects.bulk_create(( - PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 1'), - PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 2'), - PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'), + PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'), + PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'), + PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'), )) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'site': sites[1].pk, - 'rack_group': rackgroups[1].pk, + 'location': locations[1].pk, 'name': 'Power Panel X', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,rack_group,name", - "Site 1,Rack Group 1,Power Panel 4", - "Site 1,Rack Group 1,Power Panel 5", - "Site 1,Rack Group 1,Power Panel 6", + "site,location,name", + "Site 1,Location 1,Power Panel 4", + "Site 1,Location 1,Power Panel 5", + "Site 1,Location 1,Power Panel 6", ) cls.bulk_edit_data = { 'site': sites[1].pk, - 'rack_group': rackgroups[1].pk, + 'location': locations[1].pk, } @@ -1832,7 +1968,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]), )) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Power Feed X', @@ -1868,3 +2004,26 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'max_utilization': 50, 'comments': 'New comments', } + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + device_role = DeviceRole.objects.create( + name='Device Role', slug='device-role-1' + ) + device = Device.objects.create( + site=Site.objects.first(), device_type=device_type, device_role=device_role + ) + + powerfeed = PowerFeed.objects.first() + powerport = PowerPort.objects.create( + device=device, + name='Power Port 1' + ) + Cable(termination_a=powerfeed, termination_b=powerport).save() + + response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk})) + self.assertHttpStatus(response, 200) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index d167ebdb7..11ffd4458 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,13 +1,10 @@ from django.urls import path -from extras.views import ObjectChangeLogView, ImageAttachmentEditView +from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView from ipam.views import ServiceEditView +from utilities.views import SlugRedirectView from . import views -from .models import ( - Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, - RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, -) +from .models import * app_name = 'dcim' urlpatterns = [ @@ -16,37 +13,57 @@ urlpatterns = [ path('regions/', views.RegionListView.as_view(), name='region_list'), path('regions/add/', views.RegionEditView.as_view(), name='region_add'), path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), + path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + path('regions//', views.RegionView.as_view(), name='region'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + # Site groups + path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'), + path('site-groups/add/', views.SiteGroupEditView.as_view(), name='sitegroup_add'), + path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'), + path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'), + path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'), + path('site-groups//', views.SiteGroupView.as_view(), name='sitegroup'), + path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), + path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), + path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}), + # Sites path('sites/', views.SiteListView.as_view(), name='site_list'), path('sites/add/', views.SiteEditView.as_view(), name='site_add'), path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), - path('sites//', views.SiteView.as_view(), name='site'), - path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), - path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), - path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path('sites//', views.SiteView.as_view(), name='site'), + path('sites//', SlugRedirectView.as_view(), kwargs={'model': Site}), + path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), + path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), + path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path('sites//journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}), path('sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), - # Rack groups - path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), - path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'), - path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), - path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), - path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), - path('rack-groups//delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'), - path('rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), + # Locations + path('locations/', views.LocationListView.as_view(), name='location_list'), + path('locations/add/', views.LocationEditView.as_view(), name='location_add'), + path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'), + path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'), + path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'), + path('locations//', views.LocationView.as_view(), name='location'), + path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), + path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), + path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), + path('locations//images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}), # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'), path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), + path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + path('rack-roles//', views.RackRoleView.as_view(), name='rackrole'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), @@ -61,6 +78,7 @@ urlpatterns = [ path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), + path('rack-reservations//journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}), # Racks path('racks/', views.RackListView.as_view(), name='rack_list'), @@ -73,16 +91,19 @@ urlpatterns = [ path('racks//edit/', views.RackEditView.as_view(), name='rack_edit'), path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), + path('racks//journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}), path('racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'), path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), + path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), - path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), - path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), - path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path('manufacturers//', views.ManufacturerView.as_view(), name='manufacturer'), + path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), + path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -94,6 +115,7 @@ urlpatterns = [ path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), + path('device-types//journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}), # Console port templates path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), @@ -163,19 +185,23 @@ urlpatterns = [ path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), + path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), - path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), - path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), - path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path('device-roles//', views.DeviceRoleView.as_view(), name='devicerole'), + path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), + path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms path('platforms/', views.PlatformListView.as_view(), name='platform_list'), path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'), path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), + path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), - path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), - path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), - path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path('platforms//', views.PlatformView.as_view(), name='platform'), + path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), + path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices path('devices/', views.DeviceListView.as_view(), name='device_list'), @@ -198,6 +224,7 @@ urlpatterns = [ path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), path('devices//changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path('devices//journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}), path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), @@ -353,6 +380,7 @@ urlpatterns = [ path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'), path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + path('cables//journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}), # Console/power/interface connections (read-only) path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), @@ -369,6 +397,7 @@ urlpatterns = [ path('virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), + path('virtual-chassis//journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}), path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), @@ -382,18 +411,21 @@ urlpatterns = [ path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), + path('power-panels//journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}), # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), + path('power-feeds/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'), path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), path('power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), + path('power-feeds//journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}), path('power-feeds//connect//', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b092be612..5afae3ced 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from circuits.models import Circuit -from extras.views import ObjectChangeLogView, ObjectConfigContextView +from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView from ipam.models import IPAddress, Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from netbox.views import generic @@ -20,18 +20,19 @@ from secrets.models import Secret from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.tables import paginate_table from utilities.utils import csv_format, count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine -from . import filters, forms, tables +from . import filtersets, forms, tables from .choices import DeviceFaceChoices from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, - PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, + PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + SiteGroup, VirtualChassis, ) @@ -106,11 +107,39 @@ class RegionListView(generic.ObjectListView): 'site_count', cumulative=True ) - filterset = filters.RegionFilterSet + filterset = filtersets.RegionFilterSet filterset_form = forms.RegionFilterForm table = tables.RegionTable +class RegionView(generic.ObjectView): + queryset = Region.objects.all() + + def get_extra_context(self, request, instance): + child_regions = Region.objects.add_related_count( + Region.objects.all(), + Site, + 'region', + 'site_count', + cumulative=True + ).restrict(request.user, 'view').filter( + parent__in=instance.get_descendants(include_self=True) + ) + child_regions_table = tables.RegionTable(child_regions) + + sites = Site.objects.restrict(request.user, 'view').filter( + region=instance + ) + sites_table = tables.SiteTable(sites) + sites_table.columns.hide('region') + paginate_table(sites_table, request) + + return { + 'child_regions_table': child_regions_table, + 'sites_table': sites_table, + } + + class RegionEditView(generic.ObjectEditView): queryset = Region.objects.all() model_form = forms.RegionForm @@ -126,6 +155,19 @@ class RegionBulkImportView(generic.BulkImportView): table = tables.RegionTable +class RegionBulkEditView(generic.BulkEditView): + queryset = Region.objects.add_related_count( + Region.objects.all(), + Site, + 'region', + 'site_count', + cumulative=True + ) + filterset = filtersets.RegionFilterSet + table = tables.RegionTable + form = forms.RegionBulkEditForm + + class RegionBulkDeleteView(generic.BulkDeleteView): queryset = Region.objects.add_related_count( Region.objects.all(), @@ -134,17 +176,102 @@ class RegionBulkDeleteView(generic.BulkDeleteView): 'site_count', cumulative=True ) - filterset = filters.RegionFilterSet + filterset = filtersets.RegionFilterSet table = tables.RegionTable +# +# Site groups +# + +class SiteGroupListView(generic.ObjectListView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filtersets.SiteGroupFilterSet + filterset_form = forms.SiteGroupFilterForm + table = tables.SiteGroupTable + + +class SiteGroupView(generic.ObjectView): + queryset = SiteGroup.objects.all() + + def get_extra_context(self, request, instance): + child_groups = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ).restrict(request.user, 'view').filter( + parent__in=instance.get_descendants(include_self=True) + ) + child_groups_table = tables.SiteGroupTable(child_groups) + + sites = Site.objects.restrict(request.user, 'view').filter( + group=instance + ) + sites_table = tables.SiteTable(sites) + sites_table.columns.hide('group') + paginate_table(sites_table, request) + + return { + 'child_groups_table': child_groups_table, + 'sites_table': sites_table, + } + + +class SiteGroupEditView(generic.ObjectEditView): + queryset = SiteGroup.objects.all() + model_form = forms.SiteGroupForm + + +class SiteGroupDeleteView(generic.ObjectDeleteView): + queryset = SiteGroup.objects.all() + + +class SiteGroupBulkImportView(generic.BulkImportView): + queryset = SiteGroup.objects.all() + model_form = forms.SiteGroupCSVForm + table = tables.SiteGroupTable + + +class SiteGroupBulkEditView(generic.BulkEditView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filtersets.SiteGroupFilterSet + table = tables.SiteGroupTable + form = forms.SiteGroupBulkEditForm + + +class SiteGroupBulkDeleteView(generic.BulkDeleteView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filtersets.SiteGroupFilterSet + table = tables.SiteGroupTable + + # # Sites # class SiteListView(generic.ObjectListView): queryset = Site.objects.all() - filterset = filters.SiteFilterSet + filterset = filtersets.SiteFilterSet filterset_form = forms.SiteFilterForm table = tables.SiteTable @@ -161,24 +288,30 @@ class SiteView(generic.ObjectView): 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(), 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(), } - rack_groups = RackGroup.objects.add_related_count( - RackGroup.objects.all(), + locations = Location.objects.add_related_count( + Location.objects.all(), Rack, - 'group', + 'location', 'rack_count', cumulative=True + ) + locations = Location.objects.add_related_count( + locations, + Device, + 'location', + 'device_count', + cumulative=True ).restrict(request.user, 'view').filter(site=instance) return { 'stats': stats, - 'rack_groups': rack_groups, + 'locations': locations, } class SiteEditView(generic.ObjectEditView): queryset = Site.objects.all() model_form = forms.SiteForm - template_name = 'dcim/site_edit.html' class SiteDeleteView(generic.ObjectDeleteView): @@ -193,59 +326,109 @@ class SiteBulkImportView(generic.BulkImportView): class SiteBulkEditView(generic.BulkEditView): queryset = Site.objects.prefetch_related('region', 'tenant') - filterset = filters.SiteFilterSet + filterset = filtersets.SiteFilterSet table = tables.SiteTable form = forms.SiteBulkEditForm class SiteBulkDeleteView(generic.BulkDeleteView): queryset = Site.objects.prefetch_related('region', 'tenant') - filterset = filters.SiteFilterSet + filterset = filtersets.SiteFilterSet table = tables.SiteTable # -# Rack groups +# Locations # -class RackGroupListView(generic.ObjectListView): - queryset = RackGroup.objects.add_related_count( - RackGroup.objects.all(), +class LocationListView(generic.ObjectListView): + queryset = Location.objects.add_related_count( + Location.objects.add_related_count( + Location.objects.all(), + Device, + 'location', + 'device_count', + cumulative=True + ), Rack, - 'group', + 'location', 'rack_count', cumulative=True ) - filterset = filters.RackGroupFilterSet - filterset_form = forms.RackGroupFilterForm - table = tables.RackGroupTable + filterset = filtersets.LocationFilterSet + filterset_form = forms.LocationFilterForm + table = tables.LocationTable -class RackGroupEditView(generic.ObjectEditView): - queryset = RackGroup.objects.all() - model_form = forms.RackGroupForm +class LocationView(generic.ObjectView): + queryset = Location.objects.all() + + def get_extra_context(self, request, instance): + location_ids = instance.get_descendants(include_self=True).values_list('pk', flat=True) + rack_count = Rack.objects.filter(location__in=location_ids).count() + device_count = Device.objects.filter(location__in=location_ids).count() + + child_locations = Location.objects.add_related_count( + Location.objects.add_related_count( + Location.objects.all(), + Device, + 'location', + 'device_count', + cumulative=True + ), + Rack, + 'location', + 'rack_count', + cumulative=True + ).filter(pk__in=location_ids).exclude(pk=instance.pk) + child_locations_table = tables.LocationTable(child_locations) + paginate_table(child_locations_table, request) + + return { + 'rack_count': rack_count, + 'device_count': device_count, + 'child_locations_table': child_locations_table, + } -class RackGroupDeleteView(generic.ObjectDeleteView): - queryset = RackGroup.objects.all() +class LocationEditView(generic.ObjectEditView): + queryset = Location.objects.all() + model_form = forms.LocationForm -class RackGroupBulkImportView(generic.BulkImportView): - queryset = RackGroup.objects.all() - model_form = forms.RackGroupCSVForm - table = tables.RackGroupTable +class LocationDeleteView(generic.ObjectDeleteView): + queryset = Location.objects.all() -class RackGroupBulkDeleteView(generic.BulkDeleteView): - queryset = RackGroup.objects.add_related_count( - RackGroup.objects.all(), +class LocationBulkImportView(generic.BulkImportView): + queryset = Location.objects.all() + model_form = forms.LocationCSVForm + table = tables.LocationTable + + +class LocationBulkEditView(generic.BulkEditView): + queryset = Location.objects.add_related_count( + Location.objects.all(), Rack, - 'group', + 'location', 'rack_count', cumulative=True ).prefetch_related('site') - filterset = filters.RackGroupFilterSet - table = tables.RackGroupTable + filterset = filtersets.LocationFilterSet + table = tables.LocationTable + form = forms.LocationBulkEditForm + + +class LocationBulkDeleteView(generic.BulkDeleteView): + queryset = Location.objects.add_related_count( + Location.objects.all(), + Rack, + 'location', + 'rack_count', + cumulative=True + ).prefetch_related('site') + filterset = filtersets.LocationFilterSet + table = tables.LocationTable # @@ -259,6 +442,23 @@ class RackRoleListView(generic.ObjectListView): table = tables.RackRoleTable +class RackRoleView(generic.ObjectView): + queryset = RackRole.objects.all() + + def get_extra_context(self, request, instance): + racks = Rack.objects.restrict(request.user, 'view').filter( + role=instance + ) + + racks_table = tables.RackTable(racks) + racks_table.columns.hide('role') + paginate_table(racks_table, request) + + return { + 'racks_table': racks_table, + } + + class RackRoleEditView(generic.ObjectEditView): queryset = RackRole.objects.all() model_form = forms.RackRoleForm @@ -274,6 +474,15 @@ class RackRoleBulkImportView(generic.BulkImportView): table = tables.RackRoleTable +class RackRoleBulkEditView(generic.BulkEditView): + queryset = RackRole.objects.annotate( + rack_count=count_related(Rack, 'role') + ) + filterset = filtersets.RackRoleFilterSet + table = tables.RackRoleTable + form = forms.RackRoleBulkEditForm + + class RackRoleBulkDeleteView(generic.BulkDeleteView): queryset = RackRole.objects.annotate( rack_count=count_related(Rack, 'role') @@ -287,11 +496,11 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): class RackListView(generic.ObjectListView): queryset = Rack.objects.prefetch_related( - 'site', 'group', 'tenant', 'role', 'devices__device_type' + 'site', 'location', 'tenant', 'role', 'devices__device_type' ).annotate( device_count=count_related(Device, 'rack') ) - filterset = filters.RackFilterSet + filterset = filtersets.RackFilterSet filterset_form = forms.RackFilterForm table = tables.RackDetailTable @@ -304,7 +513,7 @@ class RackElevationListView(generic.ObjectListView): def get(self, request): - racks = filters.RackFilterSet(request.GET, self.queryset).qs + racks = filtersets.RackFilterSet(request.GET, self.queryset).qs total_count = racks.count() # Determine ordering @@ -339,21 +548,22 @@ class RackElevationListView(generic.ObjectListView): class RackView(generic.ObjectView): - queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role') + queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') def get_extra_context(self, request, instance): - # Get 0U and child devices located within the rack + # Get 0U devices located within the rack nonracked_devices = Device.objects.filter( rack=instance, - position__isnull=True + position__isnull=True, + parent_bay__isnull=True ).prefetch_related('device_type__manufacturer') peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) - if instance.group: - peer_racks = peer_racks.filter(group=instance.group) + if instance.location: + peer_racks = peer_racks.filter(location=instance.location) else: - peer_racks = peer_racks.filter(group__isnull=True) + peer_racks = peer_racks.filter(location__isnull=True) next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first() prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first() @@ -391,15 +601,15 @@ class RackBulkImportView(generic.BulkImportView): class RackBulkEditView(generic.BulkEditView): - queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') - filterset = filters.RackFilterSet + queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role') + filterset = filtersets.RackFilterSet table = tables.RackTable form = forms.RackBulkEditForm class RackBulkDeleteView(generic.BulkDeleteView): - queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') - filterset = filters.RackFilterSet + queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role') + filterset = filtersets.RackFilterSet table = tables.RackTable @@ -409,7 +619,7 @@ class RackBulkDeleteView(generic.BulkDeleteView): class RackReservationListView(generic.ObjectListView): queryset = RackReservation.objects.all() - filterset = filters.RackReservationFilterSet + filterset = filtersets.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm table = tables.RackReservationTable @@ -421,7 +631,6 @@ class RackReservationView(generic.ObjectView): class RackReservationEditView(generic.ObjectEditView): queryset = RackReservation.objects.all() model_form = forms.RackReservationForm - template_name = 'dcim/rackreservation_edit.html' def alter_obj(self, obj, request, args, kwargs): if not obj.pk: @@ -453,14 +662,14 @@ class RackReservationImportView(generic.BulkImportView): class RackReservationBulkEditView(generic.BulkEditView): queryset = RackReservation.objects.prefetch_related('rack', 'user') - filterset = filters.RackReservationFilterSet + filterset = filtersets.RackReservationFilterSet table = tables.RackReservationTable form = forms.RackReservationBulkEditForm class RackReservationBulkDeleteView(generic.BulkDeleteView): queryset = RackReservation.objects.prefetch_related('rack', 'user') - filterset = filters.RackReservationFilterSet + filterset = filtersets.RackReservationFilterSet table = tables.RackReservationTable @@ -477,6 +686,29 @@ class ManufacturerListView(generic.ObjectListView): table = tables.ManufacturerTable +class ManufacturerView(generic.ObjectView): + queryset = Manufacturer.objects.all() + + def get_extra_context(self, request, instance): + devicetypes = DeviceType.objects.restrict(request.user, 'view').filter( + manufacturer=instance + ).annotate( + instance_count=count_related(Device, 'device_type') + ) + inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter( + manufacturer=instance + ) + + devicetypes_table = tables.DeviceTypeTable(devicetypes) + devicetypes_table.columns.hide('manufacturer') + paginate_table(devicetypes_table, request) + + return { + 'devicetypes_table': devicetypes_table, + 'inventory_item_count': inventory_items.count(), + } + + class ManufacturerEditView(generic.ObjectEditView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm @@ -492,6 +724,15 @@ class ManufacturerBulkImportView(generic.BulkImportView): table = tables.ManufacturerTable +class ManufacturerBulkEditView(generic.BulkEditView): + queryset = Manufacturer.objects.annotate( + devicetype_count=count_related(DeviceType, 'manufacturer') + ) + filterset = filtersets.ManufacturerFilterSet + table = tables.ManufacturerTable + form = forms.ManufacturerBulkEditForm + + class ManufacturerBulkDeleteView(generic.BulkDeleteView): queryset = Manufacturer.objects.annotate( devicetype_count=count_related(DeviceType, 'manufacturer') @@ -507,7 +748,7 @@ class DeviceTypeListView(generic.ObjectListView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Device, 'device_type') ) - filterset = filters.DeviceTypeFilterSet + filterset = filtersets.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable @@ -577,7 +818,6 @@ class DeviceTypeView(generic.ObjectView): class DeviceTypeEditView(generic.ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm - template_name = 'dcim/devicetype_edit.html' class DeviceTypeDeleteView(generic.ObjectDeleteView): @@ -614,7 +854,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Device, 'device_type') ) - filterset = filters.DeviceTypeFilterSet + filterset = filtersets.DeviceTypeFilterSet table = tables.DeviceTypeTable form = forms.DeviceTypeBulkEditForm @@ -623,7 +863,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Device, 'device_type') ) - filterset = filters.DeviceTypeFilterSet + filterset = filtersets.DeviceTypeFilterSet table = tables.DeviceTypeTable @@ -919,6 +1159,25 @@ class DeviceRoleListView(generic.ObjectListView): table = tables.DeviceRoleTable +class DeviceRoleView(generic.ObjectView): + queryset = DeviceRole.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + device_role=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('device_role') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + 'device_count': Device.objects.filter(device_role=instance).count(), + 'virtualmachine_count': VirtualMachine.objects.filter(role=instance).count(), + } + + class DeviceRoleEditView(generic.ObjectEditView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm @@ -934,8 +1193,21 @@ class DeviceRoleBulkImportView(generic.BulkImportView): table = tables.DeviceRoleTable +class DeviceRoleBulkEditView(generic.BulkEditView): + queryset = DeviceRole.objects.annotate( + device_count=count_related(Device, 'device_role'), + vm_count=count_related(VirtualMachine, 'role') + ) + filterset = filtersets.DeviceRoleFilterSet + table = tables.DeviceRoleTable + form = forms.DeviceRoleBulkEditForm + + class DeviceRoleBulkDeleteView(generic.BulkDeleteView): - queryset = DeviceRole.objects.all() + queryset = DeviceRole.objects.annotate( + device_count=count_related(Device, 'device_role'), + vm_count=count_related(VirtualMachine, 'role') + ) table = tables.DeviceRoleTable @@ -951,6 +1223,23 @@ class PlatformListView(generic.ObjectListView): table = tables.PlatformTable +class PlatformView(generic.ObjectView): + queryset = Platform.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + platform=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('platform') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class PlatformEditView(generic.ObjectEditView): queryset = Platform.objects.all() model_form = forms.PlatformForm @@ -966,6 +1255,13 @@ class PlatformBulkImportView(generic.BulkImportView): table = tables.PlatformTable +class PlatformBulkEditView(generic.BulkEditView): + queryset = Platform.objects.all() + filterset = filtersets.PlatformFilterSet + table = tables.PlatformTable + form = forms.PlatformBulkEditForm + + class PlatformBulkDeleteView(generic.BulkDeleteView): queryset = Platform.objects.all() table = tables.PlatformTable @@ -977,7 +1273,7 @@ class PlatformBulkDeleteView(generic.BulkDeleteView): class DeviceListView(generic.ObjectListView): queryset = Device.objects.all() - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet filterset_form = forms.DeviceFilterForm table = tables.DeviceTable template_name = 'dcim/device_list.html' @@ -985,7 +1281,7 @@ class DeviceListView(generic.ObjectListView): class DeviceView(generic.ObjectView): queryset = Device.objects.prefetch_related( - 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' + 'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' ) def get_extra_context(self, request, instance): @@ -1031,11 +1327,11 @@ class DeviceConsolePortsView(generic.ObjectView): ) consoleport_table = tables.DeviceConsolePortTable( data=consoleports, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'): consoleport_table.columns.show('pk') + paginate_table(consoleport_table, request) return { 'consoleport_table': consoleport_table, @@ -1055,12 +1351,12 @@ class DeviceConsoleServerPortsView(generic.ObjectView): ) consoleserverport_table = tables.DeviceConsoleServerPortTable( data=consoleserverports, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_consoleserverport') or \ request.user.has_perm('dcim.delete_consoleserverport'): consoleserverport_table.columns.show('pk') + paginate_table(consoleserverport_table, request) return { 'consoleserverport_table': consoleserverport_table, @@ -1078,11 +1374,11 @@ class DevicePowerPortsView(generic.ObjectView): ) powerport_table = tables.DevicePowerPortTable( data=powerports, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'): powerport_table.columns.show('pk') + paginate_table(powerport_table, request) return { 'powerport_table': powerport_table, @@ -1100,11 +1396,11 @@ class DevicePowerOutletsView(generic.ObjectView): ) poweroutlet_table = tables.DevicePowerOutletTable( data=poweroutlets, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'): poweroutlet_table.columns.show('pk') + paginate_table(poweroutlet_table, request) return { 'poweroutlet_table': poweroutlet_table, @@ -1117,18 +1413,18 @@ class DeviceInterfacesView(generic.ObjectView): template_name = 'dcim/device/interfaces.html' def get_extra_context(self, request, instance): - interfaces = instance.vc_interfaces.restrict(request.user, 'view').prefetch_related( + interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), 'lag', 'cable', '_path__destination', 'tags', ) interface_table = tables.DeviceInterfaceTable( data=interfaces, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'): interface_table.columns.show('pk') + paginate_table(interface_table, request) return { 'interface_table': interface_table, @@ -1146,11 +1442,11 @@ class DeviceFrontPortsView(generic.ObjectView): ) frontport_table = tables.DeviceFrontPortTable( data=frontports, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'): frontport_table.columns.show('pk') + paginate_table(frontport_table, request) return { 'frontport_table': frontport_table, @@ -1166,11 +1462,11 @@ class DeviceRearPortsView(generic.ObjectView): rearports = RearPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related('cable') rearport_table = tables.DeviceRearPortTable( data=rearports, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'): rearport_table.columns.show('pk') + paginate_table(rearport_table, request) return { 'rearport_table': rearport_table, @@ -1188,11 +1484,11 @@ class DeviceDeviceBaysView(generic.ObjectView): ) devicebay_table = tables.DeviceDeviceBayTable( data=devicebays, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'): devicebay_table.columns.show('pk') + paginate_table(devicebay_table, request) return { 'devicebay_table': devicebay_table, @@ -1210,11 +1506,11 @@ class DeviceInventoryView(generic.ObjectView): ).prefetch_related('manufacturer') inventoryitem_table = tables.DeviceInventoryItemTable( data=inventoryitems, - user=request.user, - orderable=False + user=request.user ) if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'): inventoryitem_table.columns.show('pk') + paginate_table(inventoryitem_table, request) return { 'inventoryitem_table': inventoryitem_table, @@ -1239,7 +1535,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView): template_name = 'dcim/device/lldp_neighbors.html' def get_extra_context(self, request, instance): - interfaces = instance.vc_interfaces.restrict(request.user, 'view').prefetch_related( + interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( '_path__destination' ).exclude( type__in=NONCONNECTABLE_IFACE_TYPES @@ -1271,6 +1567,10 @@ class DeviceChangeLogView(ObjectChangeLogView): base_template = 'dcim/device/base.html' +class DeviceJournalView(ObjectJournalView): + base_template = 'dcim/device/base.html' + + class DeviceEditView(generic.ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm @@ -1308,14 +1608,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView): class DeviceBulkEditView(generic.BulkEditView): queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable form = forms.DeviceBulkEditForm class DeviceBulkDeleteView(generic.BulkDeleteView): queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable @@ -1325,7 +1625,7 @@ class DeviceBulkDeleteView(generic.BulkDeleteView): class ConsolePortListView(generic.ObjectListView): queryset = ConsolePort.objects.all() - filterset = filters.ConsolePortFilterSet + filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortTable action_buttons = ('import', 'export') @@ -1334,11 +1634,6 @@ class ConsolePortListView(generic.ObjectListView): class ConsolePortView(generic.ObjectView): queryset = ConsolePort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_consoleports' - } - class ConsolePortCreateView(generic.ComponentCreateView): queryset = ConsolePort.objects.all() @@ -1365,7 +1660,7 @@ class ConsolePortBulkImportView(generic.BulkImportView): class ConsolePortBulkEditView(generic.BulkEditView): queryset = ConsolePort.objects.all() - filterset = filters.ConsolePortFilterSet + filterset = filtersets.ConsolePortFilterSet table = tables.ConsolePortTable form = forms.ConsolePortBulkEditForm @@ -1380,7 +1675,7 @@ class ConsolePortBulkDisconnectView(BulkDisconnectView): class ConsolePortBulkDeleteView(generic.BulkDeleteView): queryset = ConsolePort.objects.all() - filterset = filters.ConsolePortFilterSet + filterset = filtersets.ConsolePortFilterSet table = tables.ConsolePortTable @@ -1390,7 +1685,7 @@ class ConsolePortBulkDeleteView(generic.BulkDeleteView): class ConsoleServerPortListView(generic.ObjectListView): queryset = ConsoleServerPort.objects.all() - filterset = filters.ConsoleServerPortFilterSet + filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortTable action_buttons = ('import', 'export') @@ -1399,11 +1694,6 @@ class ConsoleServerPortListView(generic.ObjectListView): class ConsoleServerPortView(generic.ObjectView): queryset = ConsoleServerPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_consoleserverports' - } - class ConsoleServerPortCreateView(generic.ComponentCreateView): queryset = ConsoleServerPort.objects.all() @@ -1430,7 +1720,7 @@ class ConsoleServerPortBulkImportView(generic.BulkImportView): class ConsoleServerPortBulkEditView(generic.BulkEditView): queryset = ConsoleServerPort.objects.all() - filterset = filters.ConsoleServerPortFilterSet + filterset = filtersets.ConsoleServerPortFilterSet table = tables.ConsoleServerPortTable form = forms.ConsoleServerPortBulkEditForm @@ -1445,7 +1735,7 @@ class ConsoleServerPortBulkDisconnectView(BulkDisconnectView): class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView): queryset = ConsoleServerPort.objects.all() - filterset = filters.ConsoleServerPortFilterSet + filterset = filtersets.ConsoleServerPortFilterSet table = tables.ConsoleServerPortTable @@ -1455,7 +1745,7 @@ class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView): class PowerPortListView(generic.ObjectListView): queryset = PowerPort.objects.all() - filterset = filters.PowerPortFilterSet + filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortTable action_buttons = ('import', 'export') @@ -1464,11 +1754,6 @@ class PowerPortListView(generic.ObjectListView): class PowerPortView(generic.ObjectView): queryset = PowerPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_powerports' - } - class PowerPortCreateView(generic.ComponentCreateView): queryset = PowerPort.objects.all() @@ -1495,7 +1780,7 @@ class PowerPortBulkImportView(generic.BulkImportView): class PowerPortBulkEditView(generic.BulkEditView): queryset = PowerPort.objects.all() - filterset = filters.PowerPortFilterSet + filterset = filtersets.PowerPortFilterSet table = tables.PowerPortTable form = forms.PowerPortBulkEditForm @@ -1510,7 +1795,7 @@ class PowerPortBulkDisconnectView(BulkDisconnectView): class PowerPortBulkDeleteView(generic.BulkDeleteView): queryset = PowerPort.objects.all() - filterset = filters.PowerPortFilterSet + filterset = filtersets.PowerPortFilterSet table = tables.PowerPortTable @@ -1520,7 +1805,7 @@ class PowerPortBulkDeleteView(generic.BulkDeleteView): class PowerOutletListView(generic.ObjectListView): queryset = PowerOutlet.objects.all() - filterset = filters.PowerOutletFilterSet + filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletTable action_buttons = ('import', 'export') @@ -1529,11 +1814,6 @@ class PowerOutletListView(generic.ObjectListView): class PowerOutletView(generic.ObjectView): queryset = PowerOutlet.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_poweroutlets' - } - class PowerOutletCreateView(generic.ComponentCreateView): queryset = PowerOutlet.objects.all() @@ -1560,7 +1840,7 @@ class PowerOutletBulkImportView(generic.BulkImportView): class PowerOutletBulkEditView(generic.BulkEditView): queryset = PowerOutlet.objects.all() - filterset = filters.PowerOutletFilterSet + filterset = filtersets.PowerOutletFilterSet table = tables.PowerOutletTable form = forms.PowerOutletBulkEditForm @@ -1575,7 +1855,7 @@ class PowerOutletBulkDisconnectView(BulkDisconnectView): class PowerOutletBulkDeleteView(generic.BulkDeleteView): queryset = PowerOutlet.objects.all() - filterset = filters.PowerOutletFilterSet + filterset = filtersets.PowerOutletFilterSet table = tables.PowerOutletTable @@ -1585,7 +1865,7 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView): class InterfaceListView(generic.ObjectListView): queryset = Interface.objects.all() - filterset = filters.InterfaceFilterSet + filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceTable action_buttons = ('import', 'export') @@ -1601,6 +1881,14 @@ class InterfaceView(generic.ObjectView): orderable=False ) + # Get child interfaces + child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) + child_interfaces_tables = tables.InterfaceTable( + child_interfaces, + orderable=False + ) + child_interfaces_tables.columns.hide('device') + # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] if instance.untagged_vlan is not None: @@ -1617,8 +1905,8 @@ class InterfaceView(generic.ObjectView): return { 'ipaddress_table': ipaddress_table, + 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, - 'breadcrumb_url': 'dcim:device_interfaces' } @@ -1647,7 +1935,7 @@ class InterfaceBulkImportView(generic.BulkImportView): class InterfaceBulkEditView(generic.BulkEditView): queryset = Interface.objects.all() - filterset = filters.InterfaceFilterSet + filterset = filtersets.InterfaceFilterSet table = tables.InterfaceTable form = forms.InterfaceBulkEditForm @@ -1662,7 +1950,7 @@ class InterfaceBulkDisconnectView(BulkDisconnectView): class InterfaceBulkDeleteView(generic.BulkDeleteView): queryset = Interface.objects.all() - filterset = filters.InterfaceFilterSet + filterset = filtersets.InterfaceFilterSet table = tables.InterfaceTable @@ -1672,7 +1960,7 @@ class InterfaceBulkDeleteView(generic.BulkDeleteView): class FrontPortListView(generic.ObjectListView): queryset = FrontPort.objects.all() - filterset = filters.FrontPortFilterSet + filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortTable action_buttons = ('import', 'export') @@ -1681,11 +1969,6 @@ class FrontPortListView(generic.ObjectListView): class FrontPortView(generic.ObjectView): queryset = FrontPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_frontports' - } - class FrontPortCreateView(generic.ComponentCreateView): queryset = FrontPort.objects.all() @@ -1712,7 +1995,7 @@ class FrontPortBulkImportView(generic.BulkImportView): class FrontPortBulkEditView(generic.BulkEditView): queryset = FrontPort.objects.all() - filterset = filters.FrontPortFilterSet + filterset = filtersets.FrontPortFilterSet table = tables.FrontPortTable form = forms.FrontPortBulkEditForm @@ -1727,7 +2010,7 @@ class FrontPortBulkDisconnectView(BulkDisconnectView): class FrontPortBulkDeleteView(generic.BulkDeleteView): queryset = FrontPort.objects.all() - filterset = filters.FrontPortFilterSet + filterset = filtersets.FrontPortFilterSet table = tables.FrontPortTable @@ -1737,7 +2020,7 @@ class FrontPortBulkDeleteView(generic.BulkDeleteView): class RearPortListView(generic.ObjectListView): queryset = RearPort.objects.all() - filterset = filters.RearPortFilterSet + filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortTable action_buttons = ('import', 'export') @@ -1746,11 +2029,6 @@ class RearPortListView(generic.ObjectListView): class RearPortView(generic.ObjectView): queryset = RearPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_rearports' - } - class RearPortCreateView(generic.ComponentCreateView): queryset = RearPort.objects.all() @@ -1777,7 +2055,7 @@ class RearPortBulkImportView(generic.BulkImportView): class RearPortBulkEditView(generic.BulkEditView): queryset = RearPort.objects.all() - filterset = filters.RearPortFilterSet + filterset = filtersets.RearPortFilterSet table = tables.RearPortTable form = forms.RearPortBulkEditForm @@ -1792,7 +2070,7 @@ class RearPortBulkDisconnectView(BulkDisconnectView): class RearPortBulkDeleteView(generic.BulkDeleteView): queryset = RearPort.objects.all() - filterset = filters.RearPortFilterSet + filterset = filtersets.RearPortFilterSet table = tables.RearPortTable @@ -1802,7 +2080,7 @@ class RearPortBulkDeleteView(generic.BulkDeleteView): class DeviceBayListView(generic.ObjectListView): queryset = DeviceBay.objects.all() - filterset = filters.DeviceBayFilterSet + filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayTable action_buttons = ('import', 'export') @@ -1811,11 +2089,6 @@ class DeviceBayListView(generic.ObjectListView): class DeviceBayView(generic.ObjectView): queryset = DeviceBay.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_devicebays' - } - class DeviceBayCreateView(generic.ComponentCreateView): queryset = DeviceBay.objects.all() @@ -1907,7 +2180,7 @@ class DeviceBayBulkImportView(generic.BulkImportView): class DeviceBayBulkEditView(generic.BulkEditView): queryset = DeviceBay.objects.all() - filterset = filters.DeviceBayFilterSet + filterset = filtersets.DeviceBayFilterSet table = tables.DeviceBayTable form = forms.DeviceBayBulkEditForm @@ -1918,7 +2191,7 @@ class DeviceBayBulkRenameView(generic.BulkRenameView): class DeviceBayBulkDeleteView(generic.BulkDeleteView): queryset = DeviceBay.objects.all() - filterset = filters.DeviceBayFilterSet + filterset = filtersets.DeviceBayFilterSet table = tables.DeviceBayTable @@ -1928,7 +2201,7 @@ class DeviceBayBulkDeleteView(generic.BulkDeleteView): class InventoryItemListView(generic.ObjectListView): queryset = InventoryItem.objects.all() - filterset = filters.InventoryItemFilterSet + filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable action_buttons = ('import', 'export') @@ -1937,11 +2210,6 @@ class InventoryItemListView(generic.ObjectListView): class InventoryItemView(generic.ObjectView): queryset = InventoryItem.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_inventory' - } - class InventoryItemEditView(generic.ObjectEditView): queryset = InventoryItem.objects.all() @@ -1967,7 +2235,7 @@ class InventoryItemBulkImportView(generic.BulkImportView): class InventoryItemBulkEditView(generic.BulkEditView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') - filterset = filters.InventoryItemFilterSet + filterset = filtersets.InventoryItemFilterSet table = tables.InventoryItemTable form = forms.InventoryItemBulkEditForm @@ -1992,7 +2260,7 @@ class DeviceBulkAddConsolePortView(generic.BulkComponentCreateView): form = forms.ConsolePortBulkCreateForm queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -2003,7 +2271,7 @@ class DeviceBulkAddConsoleServerPortView(generic.BulkComponentCreateView): form = forms.ConsoleServerPortBulkCreateForm queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -2014,7 +2282,7 @@ class DeviceBulkAddPowerPortView(generic.BulkComponentCreateView): form = forms.PowerPortBulkCreateForm queryset = PowerPort.objects.all() model_form = forms.PowerPortForm - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -2025,7 +2293,7 @@ class DeviceBulkAddPowerOutletView(generic.BulkComponentCreateView): form = forms.PowerOutletBulkCreateForm queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -2036,7 +2304,7 @@ class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView): form = forms.InterfaceBulkCreateForm queryset = Interface.objects.all() model_form = forms.InterfaceForm - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -2047,7 +2315,7 @@ class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView): # form = forms.FrontPortBulkCreateForm # queryset = FrontPort.objects.all() # model_form = forms.FrontPortForm -# filterset = filters.DeviceFilterSet +# filterset = filtersets.DeviceFilterSet # table = tables.DeviceTable # default_return_url = 'dcim:device_list' @@ -2058,7 +2326,7 @@ class DeviceBulkAddRearPortView(generic.BulkComponentCreateView): form = forms.RearPortBulkCreateForm queryset = RearPort.objects.all() model_form = forms.RearPortForm - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -2069,7 +2337,7 @@ class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView): form = forms.DeviceBayBulkCreateForm queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -2080,7 +2348,7 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView): form = forms.InventoryItemBulkCreateForm queryset = InventoryItem.objects.all() model_form = forms.InventoryItemForm - filterset = filters.DeviceFilterSet + filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -2091,7 +2359,7 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView): class CableListView(generic.ObjectListView): queryset = Cable.objects.all() - filterset = filters.CableFilterSet + filterset = filtersets.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable action_buttons = ('import', 'export') @@ -2134,10 +2402,14 @@ class PathTraceView(generic.ObjectView): else: path = related_paths.first() + # Get the total length of the cable and whether the length is definitive (fully defined) + total_length, is_definitive = path.get_total_length() if path else (None, False) + return { 'path': path, 'related_paths': related_paths, - 'total_length': path.get_total_length() if path else None, + 'total_length': total_length, + 'is_definitive': is_definitive } @@ -2181,13 +2453,15 @@ class CableCreateView(generic.ObjectEditView): initial_data = {k: request.GET[k] for k in request.GET} # Set initial site and rack based on side A termination (if not already set) - termination_a_site = getattr(obj.termination_a.parent, 'site', None) + termination_a_site = getattr(obj.termination_a.parent_object, 'site', None) if termination_a_site and 'termination_b_region' not in initial_data: initial_data['termination_b_region'] = termination_a_site.region + if termination_a_site and 'termination_b_site_group' not in initial_data: + initial_data['termination_b_site_group'] = termination_a_site.group if 'termination_b_site' not in initial_data: initial_data['termination_b_site'] = termination_a_site if 'termination_b_rack' not in initial_data: - initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None) + initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None) form = self.model_form(instance=obj, initial=initial_data) @@ -2218,14 +2492,14 @@ class CableBulkImportView(generic.BulkImportView): class CableBulkEditView(generic.BulkEditView): queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') - filterset = filters.CableFilterSet + filterset = filtersets.CableFilterSet table = tables.CableTable form = forms.CableBulkEditForm class CableBulkDeleteView(generic.BulkDeleteView): queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') - filterset = filters.CableFilterSet + filterset = filtersets.CableFilterSet table = tables.CableTable @@ -2235,7 +2509,7 @@ class CableBulkDeleteView(generic.BulkDeleteView): class ConsoleConnectionsListView(generic.ObjectListView): queryset = ConsolePort.objects.filter(_path__isnull=False).order_by('device') - filterset = filters.ConsoleConnectionFilterSet + filterset = filtersets.ConsoleConnectionFilterSet filterset_form = forms.ConsoleConnectionFilterForm table = tables.ConsoleConnectionTable template_name = 'dcim/connections_list.html' @@ -2265,7 +2539,7 @@ class ConsoleConnectionsListView(generic.ObjectListView): class PowerConnectionsListView(generic.ObjectListView): queryset = PowerPort.objects.filter(_path__isnull=False).order_by('device') - filterset = filters.PowerConnectionFilterSet + filterset = filtersets.PowerConnectionFilterSet filterset_form = forms.PowerConnectionFilterForm table = tables.PowerConnectionTable template_name = 'dcim/connections_list.html' @@ -2294,12 +2568,8 @@ class PowerConnectionsListView(generic.ObjectListView): class InterfaceConnectionsListView(generic.ObjectListView): - queryset = Interface.objects.filter( - # Avoid duplicate connections by only selecting the lower PK in a connected pair - _path__isnull=False, - pk__lt=F('_path__destination_id') - ).order_by('device') - filterset = filters.InterfaceConnectionFilterSet + queryset = Interface.objects.filter(_path__isnull=False).order_by('device') + filterset = filtersets.InterfaceConnectionFilterSet filterset_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable template_name = 'dcim/connections_list.html' @@ -2338,7 +2608,7 @@ class VirtualChassisListView(generic.ObjectListView): member_count=count_related(Device, 'virtual_chassis') ) table = tables.VirtualChassisTable - filterset = filters.VirtualChassisFilterSet + filterset = filtersets.VirtualChassisFilterSet filterset_form = forms.VirtualChassisFilterForm @@ -2546,14 +2816,14 @@ class VirtualChassisBulkImportView(generic.BulkImportView): class VirtualChassisBulkEditView(generic.BulkEditView): queryset = VirtualChassis.objects.all() - filterset = filters.VirtualChassisFilterSet + filterset = filtersets.VirtualChassisFilterSet table = tables.VirtualChassisTable form = forms.VirtualChassisBulkEditForm class VirtualChassisBulkDeleteView(generic.BulkDeleteView): queryset = VirtualChassis.objects.all() - filterset = filters.VirtualChassisFilterSet + filterset = filtersets.VirtualChassisFilterSet table = tables.VirtualChassisTable @@ -2563,17 +2833,17 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView): class PowerPanelListView(generic.ObjectListView): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) - filterset = filters.PowerPanelFilterSet + filterset = filtersets.PowerPanelFilterSet filterset_form = forms.PowerPanelFilterForm table = tables.PowerPanelTable class PowerPanelView(generic.ObjectView): - queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') + queryset = PowerPanel.objects.prefetch_related('site', 'location') def get_extra_context(self, request, instance): power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack') @@ -2581,6 +2851,8 @@ class PowerPanelView(generic.ObjectView): data=power_feeds, orderable=False ) + if request.user.has_perm('dcim.delete_cable'): + powerfeed_table.columns.show('pk') powerfeed_table.exclude = ['power_panel'] return { @@ -2591,7 +2863,6 @@ class PowerPanelView(generic.ObjectView): class PowerPanelEditView(generic.ObjectEditView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelForm - template_name = 'dcim/powerpanel_edit.html' class PowerPanelDeleteView(generic.ObjectDeleteView): @@ -2605,19 +2876,19 @@ class PowerPanelBulkImportView(generic.BulkImportView): class PowerPanelBulkEditView(generic.BulkEditView): - queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') - filterset = filters.PowerPanelFilterSet + queryset = PowerPanel.objects.prefetch_related('site', 'location') + filterset = filtersets.PowerPanelFilterSet table = tables.PowerPanelTable form = forms.PowerPanelBulkEditForm class PowerPanelBulkDeleteView(generic.BulkDeleteView): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) - filterset = filters.PowerPanelFilterSet + filterset = filtersets.PowerPanelFilterSet table = tables.PowerPanelTable @@ -2627,7 +2898,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView): class PowerFeedListView(generic.ObjectListView): queryset = PowerFeed.objects.all() - filterset = filters.PowerFeedFilterSet + filterset = filtersets.PowerFeedFilterSet filterset_form = forms.PowerFeedFilterForm table = tables.PowerFeedTable @@ -2639,7 +2910,6 @@ class PowerFeedView(generic.ObjectView): class PowerFeedEditView(generic.ObjectEditView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedForm - template_name = 'dcim/powerfeed_edit.html' class PowerFeedDeleteView(generic.ObjectDeleteView): @@ -2654,12 +2924,16 @@ class PowerFeedBulkImportView(generic.BulkImportView): class PowerFeedBulkEditView(generic.BulkEditView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') - filterset = filters.PowerFeedFilterSet + filterset = filtersets.PowerFeedFilterSet table = tables.PowerFeedTable form = forms.PowerFeedBulkEditForm +class PowerFeedBulkDisconnectView(BulkDisconnectView): + queryset = PowerFeed.objects.all() + + class PowerFeedBulkDeleteView(generic.BulkDeleteView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') - filterset = filters.PowerFeedFilterSet + filterset = filtersets.PowerFeedFilterSet table = tables.PowerFeedTable diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index a4786610d..0ceb1cc5b 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,16 +1,12 @@ from django import forms from django.contrib import admin +from django.contrib.contenttypes.models import ContentType +from django.utils.safestring import mark_safe -from utilities.forms import LaxURLField +from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField +from utilities.utils import content_type_name from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook - - -def order_content_types(field): - """ - Order the list of available ContentTypes by application - """ - queryset = field.queryset.order_by('app_label', 'model') - field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] +from .utils import FeatureQuery # @@ -18,6 +14,10 @@ def order_content_types(field): # class WebhookForm(forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('webhooks') + ) payload_url = LaxURLField( label='URL' ) @@ -26,12 +26,6 @@ class WebhookForm(forms.ModelForm): model = Webhook exclude = () - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if 'content_types' in self.fields: - order_content_types(self.fields['content_types']) - @admin.register(Webhook) class WebhookAdmin(admin.ModelAdmin): @@ -70,6 +64,10 @@ class WebhookAdmin(admin.ModelAdmin): # class CustomFieldForm(forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) class Meta: model = CustomField @@ -84,11 +82,6 @@ class CustomFieldForm(forms.ModelForm): ) } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - order_content_types(self.fields['content_types']) - @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): @@ -119,7 +112,8 @@ class CustomFieldAdmin(admin.ModelAdmin): ) def models(self, obj): - return ', '.join([ct.name for ct in obj.content_types.all()]) + ct_names = [content_type_name(ct) for ct in obj.content_types.all()] + return mark_safe('
'.join(ct_names)) # @@ -127,29 +121,26 @@ class CustomFieldAdmin(admin.ModelAdmin): # class CustomLinkForm(forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) class Meta: model = CustomLink exclude = [] widgets = { - 'text': forms.Textarea, - 'url': forms.Textarea, + 'link_text': forms.Textarea, + 'link_url': forms.Textarea, } help_texts = { 'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear ' 'first in a list.', - 'text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. Links ' - 'which render as empty text will not be displayed.', - 'url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', + 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. ' + 'Links which render as empty text will not be displayed.', + 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Format ContentType choices - order_content_types(self.fields['content_type']) - self.fields['content_type'].choices.insert(0, ('', '---------')) - @admin.register(CustomLink) class CustomLinkAdmin(admin.ModelAdmin): @@ -158,7 +149,7 @@ class CustomLinkAdmin(admin.ModelAdmin): 'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window') }), ('Templates', { - 'fields': ('text', 'url'), + 'fields': ('link_text', 'link_url'), 'classes': ('monospace',) }) ) @@ -176,24 +167,21 @@ class CustomLinkAdmin(admin.ModelAdmin): # class ExportTemplateForm(forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) class Meta: model = ExportTemplate exclude = [] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Format ContentType choices - order_content_types(self.fields['content_type']) - self.fields['content_type'].choices.insert(0, ('', '---------')) - @admin.register(ExportTemplate) class ExportTemplateAdmin(admin.ModelAdmin): fieldsets = ( ('Export Template', { - 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension') + 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment') }), ('Content', { 'fields': ('template_code',), @@ -201,7 +189,7 @@ class ExportTemplateAdmin(admin.ModelAdmin): }) ) list_display = [ - 'name', 'content_type', 'description', 'mime_type', 'file_extension', + 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', ] list_filter = [ 'content_type', diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index c8c4ba89e..5cb1fc276 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,9 +1,7 @@ from django.contrib.contenttypes.models import ContentType -from rest_framework.fields import CreateOnlyDefault, Field +from rest_framework.fields import Field -from extras.choices import * from extras.models import CustomField -from netbox.api import ValidatedModelSerializer # @@ -56,34 +54,3 @@ class CustomFieldsDataField(Field): data = {**self.parent.instance.custom_field_data, **data} return data - - -class CustomFieldModelSerializer(ValidatedModelSerializer): - """ - Extends ModelSerializer to render any CustomFields and their values associated with an object. - """ - custom_fields = CustomFieldsDataField( - source='custom_field_data', - default=CreateOnlyDefault(CustomFieldDefaultValues()) - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.instance is not None: - - # Retrieve the set of CustomFields which apply to this type of object - content_type = ContentType.objects.get_for_model(self.Meta.model) - fields = CustomField.objects.filter(content_types=content_type) - - # Populate CustomFieldValues for each instance from database - if type(self.instance) in (list, tuple): - for obj in self.instance: - self._populate_custom_fields(obj, fields) - else: - self._populate_custom_fields(self.instance, fields) - - def _populate_custom_fields(self, instance, custom_fields): - instance.custom_fields = {} - for field in custom_fields: - instance.custom_fields[field.name] = instance.cf.get(field.name) diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 5635f401b..4acde31ab 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -2,24 +2,44 @@ from rest_framework import serializers from extras import choices, models from netbox.api import ChoiceField, WritableNestedSerializer +from netbox.api.serializers import NestedTagSerializer from users.api.nested_serializers import NestedUserSerializer __all__ = [ 'NestedConfigContextSerializer', 'NestedCustomFieldSerializer', + 'NestedCustomLinkSerializer', 'NestedExportTemplateSerializer', 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', - 'NestedTagSerializer', + 'NestedJournalEntrySerializer', + 'NestedTagSerializer', # Defined in netbox.api.serializers + 'NestedWebhookSerializer', ] +class NestedWebhookSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') + + class Meta: + model = models.Webhook + fields = ['id', 'url', 'display', 'name'] + + class NestedCustomFieldSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') class Meta: model = models.CustomField - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] + + +class NestedCustomLinkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') + + class Meta: + model = models.CustomLink + fields = ['id', 'url', 'display', 'name'] class NestedConfigContextSerializer(WritableNestedSerializer): @@ -27,7 +47,7 @@ class NestedConfigContextSerializer(WritableNestedSerializer): class Meta: model = models.ConfigContext - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedExportTemplateSerializer(WritableNestedSerializer): @@ -35,7 +55,7 @@ class NestedExportTemplateSerializer(WritableNestedSerializer): class Meta: model = models.ExportTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedImageAttachmentSerializer(WritableNestedSerializer): @@ -43,15 +63,15 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer): class Meta: model = models.ImageAttachment - fields = ['id', 'url', 'name', 'image'] + fields = ['id', 'url', 'display', 'name', 'image'] -class NestedTagSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') +class NestedJournalEntrySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') class Meta: - model = models.Tag - fields = ['id', 'url', 'name', 'slug', 'color'] + model = models.JournalEntry + fields = ['id', 'url', 'display', 'created'] class NestedJobResultSerializer(serializers.ModelSerializer): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a85ca05b7..66627bfbc 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -4,17 +4,16 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, - NestedRegionSerializer, NestedSiteSerializer, + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, + NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup from extras.choices import * -from extras.models import ( - ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, -) +from extras.models import * from extras.utils import FeatureQuery -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.exceptions import SerializerNotFound +from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer @@ -23,6 +22,46 @@ from virtualization.api.nested_serializers import NestedClusterGroupSerializer, from virtualization.models import Cluster, ClusterGroup from .nested_serializers import * +__all__ = ( + 'ConfigContextSerializer', + 'ContentTypeSerializer', + 'CustomFieldSerializer', + 'CustomLinkSerializer', + 'ExportTemplateSerializer', + 'ImageAttachmentSerializer', + 'JobResultSerializer', + 'ObjectChangeSerializer', + 'ReportDetailSerializer', + 'ReportSerializer', + 'ScriptDetailSerializer', + 'ScriptInputSerializer', + 'ScriptLogMessageSerializer', + 'ScriptOutputSerializer', + 'ScriptSerializer', + 'TagSerializer', + 'WebhookSerializer', +) + + +# +# Webhooks +# + +class WebhookSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') + content_types = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), + many=True + ) + + class Meta: + model = Webhook + fields = [ + 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', + 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', + 'ssl_verification', 'ca_file_path', + ] + # # Custom fields @@ -40,11 +79,29 @@ class CustomFieldSerializer(ValidatedModelSerializer): class Meta: model = CustomField fields = [ - 'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', + 'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', ] +# +# Custom links +# + +class CustomLinkSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') + content_type = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()) + ) + + class Meta: + model = CustomLink + fields = [ + 'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', + 'button_class', 'new_window', + ] + + # # Export templates # @@ -57,7 +114,10 @@ class ExportTemplateSerializer(ValidatedModelSerializer): class Meta: model = ExportTemplate - fields = ['id', 'url', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension'] + fields = [ + 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', + 'file_extension', 'as_attachment', + ] # @@ -70,39 +130,7 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag - fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items'] - - -class TaggedObjectSerializer(serializers.Serializer): - tags = NestedTagSerializer(many=True, required=False) - - def create(self, validated_data): - tags = validated_data.pop('tags', None) - instance = super().create(validated_data) - - if tags is not None: - return self._save_tags(instance, tags) - return instance - - def update(self, instance, validated_data): - tags = validated_data.pop('tags', None) - - # Cache tags on instance for change logging - instance._tags = tags or [] - - instance = super().update(instance, validated_data) - - if tags is not None: - return self._save_tags(instance, tags) - return instance - - def _save_tags(self, instance, tags): - if tags: - instance.tags.set(*[t.name for t in tags]) - else: - instance.tags.clear() - - return instance + fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items'] # @@ -119,8 +147,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): class Meta: model = ImageAttachment fields = [ - 'id', 'url', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', - 'created', + 'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', + 'image_width', 'created', ] def validate(self, data): @@ -154,6 +182,51 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): return serializer(obj.parent, context={'request': self.context['request']}).data +# +# Journal entries +# + +class JournalEntrySerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + kind = ChoiceField( + choices=JournalEntryKindChoices, + required=False + ) + + class Meta: + model = JournalEntry + fields = [ + 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', + 'created_by', 'kind', 'comments', + ] + + def validate(self, data): + + # Validate that the parent object exists + if 'assigned_object_type' in data and 'assigned_object_id' in data: + try: + data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}" + ) + + # Enforce model validation + super().validate(data) + + return data + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, instance): + serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested') + context = {'request': self.context['request']} + return serializer(instance.assigned_object, context=context).data + + # # Config contexts # @@ -166,12 +239,24 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + site_groups = SerializedPKRelatedField( + queryset=SiteGroup.objects.all(), + serializer=NestedSiteGroupSerializer, + required=False, + many=True + ) sites = SerializedPKRelatedField( queryset=Site.objects.all(), serializer=NestedSiteSerializer, required=False, many=True ) + device_types = SerializedPKRelatedField( + queryset=DeviceType.objects.all(), + serializer=NestedDeviceTypeSerializer, + required=False, + many=True + ) roles = SerializedPKRelatedField( queryset=DeviceRole.objects.all(), serializer=NestedDeviceRoleSerializer, @@ -218,8 +303,9 @@ class ConfigContextSerializer(ValidatedModelSerializer): class Meta: model = ConfigContext fields = [ - 'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', - 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', + 'device_types', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', + 'data', 'created', 'last_updated', ] @@ -227,7 +313,7 @@ class ConfigContextSerializer(ValidatedModelSerializer): # Job Results # -class JobResultSerializer(serializers.ModelSerializer): +class JobResultSerializer(BaseModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail') user = NestedUserSerializer( read_only=True @@ -240,7 +326,7 @@ class JobResultSerializer(serializers.ModelSerializer): class Meta: model = JobResult fields = [ - 'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', + 'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', ] @@ -318,7 +404,7 @@ class ScriptOutputSerializer(serializers.Serializer): # Change logging # -class ObjectChangeSerializer(serializers.ModelSerializer): +class ObjectChangeSerializer(BaseModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail') user = NestedUserSerializer( read_only=True @@ -337,8 +423,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer): class Meta: model = ObjectChange fields = [ - 'id', 'url', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', - 'changed_object_id', 'changed_object', 'object_data', + 'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', + 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -365,13 +451,13 @@ class ObjectChangeSerializer(serializers.ModelSerializer): # ContentTypes # -class ContentTypeSerializer(serializers.ModelSerializer): +class ContentTypeSerializer(BaseModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail') display_name = serializers.SerializerMethodField() class Meta: model = ContentType - fields = ['id', 'url', 'app_label', 'model', 'display_name'] + fields = ['id', 'url', 'display', 'app_label', 'model', 'display_name'] @swagger_serializer_method(serializer_or_field=serializers.CharField) def get_display_name(self, obj): diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index da62b3d72..565f2cdc7 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,9 +5,15 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.ExtrasRootView +# Webhooks +router.register('webhooks', views.WebhookViewSet) + # Custom fields router.register('custom-fields', views.CustomFieldViewSet) +# Custom links +router.register('custom-links', views.CustomLinkViewSet) + # Export templates router.register('export-templates', views.ExportTemplateViewSet) @@ -17,6 +23,9 @@ router.register('tags', views.TagViewSet) # Image attachments router.register('image-attachments', views.ImageAttachmentViewSet) +# Journal entries +router.register('journal-entries', views.JournalEntryViewSet) + # Config contexts router.register('config-contexts', views.ConfigContextViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1067ac0d3..fbeba8328 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -9,11 +9,9 @@ from rest_framework.routers import APIRootView from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from rq import Worker -from extras import filters +from extras import filtersets from extras.choices import JobResultStatusChoices -from extras.models import ( - ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem, -) +from extras.models import * from extras.models import CustomField from extras.reports import get_report, get_reports, run_report from extras.scripts import get_script, get_scripts, run_script @@ -55,6 +53,17 @@ class ConfigContextQuerySetMixin: return queryset.annotate_config_context_data() +# +# Webhooks +# + +class WebhookViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata + queryset = Webhook.objects.all() + serializer_class = serializers.WebhookSerializer + filterset_class = filtersets.WebhookFilterSet + + # # Custom fields # @@ -63,7 +72,7 @@ class CustomFieldViewSet(ModelViewSet): metadata_class = ContentTypeMetadata queryset = CustomField.objects.all() serializer_class = serializers.CustomFieldSerializer - filterset_class = filters.CustomFieldFilterSet + filterset_class = filtersets.CustomFieldFilterSet class CustomFieldModelViewSet(ModelViewSet): @@ -84,6 +93,17 @@ class CustomFieldModelViewSet(ModelViewSet): return context +# +# Custom links +# + +class CustomLinkViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata + queryset = CustomLink.objects.all() + serializer_class = serializers.CustomLinkSerializer + filterset_class = filtersets.CustomLinkFilterSet + + # # Export templates # @@ -92,7 +112,7 @@ class ExportTemplateViewSet(ModelViewSet): metadata_class = ContentTypeMetadata queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer - filterset_class = filters.ExportTemplateFilterSet + filterset_class = filtersets.ExportTemplateFilterSet # @@ -104,7 +124,7 @@ class TagViewSet(ModelViewSet): tagged_items=count_related(TaggedItem, 'tag') ) serializer_class = serializers.TagSerializer - filterset_class = filters.TagFilterSet + filterset_class = filtersets.TagFilterSet # @@ -115,7 +135,18 @@ class ImageAttachmentViewSet(ModelViewSet): metadata_class = ContentTypeMetadata queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer - filterset_class = filters.ImageAttachmentFilterSet + filterset_class = filtersets.ImageAttachmentFilterSet + + +# +# Journal entries +# + +class JournalEntryViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata + queryset = JournalEntry.objects.all() + serializer_class = serializers.JournalEntrySerializer + filterset_class = filtersets.JournalEntryFilterSet # @@ -124,10 +155,10 @@ class ImageAttachmentViewSet(ModelViewSet): class ConfigContextViewSet(ModelViewSet): queryset = ConfigContext.objects.prefetch_related( - 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', + 'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer - filterset_class = filters.ConfigContextFilterSet + filterset_class = filtersets.ConfigContextFilterSet # @@ -208,7 +239,7 @@ class ReportViewSet(ViewSet): Run a Report identified as ". -{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 43dba8438..297e46798 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -1,97 +1,58 @@ -{% extends 'base.html' %} +{% extends 'generic/object_edit.html' %} {% load static %} {% load form_helpers %} -{% block content %} -
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
-
-

{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}

- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
Location
-
-
- -
-

{{ obj.circuit.provider }}

-
-
-
- -
-

{{ obj.circuit.cid }}

-
-
-
- -
-

{{ form.term_side.value }}

-
-
- {% render_field form.region %} - {% render_field form.site %} -
-
-
-
Termination Details
-
-
- -
-
- {{ form.port_speed }} - {% include 'circuits/inc/speed_widget.html' with target_field='port_speed' %} -
- {{ form.port_speed.help_text }} -
-
-
- -
-
- {{ form.upstream_speed }} - {% include 'circuits/inc/speed_widget.html' with target_field='upstream_speed' %} -
- {{ form.upstream_speed.help_text }} -
-
- {% render_field form.xconnect_id %} - {% render_field form.pp_info %} - {% render_field form.description %} -
-
-
-
-
-
- {% if obj.pk %} - - {% else %} - - {% endif %} - Cancel -
-
-
-{% endblock %} +{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %} -{% block javascript %} - +{% block form %} +
+
Termination
+
+
+ +
+

{{ obj.circuit.provider }}

+
+
+
+ +
+

{{ obj.circuit.cid }}

+
+
+
+ +
+

{{ form.term_side.value }}

+
+
+ {% render_field form.mark_connected %} + {% with providernetwork_tab_active=form.initial.provider_network %} + +
+
+ {% render_field form.region %} + {% render_field form.site_group %} + {% render_field form.site %} +
+
+ {% render_field form.provider_network %} +
+
+ {% endwith %} +
+
+
+
Termination Details
+
+ {% render_field form.port_speed %} + {% render_field form.upstream_speed %} + {% render_field form.xconnect_id %} + {% render_field form.pp_info %} + {% render_field form.description %} +
+
{% endblock %} diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html new file mode 100644 index 000000000..aee7bf944 --- /dev/null +++ b/netbox/templates/circuits/circuittype.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Circuit Types
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Circuit Type +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Circuits + {{ circuits_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Circuits +
    + {% include 'inc/table.html' with table=circuits_table %} + {% if perms.circuits.add_circuit %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 477788931..2832f5cc1 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -26,59 +26,66 @@ {% if termination %} - - - - - - - + + + + + + - + + + {% else %} + + + + + {% endif %} - - - - diff --git a/netbox/templates/circuits/inc/speed_widget.html b/netbox/templates/circuits/inc/speed_widget.html deleted file mode 100644 index 988418945..000000000 --- a/netbox/templates/circuits/inc/speed_widget.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 8778c3ac2..718d7f65e 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -1,65 +1,16 @@ -{% extends 'base.html' %} -{% load buttons %} +{% extends 'generic/object.html' %} {% load static %} -{% load custom_links %} {% load helpers %} {% load plugins %} -{% block title %}{{ object }}{% endblock %} - -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if perms.circuits.add_provider %} - {% clone_button object %} - {% endif %} - {% if perms.circuits.change_provider %} - {% edit_button object %} - {% endif %} - {% if perms.circuits.delete_provider %} - {% delete_button object %} - {% endif %} -
    -

    {{ object }}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Providers
  • +
  • {{ object }}
  • {% endblock %} {% block content %}
    -
    +
    Provider @@ -94,11 +45,14 @@
    Site - {% if termination.site.region %} - {{ termination.site.region }} / - {% endif %} - {{ termination.site }} -
    Termination - {% if termination.cable %} - {% if perms.dcim.delete_cable %} - + {% if termination.site %} +
    Site + {% if termination.site.region %} + {{ termination.site.region }} / {% endif %} - {{ termination.cable }} - - - - {% with peer=termination.get_cable_peer %} - to - {% if peer.device %} - {{ peer.device }} - {% elif peer.circuit %} - {{ peer.circuit }} + {{ termination.site }} +
    Termination + {% if termination.mark_connected %} + + Marked as connected + {% elif termination.cable %} + {% if perms.dcim.delete_cable %} + {% endif %} - ({{ peer }}) - {% endwith %} - {% else %} - {% if perms.dcim.add_cable %} -
    - - - - -
    + {{ termination.cable }} + + + + {% with peer=termination.get_cable_peer %} + to {{ peer.parent_object }} + / {% if peer.get_absolute_url %}{{ peer }}{% else %}{{ peer }}{% endif %} + {% endwith %} + {% else %} + {% if perms.dcim.add_cable %} +
    + + + + +
    + {% endif %} + Not defined {% endif %} - Not defined - {% endif %} -
    Provider Network + {{ termination.provider_network }} +
    Speed @@ -92,21 +99,6 @@ {% endif %}
    IP Addressing - {% if termination.connected_endpoint %} - {% for ip in termination.ip_addresses %} - {% if not forloop.first %}
    {% endif %} - {{ ip }} ({{ ip.vrf|default:"Global" }}) - {% empty %} - None - {% endfor %} - {% else %} - - {% endif %} -
    Cross-Connect {{ termination.xconnect_id|placeholder }}
    Circuits - {{ circuits_table.rows|length }} + {{ circuits_table.rows|length }}
    + {% plugin_left_page object %} + +
    {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
    @@ -113,9 +67,11 @@ {% endif %}
    - {% plugin_left_page object %} - -
    + {% plugin_right_page object %} +
    + +
    +
    Circuits @@ -129,8 +85,7 @@
    {% endif %}
    - {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} - {% plugin_right_page object %} + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
    diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html deleted file mode 100644 index ee0a3a285..000000000 --- a/netbox/templates/circuits/provider_edit.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Provider
    -
    - {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.asn %} -
    -
    -
    -
    Support Info
    -
    - {% render_field form.account %} - {% render_field form.portal_url %} - {% render_field form.noc_contact %} - {% render_field form.admin_contact %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html new file mode 100644 index 000000000..22ab3a7c0 --- /dev/null +++ b/netbox/templates/circuits/providernetwork.html @@ -0,0 +1,72 @@ +{% extends 'generic/object.html' %} +{% load static %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Provider Networks
  • +
  • {{ object.provider }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Provider Network +
    + + + + + + + + + + + + + +
    Provider + {{ object.provider }} +
    Name{{ object.name }}
    Description{{ object.description }}
    +
    + {% plugin_left_page object %} +
    +
    +
    +
    + Comments +
    +
    + {% if object.comments %} + {{ object.comments|render_markdown }} + {% else %} + None + {% endif %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:providernetwork_list' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Circuits +
    + {% include 'inc/table.html' with table=circuits_table %} +
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 3bc7869fe..a69bac6d6 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -1,42 +1,21 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} -{% load custom_links %} {% load helpers %} +{% load perms %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_cable %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_cable %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}Cable {{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Cables
  • +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + {% if request.user|can_change:object %} + {% edit_button object %} + {% endif %} + {% if request.user|can_delete:object %} + {% delete_button object %} + {% endif %} {% endblock %} {% block content %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index f8e07dca8..9556e1117 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -35,7 +35,13 @@
    -

    {{ termination_a.device.site.region }}

    +

    {{ termination_a.device.site.region|placeholder }}

    +
    +
    +
    + +
    +

    {{ termination_a.device.site.group|placeholder }}

    @@ -44,10 +50,16 @@

    {{ termination_a.device.site }}

    +
    + +
    +

    {{ termination_a.device.location|placeholder }}

    +
    +
    -

    {{ termination_a.device.rack|default:"None" }}

    +

    {{ termination_a.device.rack|placeholder }}

    @@ -120,11 +132,14 @@ {% if 'termination_b_region' in form.fields %} {% render_field form.termination_b_region %} {% endif %} + {% if 'termination_b_site_group' in form.fields %} + {% render_field form.termination_b_site_group %} + {% endif %} {% if 'termination_b_site' in form.fields %} {% render_field form.termination_b_site %} {% endif %} - {% if 'termination_b_rackgroup' in form.fields %} - {% render_field form.termination_b_rackgroup %} + {% if 'termination_b_location' in form.fields %} + {% render_field form.termination_b_location %} {% endif %} {% if 'termination_b_rack' in form.fields %} {% render_field form.termination_b_rack %} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index a39ada1ce..b4e3e8d43 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -27,6 +27,8 @@ {# Cable #} {% if cable %} {% include 'dcim/trace/cable.html' %} + {% elif far_end %} + {% include 'dcim/trace/attachment.html' %} {% endif %} {# Far end #} @@ -43,6 +45,8 @@ {% if forloop.last %} {% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %} {% endif %} + {% elif far_end %} + {% include 'dcim/trace/object.html' with object=far_end %} {% endif %} {% if forloop.last %} @@ -69,7 +73,7 @@
    Total segments: {{ traced_path|length }}
    Total length: {% if total_length %} - {{ total_length|floatformat:"-2" }} Meters / + {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters / {{ total_length|meters_to_feet|floatformat:"-2" }} Feet {% else %} N/A @@ -102,12 +106,12 @@ - {{ cablepath.origin.parent }} / {{ cablepath.origin }} + {{ cablepath.origin.parent_object }} / {{ cablepath.origin }} {% if cablepath.destination %} - {{ cablepath.destination }} ({{ cablepath.destination.parent }}) + {{ cablepath.destination }} ({{ cablepath.destination.parent_object }}) {% else %} Incomplete {% endif %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index 5d113c86a..afc34651d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Console Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    @@ -26,7 +32,11 @@ Type - {{ object.get_type_display }} + {{ object.get_type_display|placeholder }} + + + Speed + {{ object.get_speed_display|placeholder }} Description @@ -34,6 +44,7 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %}
    @@ -42,7 +53,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index b64b4aff2..96dfa5761 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Console Server Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    @@ -26,7 +32,11 @@
    - + + + + + @@ -34,6 +44,7 @@
    Cable
    Type{{ object.get_type_display }}{{ object.get_type_display|placeholder }}
    Speed{{ object.get_speed_display|placeholder }}
    Description
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -42,7 +53,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 55be343ac..6c5d8588e 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -18,21 +18,41 @@
    Cable
    - + + + + + + + + + @@ -125,7 +145,7 @@ diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 8f488b284..84a4c1d3e 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -1,96 +1,74 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load static %} {% load helpers %} {% load custom_links %} {% load plugins %} -{% block title %}{{ object }}{% endblock %} +{% block breadcrumbs %} +
  • Devices
  • +
  • {{ object.site }}
  • + {% if object.parent_bay %} +
  • {{ object.parent_bay.device }}
  • +
  • {{ object.parent_bay }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_device %} -
    - - -
    - {% endif %} - {% if perms.dcim.add_device %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_device %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_device %} - {% delete_button object %} - {% endif %} -
    -

    {{ object }}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} +{% block buttons %} + {% if perms.dcim.change_device %} +
    + +
    + {% endif %} + {% if perms.dcim.add_device %} + {% clone_button object %} + {% endif %} + {% if perms.dcim.change_device %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_device %} + {% delete_button object %} + {% endif %} +{% endblock %} + +{% block tabs %}
    + {% include 'inc/paginator.html' with paginator=consoleport_table.paginator page=consoleport_table.page %} {% table_config_form consoleport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 61d3afd8b..4aa720e38 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=consoleserverport_table.paginator page=consoleserverport_table.page %} {% table_config_form consoleserverport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index d23f307e7..e09149f56 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -41,6 +41,7 @@ + {% include 'inc/paginator.html' with paginator=devicebay_table.paginator page=devicebay_table.page %} {% table_config_form devicebay_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 7084f592e..ea5b12635 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=frontport_table.paginator page=frontport_table.page %} {% table_config_form frontport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 34897a601..83c2ae7ed 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -47,6 +47,7 @@ + {% include 'inc/paginator.html' with paginator=interface_table.paginator page=interface_table.page %} {% table_config_form interface_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 5e52667cb..14d687ee9 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -41,6 +41,7 @@ + {% include 'inc/paginator.html' with paginator=inventoryitem_table.paginator page=inventoryitem_table.page %} {% table_config_form inventoryitem_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index 8e0cd9790..6d1508fa9 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=poweroutlet_table.paginator page=poweroutlet_table.page %} {% table_config_form poweroutlet_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index 9b56b64a3..8464a5fff 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=powerport_table.paginator page=powerport_table.page %} {% table_config_form powerport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index eeef667c2..b8db06fae 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=rearport_table.paginator page=rearport_table.page %} {% table_config_form rearport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/status.html b/netbox/templates/dcim/device/status.html index b7f8d0eaa..594fb0784 100644 --- a/netbox/templates/dcim/device/status.html +++ b/netbox/templates/dcim/device/status.html @@ -1,7 +1,7 @@ {% extends 'dcim/device/base.html' %} {% load static %} -{% block title %}{{ device }} - Status{% endblock %} +{% block title %}{{ object }} - Status{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html index 234d10fdd..1a3d0de4a 100644 --- a/netbox/templates/dcim/device_component.html +++ b/netbox/templates/dcim/device_component.html @@ -1,47 +1,12 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load helpers %} {% load perms %} {% load custom_links %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if request.user|can_change:object %} - - Edit - - {% endif %} - {% if request.user|can_delete:object %} - - Delete - - {% endif %} -
    -

    {% block title %}{{ object.device }} / {{ object }}{% endblock %}

    -
    - {% custom_links object %} -
    - +{% block title %}{{ object.device }} / {{ object }}{% endblock %} + +{% block breadcrumbs %} +
  • Devices
  • +
  • {{ object.device }}
  • {% endblock %} diff --git a/netbox/templates/dcim/device_component_add.html b/netbox/templates/dcim/device_component_add.html index 0b7200c1f..16cf267d0 100644 --- a/netbox/templates/dcim/device_component_add.html +++ b/netbox/templates/dcim/device_component_add.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load helpers %} {% load form_helpers %} {% block title %}Create {{ component_type }}{% endblock %} @@ -18,19 +19,34 @@ {% endif %}
    - {{ component_type|title }} + {{ component_type|bettertitle }}
    - {% render_form form %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + {% for field in form.visible_fields %} + {% if not form.custom_fields or field.name not in form.custom_fields %} + {% render_field field %} + {% endif %} + {% endfor %}
    -
    + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %} +
    Cancel
    -
    +
    diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 023b7e43c..0859a163b 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -7,6 +7,7 @@
    {% render_field form.name %} {% render_field form.device_role %} + {% render_field form.tags %}
    @@ -22,8 +23,9 @@
    Location
    {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} - {% render_field form.rack_group %} + {% render_field form.location %} {% render_field form.rack %} {% if obj.device_type.is_child_device and obj.parent_bay %}
    @@ -92,12 +94,6 @@ {% render_field form.local_context_data %}
    -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    Comments
    diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 62d03493c..14b4d8b3b 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -2,7 +2,7 @@ {% block bulk_buttons %} {% if perms.dcim.change_device %} -
    +
    diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 365625469..18da55ac0 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Device Bays
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    @@ -30,6 +36,7 @@
    SiteRegion {% if object.site.region %} - {{ object.site.region }} / + {% for region in object.site.region.get_ancestors %} + {{ region }} / + {% endfor %} + {{ object.site.region }} + {% else %} + None + {% endif %} +
    Site + {{ object.site }} +
    Location + {% if object.location %} + {% for location in object.location.get_ancestors %} + {{ location }} / + {% endfor %} + {{ object.location }} + {% else %} + None {% endif %} - {{ object.site }}
    Rack {% if object.rack %} - {% if object.rack.group %} - {{ object.rack.group }} / - {% endif %} {{ object.rack }} {% else %} None @@ -74,7 +94,7 @@
    Device Type - {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U) + {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U)
    Role - {{ object.device_role }} + {{ object.device_role }}
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html new file mode 100644 index 000000000..664bb00c6 --- /dev/null +++ b/netbox/templates/dcim/devicerole.html @@ -0,0 +1,86 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Device Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Device Role +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    VM Role + {% if object.vm_role %} + + {% else %} + + {% endif %} +
    Devices + {{ device_count }} +
    Virtual Machines + {% if object.vm_role %} + {{ virtualmachine_count }} + {% else %} + — + {% endif %} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 6bc970174..95dd8909e 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,81 +1,59 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} -{% load custom_links %} {% load helpers %} {% load plugins %} {% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} -{% block header %} -
    -
    - -
    +{% block breadcrumbs %} +
  • Device Types
  • +
  • {{ object.manufacturer }}
  • +
  • {{ object.model }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.dcim.change_devicetype %} +
    + +
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_devicetype %} -
    - - -
    - {% endif %} - {% if perms.dcim.add_devicetype %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_devicetype %} - {% edit_button object use_pk=True %} - {% endif %} - {% if perms.dcim.delete_devicetype %} - {% delete_button object use_pk=True %} - {% endif %} -
    -

    {{ object.manufacturer }} {{ object.model }}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - + {% endif %} + {% if perms.dcim.add_devicetype %} + {% clone_button object %} + {% endif %} + {% if perms.dcim.change_devicetype %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_devicetype %} + {% delete_button object %} + {% endif %} {% endblock %} {% block content %} @@ -88,7 +66,7 @@ - + diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html deleted file mode 100644 index 5aba04b39..000000000 --- a/netbox/templates/dcim/devicetype_edit.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Device Type
    -
    - {% render_field form.manufacturer %} - {% render_field form.model %} - {% render_field form.slug %} - {% render_field form.part_number %} - {% render_field form.u_height %} - {% render_field form.is_full_depth %} - {% render_field form.subdevice_role %} -
    -
    -
    -
    Rack Images
    -
    - {% render_field form.front_image %} - {% render_field form.rear_image %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 9dc0bc9f5..28e5c160c 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Front Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    @@ -44,6 +50,7 @@
    Manufacturer{{ object.manufacturer }}{{ object.manufacturer }}
    Model Name
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -52,7 +59,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html index 1962248e7..26a7e1cd3 100644 --- a/netbox/templates/dcim/inc/cabletermination.html +++ b/netbox/templates/dcim/inc/cabletermination.html @@ -1,12 +1,12 @@ + {% endwith %} {% else %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 2c0f6e01f..d8069da43 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -3,6 +3,21 @@ {% load plugins %} {% load render_table from django_tables2 %} +{% block breadcrumbs %} + {{ block.super }} +
  • Interfaces
  • +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.dcim.add_interface and not object.is_virtual %} + + Add Child Interface + + {% endif %} + {{ block.super }} +{% endblock %} + {% block content %}
    @@ -39,10 +54,30 @@ {% endif %}
    + + + + + + + + - +
    Cable - {% if termination.parent.provider %} + {% if termination.parent_object.provider %} - - {{ termination.parent.provider }} - {{ termination.parent }} + + {{ termination.parent_object.provider }} + {{ termination.parent_object }} {% else %} - {{ termination.parent }} + {{ termination.parent_object }} {% endif %} diff --git a/netbox/templates/dcim/inc/device_napalm_tabs.html b/netbox/templates/dcim/inc/device_napalm_tabs.html deleted file mode 100644 index f402d94ef..000000000 --- a/netbox/templates/dcim/inc/device_napalm_tabs.html +++ /dev/null @@ -1,15 +0,0 @@ -{% if not disabled_message %} - - - -{% else %} - - - -{% endif %} diff --git a/netbox/templates/dcim/inc/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html index 3169d2ffc..d5b9f6112 100644 --- a/netbox/templates/dcim/inc/endpoint_connection.html +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -1,6 +1,6 @@ {% if path.destination_id %} {% with endpoint=path.destination %} - {{ endpoint.parent }}{{ endpoint.parent_object }} {{ endpoint }}
    Management Only + {% if object.mgmt_only %} + + {% else %} + + {% endif %} +
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + None + {% endif %} +
    LAG - {% if object.lag%} + {% if object.lag %} {{ object.lag }} {% else %} None @@ -63,10 +98,11 @@
    802.1Q Mode{{ object.get_mode_display }}{{ object.get_mode_display|placeholder }}
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -76,7 +112,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} @@ -251,6 +291,11 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    + {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} +
    +
    {% plugin_full_width_page object %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 1f549febd..4dc081522 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -19,27 +19,33 @@ {% render_field form.label %} {% render_field form.type %} {% render_field form.enabled %} + {% render_field form.parent %} {% render_field form.lag %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.mgmt_only %} + {% render_field form.mark_connected %} {% render_field form.description %} + {% render_field form.tags %}
    802.1Q Switching
    {% render_field form.mode %} + {% render_field form.vlan_group %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %}
    -
    -
    Tags
    + {% if form.custom_fields %} +
    +
    Custom Fields
    - {% render_field form.tags %} + {% render_custom_fields form %}
    -
    +
    + {% endif %} {% endblock %} {% block buttons %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 4e0963f9d..35aace971 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Inventory Items
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    @@ -62,6 +68,7 @@
    Cable
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html new file mode 100644 index 000000000..e523465ec --- /dev/null +++ b/netbox/templates/dcim/location.html @@ -0,0 +1,97 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Locations
  • + {% for location in object.get_ancestors %} +
  • {{ location }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Location +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Site{{ object.site }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Racks + {{ rack_count }} +
    Devices + {{ device_count }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} +
    +
    + Images +
    + {% include 'inc/image_attachments.html' with images=object.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
    + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Locations +
    + {% include 'inc/table.html' with table=child_locations_table %} + {% if perms.dcim.add_location %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=child_locations_table.paginator page=child_locations_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html new file mode 100644 index 000000000..9ea6f3fe1 --- /dev/null +++ b/netbox/templates/dcim/manufacturer.html @@ -0,0 +1,66 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Manufacturers
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Manufacturer +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Device types + {{ devicetypes_table.rows|length }} +
    Inventory Items + {{ inventory_item_count }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Device Types +
    + {% include 'inc/table.html' with table=devicetypes_table %} + {% if perms.dcim.add_devicetype %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devicetypes_table.paginator page=devicetypes_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html new file mode 100644 index 000000000..f620bc4c9 --- /dev/null +++ b/netbox/templates/dcim/platform.html @@ -0,0 +1,78 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Platforms
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Platform +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Manufacturer + {% if object.manufacturer %} + {{ object.manufacturer }} + {% else %} + None + {% endif %} +
    NAPALM Driver{{ object.napalm_driver|placeholder }}
    NAPALM Arguments
    {{ object.napalm_args }}
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index be7e03ec5..d2a6ec1a3 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -1,63 +1,18 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load static %} {% load custom_links %} {% load helpers %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.add_powerfeed %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_powerfeed %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_powerfeed %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Power Feeds
  • +
  • {{ object.power_panel.site }}
  • +
  • {{ object.power_panel }}
  • + {% if object.rack %} +
  • {{ object.rack }}
  • + {% endif %} +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -159,7 +114,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html deleted file mode 100644 index 0a6581444..000000000 --- a/netbox/templates/dcim/powerfeed_edit.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Power Panel
    -
    - {% render_field form.region %} - {% render_field form.site %} - {% render_field form.power_panel %} -
    -
    -
    -
    Power Feed
    -
    - {% render_field form.rack %} - {% render_field form.name %} - {% render_field form.status %} -
    -
    -
    -
    Characteristics
    -
    - {% render_field form.type %} - {% render_field form.supply %} - {% render_field form.voltage %} - {% render_field form.amperage %} - {% render_field form.phase %} - {% render_field form.max_utilization %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index e5c9f8093..9a11628c8 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Power Outlets
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    @@ -42,6 +48,7 @@
    Cable
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -50,7 +57,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 179697b4f..9deb12005 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -1,59 +1,15 @@ -{% extends 'base.html' %} -{% load buttons %} -{% load custom_links %} +{% extends 'generic/object.html' %} {% load helpers %} -{% load static %} {% load plugins %} +{% load render_table from django_tables2 %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_powerpanel %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_powerpanel %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Power Panels
  • +
  • {{ object.site }}
  • + {% if object.location %} +
  • {{ object.location }}
  • + {% endif %} +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -71,10 +27,10 @@ - +
    Cable
    Rack GroupLocation - {% if object.rack_group %} - {{ object.rack_group }} + {% if object.location %} + {{ object.location }} {% else %} None {% endif %} @@ -92,7 +48,37 @@
    - {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} +
    + {% csrf_token %} +
    + {% render_table powerfeed_table 'inc/table.html' %} + +
    +
    {% plugin_full_width_page object %}
    diff --git a/netbox/templates/dcim/powerpanel_edit.html b/netbox/templates/dcim/powerpanel_edit.html deleted file mode 100644 index c1890cd47..000000000 --- a/netbox/templates/dcim/powerpanel_edit.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Power Panel
    -
    - {% render_field form.region %} - {% render_field form.site %} - {% render_field form.rack_group %} - {% render_field form.name %} - {% render_field form.tags %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 7356bafb9..1a7cc24e7 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Power Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    @@ -42,6 +48,7 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -50,7 +57,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 6a00308f3..94f0ea24c 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,74 +1,46 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} -{% load custom_links %} {% load helpers %} {% load static %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    - {% plugin_buttons object %} - - Previous Rack - - - Next Rack - - {% if perms.dcim.add_rack %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_rack %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_rack %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}Rack {{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - - {% custom_links object %} -
    - +{% block title %}Rack {{ object }}{% endblock %} + +{% block breadcrumbs %} +
  • Racks
  • +
  • {{ object.site }}
  • + {% if object.location %} + {% for location in object.location.get_ancestors %} +
  • {{ location }}
  • + {% endfor %} +
  • {{ object.location }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + + Previous Rack + + + Next Rack + + {% if perms.dcim.add_rack %} + {% clone_button object %} + {% endif %} + {% if perms.dcim.change_rack %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_rack %} + {% delete_button object %} + {% endif %} +{% endblock %} + +{% block tab_buttons %} + + {{ block.super }} {% endblock %} {% block content %} @@ -85,17 +57,17 @@ {% if object.site.region %} {{ object.site.region }} / {% endif %} - {{ object.site }} + {{ object.site }} - + - +
    Cable
    GroupLocation - {% if object.group %} - {% for group in object.group.get_ancestors %} - {{ group }} / + {% if object.location %} + {% for location in object.location.get_ancestors %} + {{ location }} / {% endfor %} - {{ object.group }} + {{ object.location }} {% else %} None {% endif %} @@ -296,7 +268,7 @@ {{ resv.description }}
    - {{ resv.user }} · {{ resv.created }} + {{ resv.user }} · {{ resv.created|annotated_date }}
    {% if perms.dcim.change_rackreservation %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 9ce242191..af94a7197 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -6,12 +6,19 @@
    Rack
    {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} - {% render_field form.group %} + {% render_field form.location %} {% render_field form.name %} - {% render_field form.facility_id %} {% render_field form.status %} {% render_field form.role %} + {% render_field form.tags %} +
    + +
    +
    Inventory Control
    +
    + {% render_field form.facility_id %} {% render_field form.serial %} {% render_field form.asset_tag %}
    @@ -52,12 +59,6 @@
    {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    Comments
    diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 5273b2a39..8d4fa735c 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -1,56 +1,23 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load custom_links %} {% load helpers %} {% load static %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_rackreservation %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_rackreservation %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Rack Reservations
  • +
  • {{ object.rack }}
  • +
  • Units {{ object.unit_list }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.dcim.change_rackreservation %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_rackreservation %} + {% delete_button object %} + {% endif %} {% endblock %} {% block content %} @@ -68,14 +35,14 @@ {% if rack.site.region %} {{ rack.site.region }} / {% endif %} - {{ rack.site }} + {{ rack.site }}
    GroupLocation - {% if rack.group %} - {{ rack.group }} + {% if rack.location %} + {{ rack.location }} {% else %} None {% endif %} diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html deleted file mode 100644 index 1465dc02d..000000000 --- a/netbox/templates/dcim/rackreservation_edit.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Rack Reservation
    -
    - {% render_field form.region %} - {% render_field form.site %} - {% render_field form.rack_group %} - {% render_field form.rack %} - {% render_field form.units %} - {% render_field form.user %} - {% render_field form.description %} - {% render_field form.tags %} -
    -
    -
    -
    Tenant Assignment
    -
    - {% render_field form.tenant_group %} - {% render_field form.tenant %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html new file mode 100644 index 000000000..89306b481 --- /dev/null +++ b/netbox/templates/dcim/rackrole.html @@ -0,0 +1,66 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Rack Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Rack Role +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Racks + {{ racks_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Racks +
    + {% include 'inc/table.html' with table=racks_table %} + {% if perms.dcim.add_rack %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=racks_table.paginator page=racks_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 319850397..eb9452a0c 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Rear Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    @@ -38,6 +44,7 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -46,7 +53,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html new file mode 100644 index 000000000..1e2d395dd --- /dev/null +++ b/netbox/templates/dcim/region.html @@ -0,0 +1,86 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Region
  • + {% for region in object.get_ancestors %} +
  • {{ region }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Region +
    +
    Cable
    + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ sites_table.rows|length }} +
    + + {% include 'inc/custom_fields_panel.html' %} + {% plugin_left_page object %} + +
    +
    +
    + Child Regions +
    + {% include 'inc/table.html' with table=child_regions_table %} + {% if perms.dcim.add_region %} + + {% endif %} +
    + {% plugin_right_page object %} +
    + +
    +
    +
    +
    + Sites +
    + {% include 'inc/table.html' with table=sites_table %} + {% if perms.dcim.add_site %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index a0e713fcf..a2ad76d53 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,65 +1,17 @@ -{% extends 'base.html' %} -{% load buttons %} -{% load custom_links %} +{% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} -{% load static %} {% load tz %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.add_site %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_site %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_site %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Sites
  • + {% if object.region %} + {% for region in object.region.get_ancestors %} +
  • {{ region }}
  • + {% endfor %} +
  • {{ object.region }}
  • + {% endif %} +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -89,6 +41,19 @@ {% endif %} + + Group + + {% if object.group %} + {% for group in object.group.get_ancestors %} + {{ group }} / + {% endfor %} + {{ object.group }} + {% else %} + None + {% endif %} + + Tenant @@ -115,7 +80,11 @@ {% if object.time_zone %} {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
    - Site time: {% timezone object.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %} + Site time: + {% timezone object.time_zone %} + {% annotated_now %} + {% endtimezone %} + {% else %} {% endif %} @@ -137,7 +106,7 @@ {% if object.physical_address %} @@ -156,7 +125,7 @@ {% if object.latitude and object.longitude %} @@ -215,56 +184,59 @@
    - Rack Groups + Locations
    - {% for rg in rack_groups %} + + + + + + + {% for location in locations %} - - + + + {% endfor %} - - - - -
    LocationRacksDevices
    {{ rg }}{{ rg.rack_count }} + {{ location }} + + {{ location.rack_count }} + {{ location.device_count }} + - +
    All racks{{ stats.rack_count }} - - - -
    diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html deleted file mode 100644 index 274863fc4..000000000 --- a/netbox/templates/dcim/site_edit.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Site
    -
    - {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.status %} - {% render_field form.region %} - {% render_field form.facility %} - {% render_field form.asn %} - {% render_field form.time_zone %} - {% render_field form.description %} -
    -
    -
    -
    Tenancy
    -
    - {% render_field form.tenant_group %} - {% render_field form.tenant %} -
    -
    -
    -
    Contact Info
    -
    - {% render_field form.physical_address %} - {% render_field form.shipping_address %} - {% render_field form.latitude %} - {% render_field form.longitude %} - {% render_field form.contact_name %} - {% render_field form.contact_phone %} - {% render_field form.contact_email %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html new file mode 100644 index 000000000..2de17c025 --- /dev/null +++ b/netbox/templates/dcim/sitegroup.html @@ -0,0 +1,86 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Site Groups
  • + {% for sitegroup in object.get_ancestors %} +
  • {{ sitegroup }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Site Group +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ sites_table.rows|length }} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_left_page object %} +
    +
    +
    +
    + Child Groups +
    + {% include 'inc/table.html' with table=child_groups_table %} + {% if perms.dcim.add_sitegroup %} + + {% endif %} +
    + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Sites +
    + {% include 'inc/table.html' with table=sites_table %} + {% if perms.dcim.add_site %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/trace/attachment.html b/netbox/templates/dcim/trace/attachment.html new file mode 100644 index 000000000..450d74bc8 --- /dev/null +++ b/netbox/templates/dcim/trace/attachment.html @@ -0,0 +1,5 @@ +{% load helpers %} + +
    + Attachment +
    diff --git a/netbox/templates/dcim/trace/device.html b/netbox/templates/dcim/trace/device.html index c3696e786..b1d37b76c 100644 --- a/netbox/templates/dcim/trace/device.html +++ b/netbox/templates/dcim/trace/device.html @@ -2,6 +2,9 @@ {{ device }}
    {{ device.device_type.manufacturer }} {{ device.device_type }}
    {{ device.site }} + {% if device.location %} + / {{ device.location }} + {% endif %} {% if device.rack %} / {{ device.rack }} {% endif %} diff --git a/netbox/templates/dcim/trace/object.html b/netbox/templates/dcim/trace/object.html new file mode 100644 index 000000000..72e5b5787 --- /dev/null +++ b/netbox/templates/dcim/trace/object.html @@ -0,0 +1,3 @@ + diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index eb9972406..6887c01ca 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -1,57 +1,24 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load custom_links %} {% load helpers %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_virtualchassis %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_virtualchassis %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Virtual Chassis
  • + {% if object.master %} +
  • {{ object.master.site }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.dcim.change_virtualchassis %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_virtualchassis %} + {% delete_button object %} + {% endif %} {% endblock %} {% block content %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html index 2a68cb5e4..6dc9fc59f 100644 --- a/netbox/templates/dcim/virtualchassis_add.html +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -14,6 +14,7 @@
    Member Devices
    {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} {% render_field form.rack %} {% render_field form.members %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 408e51b75..626002a68 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -1,47 +1,10 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load helpers %} {% load static %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% if perms.extras.change_configcontext %} - - - Edit this config context - - {% endif %} -
    - -

    {% block title %}{{ object }}{% endblock %}

    +{% block breadcrumbs %} +
  • Config Contexts
  • +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -103,6 +66,20 @@ {% endif %} + + Site Groups + + {% if object.site_groups.all %} +
      + {% for sitegroup in object.site_groups.all %} +
    • {{ sitegroup }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} + + Sites @@ -117,13 +94,27 @@ {% endif %} + + Device Types + + {% if object.device_types.all %} +
      + {% for devicetype in object.device_types.all %} +
    • {{ devicetype }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} + + Roles {% if object.roles.all %} {% else %} diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 08e92d258..37e03e0ac 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -15,7 +15,9 @@
    Assignment
    {% render_field form.regions %} + {% render_field form.site_groups %} {% render_field form.sites %} + {% render_field form.device_types %} {% render_field form.roles %} {% render_field form.platforms %} {% render_field form.cluster_groups %} diff --git a/netbox/templates/extras/journalentry.html b/netbox/templates/extras/journalentry.html new file mode 100644 index 000000000..6e986be6c --- /dev/null +++ b/netbox/templates/extras/journalentry.html @@ -0,0 +1,57 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load static %} + +{% block breadcrumbs %} +
  • Journal Entries
  • +
  • {{ object.assigned_object }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Journal Entry +
    + + + + + + + + + + + + + + + + + +
    Object + {{ object.assigned_object }} +
    Created + {{ object.created|annotated_date }} +
    Created By + {{ object.created_by }} +
    Kind + {{ object.get_kind_display }} +
    +
    +
    +
    +
    +
    + Comments +
    +
    + {{ object.comments|render_markdown }} +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html new file mode 100644 index 000000000..6d0a76e73 --- /dev/null +++ b/netbox/templates/extras/object_journal.html @@ -0,0 +1,32 @@ +{% extends base_template %} +{% load form_helpers %} + +{% block title %}{{ block.super }} - Journal{% endblock %} + +{% block content %} + {% if perms.extras.add_journalentry %} +
    +
    + New Journal Entry +
    +
    + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    +
    + {% render_field form.kind %} + {% render_field form.comments %} +
    + + Cancel +
    +
    +
    +
    +
    + {% endif %} + {% include 'panel_table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +{% endblock %} diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index 15265889e..b13491a55 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -44,7 +44,7 @@ Time - {{ object.time }} + {{ object.time|annotated_date }} @@ -83,6 +83,8 @@
    +
    +
    Difference @@ -113,13 +115,41 @@
    -
    +
    +
    +
    - Object Data + Pre-Change Data
    -
    {{ object.object_data|render_json }}
    + {% if object.prechange_data %} +
    {% for k, v in object.prechange_data.items %}{% spaceless %}
    +                      {{ k }}: {{ v|render_json }}
    +                    {% endspaceless %}
    +{% endfor %}
    + {% elif non_atomic_change %} + Warning: Comparing non-atomic change to previous change record ({{ prev_change.pk }}) + {% else %} + None + {% endif %} +
    +
    +
    +
    +
    +
    + Post-Change Data +
    +
    + {% if object.postchange_data %} +
    {% for k, v in object.postchange_data.items %}{% spaceless %}
    +                        {{ k }}: {{ v|render_json }}
    +                      {% endspaceless %}
    +{% endfor %}
    + {% else %} + None + {% endif %}
    diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 76a34c060..642272c8f 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -27,9 +27,9 @@
    {% endif %} -

    {{ report.name }}

    +

    {{ report.name }}

    {% if report.description %} -

    {{ report.description }}

    +

    {{ report.description|render_markdown }}

    {% endif %} {% endblock %} @@ -38,7 +38,7 @@ diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 7685cdacf..ac8171a9a 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -29,10 +29,10 @@ {% include 'extras/inc/job_label.html' with result=report.result %} - {{ report.description|placeholder }} + {{ report.description|render_markdown|placeholder }} {% if report.result %} - {{ report.result.created }} + {{ report.result.created|annotated_date }} {% else %} Never {% endif %} diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html index 80715f2aa..073edc803 100644 --- a/netbox/templates/extras/report_result.html +++ b/netbox/templates/extras/report_result.html @@ -8,7 +8,7 @@

    - Run: {{ result.created }} + Run: {{ result.created|annotated_date }} {% if result.completed %} Duration: {{ result.duration }} {% else %} @@ -66,9 +66,11 @@ {{ obj }} {% elif obj %} {{ obj }} + {% else %} + {% endif %} - {{ message }} + {{ message|render_markdown }} {% endfor %} {% endfor %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 5808f707f..7a99d245d 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -15,8 +15,8 @@

    -

    {{ script }}

    -

    {{ script.Meta.description }}

    +

    {{ script }}

    +

    {{ script.Meta.description|render_markdown }}

    -

    {{ script }}

    -

    {{ script.Meta.description }}

    +

    {{ script }}

    +

    {{ script.Meta.description|render_markdown }}

    - Run: {{ result.created }} + Run: {{ result.created|annotated_date }} {% if result.completed %} Duration: {{ result.duration }} {% else %} @@ -110,4 +110,4 @@ function jobTerminatedAction(){ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 2ad7cf814..f8e935992 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -1,98 +1,79 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load helpers %} +{% load plugins %} -{% block header %} -

    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% if perms.taggit.change_tag %} - - - Edit this tag - - {% endif %} - {% if perms.taggit.delete_tag %} - - - Delete this tag - - {% endif %} -
    -

    {% block title %}Tag: {{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} - +{% block breadcrumbs %} +
  • Tags
  • +
  • {{ object }}
  • {% endblock %} {% block content %} -
    -
    -
    -
    - Tag -
    - - - - - - - - - - - - - - - - - - - - -
    Name - {{ object.name }} -
    Slug - {{ object.slug }} -
    Tagged Items - {{ items_count }} -
    Color -   -
    Description - {{ object.description|placeholder }} -
    -
    -
    -
    - {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %} - {% include 'inc/paginator.html' with paginator=items_table.paginator page=items_table.page %} -
    +
    +
    +
    +
    + Tag +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Tagged Items + {{ taggeditem_table.rows|length }} +
    + {% plugin_left_page object %} +
    +
    +
    +
    + Tagged Item Types +
    + + {% for object_type in object_types %} + + + + + {% endfor %} +
    {{ object_type.content_type.name|bettertitle }} + {% with viewname=object_type.content_type.model_class|validated_viewname:"list" %} + {% if viewname %} + {{ object_type.item_count }} + {% else %} + {{ object_type.item_count }} + {% endif %} + {% endwith %} +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Tagged Items +
    + {% include 'inc/table.html' with table=taggeditem_table %} +
    + {% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=taggeditem_table.page %} + {% plugin_full_width_page object %} +
    +
    {% endblock %} diff --git a/netbox/templates/extras/tag_edit.html b/netbox/templates/extras/tag_edit.html deleted file mode 100644 index 1516bde39..000000000 --- a/netbox/templates/extras/tag_edit.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Tag
    -
    - {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.color %} - {% render_field form.description %} -
    -
    -{% endblock %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html new file mode 100644 index 000000000..e7424aa56 --- /dev/null +++ b/netbox/templates/generic/object.html @@ -0,0 +1,80 @@ +{% extends 'base.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load perms %} +{% load plugins %} + +{% block header %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% plugin_buttons object %} + {% block buttons %} + {% if request.user|can_add:object %} + {% clone_button object %} + {% endif %} + {% if request.user|can_change:object %} + {% edit_button object %} + {% endif %} + {% if request.user|can_delete:object %} + {% delete_button object %} + {% endif %} + {% endblock %} +
    +

    {% block title %}{{ object }}{% endblock %}

    +

    + + Created {{ object.created|annotated_date }} · + Updated {{ object.last_updated|annotated_date }} ({{ object.last_updated|timesince }} ago) + + {{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }} +

    +
    + {% block tab_buttons %} + {% custom_links object %} + {% endblock %} +
    + {% block tabs %} + + {% endblock %} +{% endblock %} diff --git a/netbox/templates/generic/object_bulk_import.html b/netbox/templates/generic/object_bulk_import.html index 170cf3665..ec6dddf14 100644 --- a/netbox/templates/generic/object_bulk_import.html +++ b/netbox/templates/generic/object_bulk_import.html @@ -16,103 +16,107 @@
    {% endif %} -
    -
    -
    - {% csrf_token %} - {% render_form form %} -
    -
    - - {% if return_url %} - Cancel - {% endif %} -
    -
    -
    -
    -

    - {% if fields %} -
    -
    - CSV Field Options -
    - - - - - - - - {% for name, field in fields.items %} - - - - - - - {% endfor %} -
    FieldRequiredAccessorDescription
    - {{ name }} - - {% if field.required %} - - {% else %} - - {% endif %} - - {% if field.to_field_name %} - {{ field.to_field_name }} - {% else %} - - {% endif %} - - {% if field.STATIC_CHOICES %} - - - {% endif %} - {% if field.help_text %} - {{ field.help_text }}
    - {% elif field.label %} - {{ field.label }}
    - {% endif %} - {% if field|widget_type == 'dateinput' %} - Format: YYYY-MM-DD - {% elif field|widget_type == 'checkboxinput' %} - Specify "true" or "false" - {% endif %} -
    -
    -

    - Required fields must be specified for all - objects. -

    -

    - Related objects may be referenced by any unique attribute. - For example, vrf.rd would identify a VRF by its route distinguisher. -

    - {% endif %} +
    + {% csrf_token %} +
    +
    + {% render_field form.csv %} +
    +
    + {% render_field form.csv_file %} +
    -
    +
    +
    + + {% if return_url %} + Cancel + {% endif %} +
    +
    + +
    +

    + {% if fields %} +
    +
    + CSV Field Options +
    + + + + + + + + {% for name, field in fields.items %} + + + + + + + {% endfor %} +
    FieldRequiredAccessorDescription
    + {{ name }} + + {% if field.required %} + + {% else %} + + {% endif %} + + {% if field.to_field_name %} + {{ field.to_field_name }} + {% else %} + + {% endif %} + + {% if field.STATIC_CHOICES %} + + + {% endif %} + {% if field.help_text %} + {{ field.help_text }}
    + {% elif field.label %} + {{ field.label }}
    + {% endif %} + {% if field|widget_type == 'dateinput' %} + Format: YYYY-MM-DD + {% elif field|widget_type == 'checkboxinput' %} + Specify "true" or "false" + {% endif %} +
    +
    +

    + Required fields must be specified for all + objects. +

    +

    + Related objects may be referenced by any unique attribute. + For example, vrf.rd would identify a VRF by its route distinguisher. +

    + {% endif %}
    {% endblock %} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 965749a6d..a07b2dfaf 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -30,14 +30,45 @@ {% endif %} {% block form %} -
    -
    {{ obj_type|capfirst }}
    + {% if form.Meta.fieldsets %} + {# Render grouped fields accoring to Form #} + {% for group, fields in form.Meta.fieldsets %} +
    +
    {{ group }}
    +
    + {% for name in fields %} + {% render_field form|getfield:name %} + {% endfor %} +
    +
    + {% endfor %} + {% if form.custom_fields %} +
    +
    Custom Fields
    - {% block form_fields %} - {% render_form form %} - {% endblock %} + {% render_custom_fields form %}
    +
    + {% endif %} + {% if form.comments %} +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    + {% endif %} + {% else %} + {# Render all fields in a single group #} +
    +
    {{ obj_type|capfirst }}
    +
    + {% block form_fields %} + {% render_form form %} + {% endblock %} +
    + {% endif %} {% endblock %}
    diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 738cbca49..5138f4639 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} {% load buttons %} {% load helpers %} +{% load render_table from django_tables2 %} {% load static %} {% block content %} @@ -28,54 +29,58 @@ {% block sidebar %}{% endblock %} {% endif %} - {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} - {% if permissions.change or permissions.delete %} -
    - {% csrf_token %} - - {% if table.paginator.num_pages > 1 %} -
    +
    +
    +
    +
    + Secrets +
    + {% include 'inc/table.html' with table=secrets_table %} + {% if perms.secrets.add_secret %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=secrets_table.paginator page=secrets_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e2e66e6b2..d231f7c9d 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -1,60 +1,13 @@ -{% extends 'base.html' %} -{% load buttons %} -{% load custom_links %} +{% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.tenancy.add_tenant %} - {% clone_button object %} - {% endif %} - {% if perms.tenancy.change_tenant %} - {% edit_button object %} - {% endif %} - {% if perms.tenancy.delete_tenant %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Tenants
  • + {% if object.group %} +
  • {{ object.group }}
  • + {% endif %} +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -104,47 +57,51 @@ diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html deleted file mode 100644 index 8a1d1d1b6..000000000 --- a/netbox/templates/tenancy/tenant_edit.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load static %} -{% load form_helpers %} - -{% block form %} -
    -
    Tenant
    -
    - {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.group %} - {% render_field form.description %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html new file mode 100644 index 000000000..4f65ef21a --- /dev/null +++ b/netbox/templates/tenancy/tenantgroup.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Tenant Groups
  • + {% for tenantgroup in object.get_ancestors %} +
  • {{ tenantgroup }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Tenant Group +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ tenants_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Tenants +
    + {% include 'inc/table.html' with table=tenants_table %} + {% if perms.tenancy.add_tenant %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=tenants_table.paginator page=tenants_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 04e7cb23d..ad2e3911a 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -11,12 +11,8 @@
    Copy - {% if perms.users.change_token %} - Edit - {% endif %} - {% if perms.users.delete_token %} - Delete - {% endif %} + Edit + Delete
    {{ token.key }} @@ -28,12 +24,12 @@
    Created
    - {{ token.created|date }} + {{ token.created|annotated_date }}
    Expires
    {% if token.expires %} - {{ token.expires|date }} + {{ token.expires|annotated_date }} {% else %} Never {% endif %} @@ -55,16 +51,10 @@ {% empty %}

    You do not have any API tokens.

    {% endfor %} - {% if perms.users.add_token %} - - - Add a token - - {% else %} - - {% endif %} + + + Add a token +
    {% endblock %} diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html index 35a94ac6f..2e575cca6 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/profile.html @@ -11,7 +11,7 @@ Email
    {{ request.user.email }}
    Registered -
    {{ request.user.date_joined }}
    +
    {{ request.user.date_joined|annotated_date }}
    Groups
    {{ request.user.groups.all|join:', ' }}
    Admin access diff --git a/netbox/templates/users/userkey.html b/netbox/templates/users/userkey.html index 09b5bde5b..a024ee352 100644 --- a/netbox/templates/users/userkey.html +++ b/netbox/templates/users/userkey.html @@ -1,4 +1,5 @@ {% extends 'users/base.html' %} +{% load helpers %} {% block title %}User Key{% endblock %} @@ -18,7 +19,11 @@ Inactive {% endif %} - {% include 'inc/created_updated.html' %} +

    + + Created {{ object.created|annotated_date }} · + Updated {{ object.last_updated|annotated_date }} ({{ object.last_updated|timesince }} ago) +

    {% if not object.is_active %}

    Session key: Active

    - Created {{ object.session_key.created }} + Created {{ object.session_key.created|annotated_date }} {% else %}

    No active session key

    {% endif %} diff --git a/netbox/templates/utilities/templatetags/table_config_form.html b/netbox/templates/utilities/templatetags/table_config_form.html index c92adaee1..65397c18d 100644 --- a/netbox/templates/utilities/templatetags/table_config_form.html +++ b/netbox/templates/utilities/templatetags/table_config_form.html @@ -8,17 +8,23 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_right_page object %}
    @@ -118,9 +101,14 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} -
    -
    - {% plugin_full_width_page object %} -
    +
    +
    + {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    {% endblock %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index d1fe6d104..5f0a56505 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -17,25 +17,30 @@ {% endif %} {% render_field form.name %} {% render_field form.enabled %} + {% render_field form.parent %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} + {% render_field form.tags %}
    802.1Q Switching
    {% render_field form.mode %} + {% render_field form.vlan_group %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %}
    -
    -
    Tags
    + {% if form.custom_fields %} +
    +
    Custom Fields
    - {% render_field form.tags %} + {% render_custom_fields form %}
    -
    +
    + {% endif %} {% endblock %} {% block buttons %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 7b227c123..11225fa7a 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -20,7 +20,7 @@ class NestedTenantGroupSerializer(WritableNestedSerializer): class Meta: model = TenantGroup - fields = ['id', 'url', 'name', 'slug', 'tenant_count', '_depth'] + fields = ['id', 'url', 'display', 'name', 'slug', 'tenant_count', '_depth'] class NestedTenantSerializer(WritableNestedSerializer): @@ -28,4 +28,4 @@ class NestedTenantSerializer(WritableNestedSerializer): class Meta: model = Tenant - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'display', 'name', 'slug'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 4c2f9faee..3136c811c 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,8 +1,6 @@ from rest_framework import serializers -from extras.api.customfields import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer -from netbox.api import ValidatedModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.models import Tenant, TenantGroup from .nested_serializers import * @@ -11,18 +9,20 @@ from .nested_serializers import * # Tenants # -class TenantGroupSerializer(ValidatedModelSerializer): +class TenantGroupSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') parent = NestedTenantGroupSerializer(required=False, allow_null=True) tenant_count = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = TenantGroup - fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'tenant_count', '_depth'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'tenant_count', '_depth', + ] -class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class TenantSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') group = NestedTenantGroupSerializer(required=False, allow_null=True) circuit_count = serializers.IntegerField(read_only=True) @@ -39,7 +39,7 @@ class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Tenant fields = [ - 'id', 'url', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', + 'id', 'url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 2b7ae8365..2e049135d 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,8 +4,7 @@ from circuits.models import Circuit from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF -from netbox.api.views import ModelViewSet -from tenancy import filters +from tenancy import filtersets from tenancy.models import Tenant, TenantGroup from utilities.utils import count_related from virtualization.models import VirtualMachine @@ -24,7 +23,7 @@ class TenancyRootView(APIRootView): # Tenant Groups # -class TenantGroupViewSet(ModelViewSet): +class TenantGroupViewSet(CustomFieldModelViewSet): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -33,7 +32,7 @@ class TenantGroupViewSet(ModelViewSet): cumulative=True ) serializer_class = serializers.TenantGroupSerializer - filterset_class = filters.TenantGroupFilterSet + filterset_class = filtersets.TenantGroupFilterSet # @@ -55,4 +54,4 @@ class TenantViewSet(CustomFieldModelViewSet): vrf_count=count_related(VRF, 'tenant') ) serializer_class = serializers.TenantSerializer - filterset_class = filters.TenantFilterSet + filterset_class = filtersets.TenantFilterSet diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filtersets.py similarity index 87% rename from netbox/tenancy/filters.py rename to netbox/tenancy/filtersets.py index d61081de4..d00b78629 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filtersets.py @@ -1,8 +1,9 @@ import django_filters from django.db.models import Q -from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet -from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter +from extras.filters import TagFilter +from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from utilities.filters import TreeNodeMultipleChoiceFilter from .models import Tenant, TenantGroup @@ -13,7 +14,7 @@ __all__ = ( ) -class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class TenantGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), label='Tenant group (ID)', @@ -30,7 +31,7 @@ class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'description'] -class TenantFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): +class TenantFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index bceab7ce7..d53748055 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,12 +1,13 @@ from django import forms +from django.utils.translation import gettext as _ from extras.forms import ( AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, ) from extras.models import Tag from utilities.forms import ( - BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, SlugField, TagFilterField, + BootstrapMixin, CommentField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + SlugField, TagFilterField, ) from .models import Tenant, TenantGroup @@ -15,7 +16,7 @@ from .models import Tenant, TenantGroup # Tenant groups # -class TenantGroupForm(BootstrapMixin, forms.ModelForm): +class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False @@ -29,7 +30,7 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): ] -class TenantGroupCSVForm(CSVModelForm): +class TenantGroupCSVForm(CustomFieldModelCSVForm): parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -43,6 +44,24 @@ class TenantGroupCSVForm(CSVModelForm): fields = TenantGroup.csv_headers +class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + # # Tenants # @@ -64,6 +83,9 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): fields = ( 'name', 'slug', 'group', 'description', 'comments', 'tags', ) + fieldsets = ( + ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), + ) class TenantCSVForm(CustomFieldModelCSVForm): @@ -100,13 +122,13 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Tenant q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - group = DynamicModelMultipleChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Group') ) tag = TagFilterField(model) @@ -134,18 +156,18 @@ class TenancyForm(forms.Form): class TenancyFilterForm(forms.Form): - tenant_group = DynamicModelMultipleChoiceField( + tenant_group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Tenant group') ) - tenant = DynamicModelMultipleChoiceField( + tenant_id = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), - to_field_name='slug', required=False, null_option='None', query_params={ - 'group': '$tenant_group' - } + 'group_id': '$tenant_group_id' + }, + label=_('Tenant') ) diff --git a/netbox/tenancy/migrations/0012_standardize_models.py b/netbox/tenancy/migrations/0012_standardize_models.py new file mode 100644 index 000000000..7ce55cf42 --- /dev/null +++ b/netbox/tenancy/migrations/0012_standardize_models.py @@ -0,0 +1,27 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0011_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='tenantgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='tenant', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenantgroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 3ba644c09..63f960b0e 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,13 +1,11 @@ +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey -from taggit.managers import TaggableManager -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features -from utilities.mptt import TreeManager +from netbox.models import NestedGroupModel, PrimaryModel from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object __all__ = ( @@ -16,7 +14,8 @@ __all__ = ( ) -class TenantGroup(MPTTModel, ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. """ @@ -41,21 +40,13 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): blank=True ) - objects = TreeManager() - csv_headers = ['name', 'slug', 'parent', 'description'] class Meta: ordering = ['name'] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): - return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug) + return reverse('tenancy:tenantgroup', args=[self.pk]) def to_csv(self): return ( @@ -65,18 +56,9 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): self.description, ) - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Tenant(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. @@ -103,7 +85,6 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -119,7 +100,7 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): return self.name def get_absolute_url(self): - return reverse('tenancy:tenant', args=[self.slug]) + return reverse('tenancy:tenant', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 9e8be6b18..803675bfd 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,22 +1,32 @@ import django_tables2 as tables -from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn from .models import Tenant, TenantGroup -MPTT_LINK = """ -{% for i in record.get_ancestors %} - -{% endfor %} -{{ record.name }} -""" -COL_TENANT = """ -{% if record.tenant %} - {{ record.tenant }} -{% else %} - — -{% endif %} -""" +# +# Table columns +# + +class TenantColumn(tables.TemplateColumn): + """ + Include the tenant description. + """ + template_code = """ + {% if record.tenant %} + {{ record.tenant }} + {% elif record.vrf.tenant %} + {{ record.vrf.tenant }}* + {% else %} + — + {% endif %} + """ + + def __init__(self, *args, **kwargs): + super().__init__(template_code=self.template_code, *args, **kwargs) + + def value(self, value): + return str(value) if value else None # @@ -25,17 +35,15 @@ COL_TENANT = """ class TenantGroupTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False, - attrs={'td': {'class': 'text-nowrap'}} + name = MPTTColumn( + linkify=True ) tenant_count = LinkedCountColumn( viewname='tenancy:tenant_list', - url_params={'group': 'slug'}, + url_params={'group_id': 'pk'}, verbose_name='Tenants' ) - actions = ButtonsColumn(TenantGroup, pk_field='slug') + actions = ButtonsColumn(TenantGroup) class Meta(BaseTable.Meta): model = TenantGroup @@ -49,7 +57,9 @@ class TenantGroupTable(BaseTable): class TenantTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) tags = TagColumn( url_name='tenancy:tenant_list' ) diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c512ff688..5a3c2c1b0 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -16,7 +16,7 @@ class AppTest(APITestCase): class TenantGroupTest(APIViewTestCases.APIViewTestCase): model = TenantGroup - brief_fields = ['_depth', 'id', 'name', 'slug', 'tenant_count', 'url'] + brief_fields = ['_depth', 'display', 'id', 'name', 'slug', 'tenant_count', 'url'] bulk_update_data = { 'description': 'New description', } @@ -54,7 +54,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): class TenantTest(APIViewTestCases.APIViewTestCase): model = Tenant - brief_fields = ['id', 'name', 'slug', 'url'] + brief_fields = ['display', 'id', 'name', 'slug', 'url'] bulk_update_data = { 'group': None, 'description': 'New description', diff --git a/netbox/tenancy/tests/test_filters.py b/netbox/tenancy/tests/test_filtersets.py similarity index 88% rename from netbox/tenancy/tests/test_filters.py rename to netbox/tenancy/tests/test_filtersets.py index c78b25083..fd4a0bd76 100644 --- a/netbox/tenancy/tests/test_filters.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -1,10 +1,11 @@ from django.test import TestCase -from tenancy.filters import * +from tenancy.filtersets import * from tenancy.models import Tenant, TenantGroup +from utilities.testing import ChangeLoggedFilterSetTests -class TenantGroupTestCase(TestCase): +class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = TenantGroup.objects.all() filterset = TenantGroupFilterSet @@ -27,10 +28,6 @@ class TenantGroupTestCase(TestCase): for tenantgroup in tenant_groups: tenantgroup.save() - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Tenant Group 1', 'Tenant Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -51,7 +48,7 @@ class TenantGroupTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class TenantTestCase(TestCase): +class TenantTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Tenant.objects.all() filterset = TenantFilterSet @@ -73,10 +70,6 @@ class TenantTestCase(TestCase): ) Tenant.objects.bulk_create(tenants) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Tenant 1', 'Tenant 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 5b88b84cf..f45afc302 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -1,5 +1,5 @@ from tenancy.models import Tenant, TenantGroup -from utilities.testing import ViewTestCases +from utilities.testing import ViewTestCases, create_tags class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -29,6 +29,10 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Tenant Group 6,tenant-group-6,Sixth tenant group", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tenant @@ -49,7 +53,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Tenant X', diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 372308bb8..a1f46c7ec 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,6 +1,7 @@ from django.urls import path -from extras.views import ObjectChangeLogView +from extras.views import ObjectChangeLogView, ObjectJournalView +from utilities.views import SlugRedirectView from . import views from .models import Tenant, TenantGroup @@ -11,10 +12,12 @@ urlpatterns = [ path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), + path('tenant-groups/edit/', views.TenantGroupBulkEditView.as_view(), name='tenantgroup_bulk_edit'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), - path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), - path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), - path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), + path('tenant-groups//', views.TenantGroupView.as_view(), name='tenantgroup'), + path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), + path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), @@ -22,9 +25,11 @@ urlpatterns = [ path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'), path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), - path('tenants//', views.TenantView.as_view(), name='tenant'), - path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), - path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), - path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), + path('tenants//', views.TenantView.as_view(), name='tenant'), + path('tenants//', SlugRedirectView.as_view(), kwargs={'model': Tenant}), + path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), + path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), + path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), + path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}), ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 9fd77d88e..b4a29a2e6 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,11 +1,10 @@ -from django.shortcuts import get_object_or_404, render - from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation -from ipam.models import IPAddress, Prefix, VLAN, VRF +from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic +from utilities.tables import paginate_table from virtualization.models import VirtualMachine, Cluster -from . import filters, forms, tables +from . import filtersets, forms, tables from .models import Tenant, TenantGroup @@ -24,6 +23,23 @@ class TenantGroupListView(generic.ObjectListView): table = tables.TenantGroupTable +class TenantGroupView(generic.ObjectView): + queryset = TenantGroup.objects.all() + + def get_extra_context(self, request, instance): + tenants = Tenant.objects.restrict(request.user, 'view').filter( + group=instance + ) + + tenants_table = tables.TenantTable(tenants) + tenants_table.columns.hide('group') + paginate_table(tenants_table, request) + + return { + 'tenants_table': tenants_table, + } + + class TenantGroupEditView(generic.ObjectEditView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm @@ -39,6 +55,19 @@ class TenantGroupBulkImportView(generic.BulkImportView): table = tables.TenantGroupTable +class TenantGroupBulkEditView(generic.BulkEditView): + queryset = TenantGroup.objects.add_related_count( + TenantGroup.objects.all(), + Tenant, + 'group', + 'tenant_count', + cumulative=True + ) + filterset = filtersets.TenantGroupFilterSet + table = tables.TenantGroupTable + form = forms.TenantGroupBulkEditForm + + class TenantGroupBulkDeleteView(generic.BulkDeleteView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), @@ -56,7 +85,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView): class TenantListView(generic.ObjectListView): queryset = Tenant.objects.all() - filterset = filters.TenantFilterSet + filterset = filtersets.TenantFilterSet filterset_form = forms.TenantFilterForm table = tables.TenantTable @@ -72,6 +101,7 @@ class TenantView(generic.ObjectView): 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), @@ -87,7 +117,6 @@ class TenantView(generic.ObjectView): class TenantEditView(generic.ObjectEditView): queryset = Tenant.objects.all() model_form = forms.TenantForm - template_name = 'tenancy/tenant_edit.html' class TenantDeleteView(generic.ObjectDeleteView): @@ -102,12 +131,12 @@ class TenantBulkImportView(generic.BulkImportView): class TenantBulkEditView(generic.BulkEditView): queryset = Tenant.objects.prefetch_related('group') - filterset = filters.TenantFilterSet + filterset = filtersets.TenantFilterSet table = tables.TenantTable form = forms.TenantBulkEditForm class TenantBulkDeleteView(generic.BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') - filterset = filters.TenantFilterSet + filterset = filtersets.TenantFilterSet table = tables.TenantTable diff --git a/netbox/users/admin.py b/netbox/users/admin.py index e1625e1ed..98b8ef0dc 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -4,10 +4,10 @@ from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError -from django.db.models import Q -from extras.admin import order_content_types -from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig +from utilities.forms.fields import ContentTypeMultipleChoiceField +from .constants import * +from .models import ObjectPermission, Token, UserConfig # @@ -39,11 +39,11 @@ class ObjectPermissionInline(admin.TabularInline): class GroupObjectPermissionInline(ObjectPermissionInline): - model = AdminGroup.object_permissions.through + model = Group.object_permissions.through class UserObjectPermissionInline(ObjectPermissionInline): - model = AdminUser.object_permissions.through + model = User.object_permissions.through class UserConfigInline(admin.TabularInline): @@ -62,7 +62,7 @@ admin.site.unregister(Group) admin.site.unregister(User) -@admin.register(AdminGroup) +@admin.register(Group) class GroupAdmin(admin.ModelAdmin): fields = ('name',) list_display = ('name', 'user_count') @@ -75,7 +75,7 @@ class GroupAdmin(admin.ModelAdmin): return obj.user_set.count() -@admin.register(AdminUser) +@admin.register(User) class UserAdmin(UserAdmin_): list_display = [ 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' @@ -89,6 +89,7 @@ class UserAdmin(UserAdmin_): ('Important dates', {'fields': ('last_login', 'date_joined')}), ) filter_horizontal = ('groups',) + list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name') def get_inlines(self, request, obj): if obj is not None: @@ -126,6 +127,10 @@ class TokenAdmin(admin.ModelAdmin): # class ObjectPermissionForm(forms.ModelForm): + object_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES + ) can_view = forms.BooleanField(required=False) can_add = forms.BooleanField(required=False) can_change = forms.BooleanField(required=False) @@ -153,10 +158,6 @@ class ObjectPermissionForm(forms.ModelForm): # Make the actions field optional since the admin form uses it only for non-CRUD actions self.fields['actions'].required = False - # Format ContentType choices - order_content_types(self.fields['object_types']) - self.fields['object_types'].choices.insert(0, ('', '---------')) - # Order group and user fields self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 3b43ca7c9..27ef3fc82 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -17,7 +17,7 @@ class NestedGroupSerializer(WritableNestedSerializer): class Meta: model = Group - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedUserSerializer(WritableNestedSerializer): @@ -25,7 +25,7 @@ class NestedUserSerializer(WritableNestedSerializer): class Meta: model = User - fields = ['id', 'url', 'username'] + fields = ['id', 'url', 'display', 'username'] class NestedObjectPermissionSerializer(WritableNestedSerializer): @@ -39,7 +39,7 @@ class NestedObjectPermissionSerializer(WritableNestedSerializer): class Meta: model = ObjectPermission - fields = ['id', 'url', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions'] + fields = ['id', 'url', 'display', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions'] def get_groups(self, obj): return [g.name for g in obj.groups.all()] diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index eed0bd80e..054f9ba48 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -19,7 +19,7 @@ class UserSerializer(ValidatedModelSerializer): class Meta: model = User fields = ( - 'id', 'url', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', + 'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', ) extra_kwargs = { @@ -44,7 +44,7 @@ class GroupSerializer(ValidatedModelSerializer): class Meta: model = Group - fields = ('id', 'url', 'name', 'user_count') + fields = ('id', 'url', 'display', 'name', 'user_count') class ObjectPermissionSerializer(ValidatedModelSerializer): @@ -69,5 +69,6 @@ class ObjectPermissionSerializer(ValidatedModelSerializer): class Meta: model = ObjectPermission fields = ( - 'id', 'url', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions', 'constraints', + 'id', 'url', 'display', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions', + 'constraints', ) diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 7773e54f4..b0443b87e 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -6,7 +6,7 @@ from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet from netbox.api.views import ModelViewSet -from users import filters +from users import filtersets from users.models import ObjectPermission, UserConfig from utilities.querysets import RestrictedQuerySet from utilities.utils import deepmerge @@ -28,13 +28,13 @@ class UsersRootView(APIRootView): class UserViewSet(ModelViewSet): queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username') serializer_class = serializers.UserSerializer - filterset_class = filters.UserFilterSet + filterset_class = filtersets.UserFilterSet class GroupViewSet(ModelViewSet): queryset = RestrictedQuerySet(model=Group).annotate(user_count=Count('user')).order_by('name') serializer_class = serializers.GroupSerializer - filterset_class = filters.GroupFilterSet + filterset_class = filtersets.GroupFilterSet # @@ -44,7 +44,7 @@ class GroupViewSet(ModelViewSet): class ObjectPermissionViewSet(ModelViewSet): queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users') serializer_class = serializers.ObjectPermissionSerializer - filterset_class = filters.ObjectPermissionFilterSet + filterset_class = filtersets.ObjectPermissionFilterSet # diff --git a/netbox/users/constants.py b/netbox/users/constants.py new file mode 100644 index 000000000..e6917c482 --- /dev/null +++ b/netbox/users/constants.py @@ -0,0 +1,8 @@ +from django.db.models import Q + + +OBJECTPERMISSION_OBJECT_TYPES = Q( + ~Q(app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) | + Q(app_label='auth', model__in=['group', 'user']) | + Q(app_label='users', model__in=['objectpermission', 'token']) +) diff --git a/netbox/users/filters.py b/netbox/users/filtersets.py similarity index 98% rename from netbox/users/filters.py rename to netbox/users/filtersets.py index 359cf9cc7..6625cba36 100644 --- a/netbox/users/filters.py +++ b/netbox/users/filtersets.py @@ -2,8 +2,8 @@ import django_filters from django.contrib.auth.models import Group, User from django.db.models import Q +from netbox.filtersets import BaseFilterSet from users.models import ObjectPermission -from utilities.filters import BaseFilterSet __all__ = ( 'GroupFilterSet', diff --git a/netbox/users/migrations/0011_standardize_models.py b/netbox/users/migrations/0011_standardize_models.py new file mode 100644 index 000000000..08d1103ed --- /dev/null +++ b/netbox/users/migrations/0011_standardize_models.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0010_update_jsonfield'), + ] + + operations = [ + migrations.AlterField( + model_name='objectpermission', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='token', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index b25a75134..4a8274ab4 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -6,18 +6,17 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import MinLengthValidator from django.db import models -from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict +from .constants import * __all__ = ( - 'AdminGroup', - 'AdminUser', 'ObjectPermission', 'Token', 'UserConfig', @@ -162,7 +161,6 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) -@receiver(post_save, sender=AdminUser) def create_userconfig(instance, created, **kwargs): """ Automatically create a new UserConfig when a new User is created. @@ -175,7 +173,7 @@ def create_userconfig(instance, created, **kwargs): # REST API # -class Token(models.Model): +class Token(BigIDModel): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. @@ -233,7 +231,7 @@ class Token(models.Model): # Permissions # -class ObjectPermission(models.Model): +class ObjectPermission(BigIDModel): """ A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects identified by ORM query parameters. @@ -250,11 +248,7 @@ class ObjectPermission(models.Model): ) object_types = models.ManyToManyField( to=ContentType, - limit_choices_to=Q( - ~Q(app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) | - Q(app_label='auth', model__in=['group', 'user']) | - Q(app_label='users', model__in=['objectpermission', 'token']) - ), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, related_name='object_permissions' ) groups = models.ManyToManyField( diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 11d4e58cd..c2488f2f6 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -20,7 +20,7 @@ class AppTest(APITestCase): class UserTest(APIViewTestCases.APIViewTestCase): model = User view_namespace = 'users' - brief_fields = ['id', 'url', 'username'] + brief_fields = ['display', 'id', 'url', 'username'] validation_excluded_fields = ['password'] create_data = [ { @@ -51,7 +51,7 @@ class UserTest(APIViewTestCases.APIViewTestCase): class GroupTest(APIViewTestCases.APIViewTestCase): model = Group view_namespace = 'users' - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { 'name': 'Group 4', @@ -77,7 +77,7 @@ class GroupTest(APIViewTestCases.APIViewTestCase): class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): model = ObjectPermission - brief_fields = ['actions', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users'] + brief_fields = ['actions', 'display', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users'] @classmethod def setUpTestData(cls): diff --git a/netbox/users/tests/test_filters.py b/netbox/users/tests/test_filtersets.py similarity index 89% rename from netbox/users/tests/test_filters.py rename to netbox/users/tests/test_filtersets.py index 7130fa03e..0b325c4b6 100644 --- a/netbox/users/tests/test_filters.py +++ b/netbox/users/tests/test_filtersets.py @@ -2,11 +2,12 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from users.filters import GroupFilterSet, ObjectPermissionFilterSet, UserFilterSet +from users.filtersets import GroupFilterSet, ObjectPermissionFilterSet, UserFilterSet from users.models import ObjectPermission +from utilities.testing import BaseFilterSetTests -class UserTestCase(TestCase): +class UserTestCase(TestCase, BaseFilterSetTests): queryset = User.objects.all() filterset = UserFilterSet @@ -59,10 +60,6 @@ class UserTestCase(TestCase): users[1].groups.set([groups[1]]) users[2].groups.set([groups[2]]) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_username(self): params = {'username': ['User1', 'User2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -95,7 +92,7 @@ class UserTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class GroupTestCase(TestCase): +class GroupTestCase(TestCase, BaseFilterSetTests): queryset = Group.objects.all() filterset = GroupFilterSet @@ -109,16 +106,12 @@ class GroupTestCase(TestCase): ) Group.objects.bulk_create(groups) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Group 1', 'Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ObjectPermissionTestCase(TestCase): +class ObjectPermissionTestCase(TestCase, BaseFilterSetTests): queryset = ObjectPermission.objects.all() filterset = ObjectPermissionFilterSet @@ -160,10 +153,6 @@ class ObjectPermissionTestCase(TestCase): permissions[i].users.set([users[i]]) permissions[i].object_types.set([object_types[i]]) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Permission 1', 'Permission 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/views.py b/netbox/users/views.py index a6d28ecd2..cf7ed6430 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -6,7 +6,7 @@ from django.contrib.auth import login as auth_login, logout as auth_logout, upda from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in -from django.http import HttpResponseForbidden, HttpResponseRedirect +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.decorators import method_decorator @@ -282,13 +282,9 @@ class TokenEditView(LoginRequiredMixin, View): def get(self, request, pk=None): - if pk is not None: - if not request.user.has_perm('users.change_token'): - return HttpResponseForbidden() + if pk: token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) else: - if not request.user.has_perm('users.add_token'): - return HttpResponseForbidden() token = Token(user=request.user) form = TokenForm(instance=token) @@ -302,11 +298,11 @@ class TokenEditView(LoginRequiredMixin, View): def post(self, request, pk=None): - if pk is not None: + if pk: token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) form = TokenForm(request.POST, instance=token) else: - token = Token() + token = Token(user=request.user) form = TokenForm(request.POST) if form.is_valid(): @@ -314,7 +310,7 @@ class TokenEditView(LoginRequiredMixin, View): token.user = request.user token.save() - msg = "Modified token {}".format(token) if pk else "Created token {}".format(token) + msg = f"Modified token {token}" if pk else f"Created token {token}" messages.success(request, msg) if '_addanother' in request.POST: diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 3d1002105..2d8e27acf 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -130,22 +130,24 @@ class ColorChoices(ChoiceSet): class ButtonColorChoices(ChoiceSet): """ - Map standard button color choices to Bootstrap color classes + Map standard button color choices to Bootstrap 3 button classes """ DEFAULT = 'default' BLUE = 'primary' - GREY = 'secondary' + CYAN = 'info' GREEN = 'success' RED = 'danger' YELLOW = 'warning' + GREY = 'secondary' BLACK = 'dark' CHOICES = ( (DEFAULT, 'Default'), (BLUE, 'Blue'), - (GREY, 'Grey'), + (CYAN, 'Cyan'), (GREEN, 'Green'), (RED, 'Red'), (YELLOW, 'Yellow'), + (GREY, 'Grey'), (BLACK, 'Black') ) diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 8cf047c42..c3fbd0687 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -11,7 +11,8 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict( isw='istartswith', nisw='istartswith', ie='iexact', - nie='iexact' + nie='iexact', + empty='empty', ) FILTER_NUMERIC_BASED_LOOKUP_MAP = dict( diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 77a915d9c..4ba62bc01 100644 --- a/netbox/utilities/exceptions.py +++ b/netbox/utilities/exceptions.py @@ -9,6 +9,14 @@ class AbortTransaction(Exception): pass +class PermissionsViolation(Exception): + """ + Raised when an operation was prevented because it would violate the + allowed permissions. + """ + pass + + class RQWorkerNotRunningException(APIException): """ Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 6305c0bba..ed71afc1b 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,17 +1,9 @@ import django_filters -from django_filters.constants import EMPTY_VALUES -from copy import deepcopy -from dcim.forms import MACAddressField from django import forms from django.conf import settings -from django.db import models -from django_filters.utils import get_model_field, resolve_field +from django_filters.constants import EMPTY_VALUES -from extras.models import Tag -from utilities.constants import ( - FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, - FILTER_NUMERIC_BASED_LOOKUP_MAP -) +from dcim.forms import MACAddressField def multivalue_field_factory(field_class): @@ -91,21 +83,6 @@ class NullableCharFieldFilter(django_filters.CharFilter): return qs.distinct() if self.distinct else qs -class TagFilter(django_filters.ModelMultipleChoiceFilter): - """ - Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered - to objects matching all tags. - """ - def __init__(self, *args, **kwargs): - - kwargs.setdefault('field_name', 'tags__slug') - kwargs.setdefault('to_field_name', 'slug') - kwargs.setdefault('conjoined', True) - kwargs.setdefault('queryset', Tag.objects.all()) - - super().__init__(*args, **kwargs) - - class NumericArrayFilter(django_filters.NumberFilter): """ Filter based on the presence of an integer within an ArrayField. @@ -134,182 +111,3 @@ class ContentTypeFilter(django_filters.CharFilter): f'{self.field_name}__model': model } ) - - -# -# FilterSets -# - -class BaseFilterSet(django_filters.FilterSet): - """ - A base filterset which provides common functionaly to all NetBox filtersets - """ - FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) - FILTER_DEFAULTS.update({ - models.AutoField: { - 'filter_class': MultiValueNumberFilter - }, - models.CharField: { - 'filter_class': MultiValueCharFilter - }, - models.DateField: { - 'filter_class': MultiValueDateFilter - }, - models.DateTimeField: { - 'filter_class': MultiValueDateTimeFilter - }, - models.DecimalField: { - 'filter_class': MultiValueNumberFilter - }, - models.EmailField: { - 'filter_class': MultiValueCharFilter - }, - models.FloatField: { - 'filter_class': MultiValueNumberFilter - }, - models.IntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.PositiveIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.PositiveSmallIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.SlugField: { - 'filter_class': MultiValueCharFilter - }, - models.SmallIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.TimeField: { - 'filter_class': MultiValueTimeFilter - }, - models.URLField: { - 'filter_class': MultiValueCharFilter - }, - MACAddressField: { - 'filter_class': MultiValueMACAddressFilter - }, - }) - - @staticmethod - def _get_filter_lookup_dict(existing_filter): - # Choose the lookup expression map based on the filter type - if isinstance(existing_filter, ( - MultiValueDateFilter, - MultiValueDateTimeFilter, - MultiValueNumberFilter, - MultiValueTimeFilter - )): - lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP - - elif isinstance(existing_filter, ( - TreeNodeMultipleChoiceFilter, - )): - # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression - lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP - - elif isinstance(existing_filter, ( - django_filters.ModelChoiceFilter, - django_filters.ModelMultipleChoiceFilter, - TagFilter - )) or existing_filter.extra.get('choices'): - # These filter types support only negation - lookup_map = FILTER_NEGATION_LOOKUP_MAP - - elif isinstance(existing_filter, ( - django_filters.filters.CharFilter, - django_filters.MultipleChoiceFilter, - MultiValueCharFilter, - MultiValueMACAddressFilter - )): - lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP - - else: - lookup_map = None - - return lookup_map - - @classmethod - def get_filters(cls): - """ - Override filter generation to support dynamic lookup expressions for certain filter types. - - For specific filter types, new filters are created based on defined lookup expressions in - the form `__` - """ - filters = super().get_filters() - - new_filters = {} - for existing_filter_name, existing_filter in filters.items(): - # Loop over existing filters to extract metadata by which to create new filters - - # If the filter makes use of a custom filter method or lookup expression skip it - # as we cannot sanely handle these cases in a generic mannor - if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: - continue - - # Choose the lookup expression map based on the filter type - lookup_map = cls._get_filter_lookup_dict(existing_filter) - if lookup_map is None: - # Do not augment this filter type with more lookup expressions - continue - - # Get properties of the existing filter for later use - field_name = existing_filter.field_name - field = get_model_field(cls._meta.model, field_name) - - # Create new filters for each lookup expression in the map - for lookup_name, lookup_expr in lookup_map.items(): - new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) - - try: - if existing_filter_name in cls.declared_filters: - # The filter field has been explicity defined on the filterset class so we must manually - # create the new filter with the same type because there is no guarantee the defined type - # is the same as the default type for the field - resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid - new_filter = type(existing_filter)( - field_name=field_name, - lookup_expr=lookup_expr, - label=existing_filter.label, - exclude=existing_filter.exclude, - distinct=existing_filter.distinct, - **existing_filter.extra - ) - else: - # The filter field is listed in Meta.fields so we can safely rely on default behaviour - # Will raise FieldLookupError if the lookup is invalid - new_filter = cls.filter_for_field(field, field_name, lookup_expr) - except django_filters.exceptions.FieldLookupError: - # The filter could not be created because the lookup expression is not supported on the field - continue - - if lookup_name.startswith('n'): - # This is a negation filter which requires a queryset.exclude() clause - # Of course setting the negation of the existing filter's exclude attribute handles both cases - new_filter.exclude = not existing_filter.exclude - - new_filters[new_filter_name] = new_filter - - filters.update(new_filters) - return filters - - -class NameSlugSearchFilterSet(django_filters.FilterSet): - """ - A base class for adding the search method to models which only expose the `name` and `slug` fields - """ - q = django_filters.CharFilter( - method='search', - label='Search', - ) - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - models.Q(name__icontains=value) | - models.Q(slug__icontains=value) - ) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 5f1e06c3b..233549e0e 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -13,17 +13,22 @@ from django.forms import BoundField from django.urls import reverse from utilities.choices import unpack_grouped_choices +from utilities.utils import content_type_name from utilities.validators import EnhancedURLValidator from . import widgets from .constants import * -from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern +from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, parse_csv, validate_csv __all__ = ( 'CommentField', + 'ContentTypeChoiceField', + 'ContentTypeMultipleChoiceField', 'CSVChoiceField', 'CSVContentTypeField', 'CSVDataField', + 'CSVFileField', 'CSVModelChoiceField', + 'CSVTypedChoiceField', 'DynamicModelChoiceField', 'DynamicModelMultipleChoiceField', 'ExpandableIPAddressField', @@ -35,6 +40,109 @@ __all__ = ( ) +class CommentField(forms.CharField): + """ + A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. + """ + widget = forms.Textarea + default_label = '' + # TODO: Port Markdown cheat sheet to internal documentation + default_helptext = ' '\ + ''\ + 'Markdown syntax is supported' + + def __init__(self, *args, **kwargs): + required = kwargs.pop('required', False) + label = kwargs.pop('label', self.default_label) + help_text = kwargs.pop('help_text', self.default_helptext) + super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) + + +class SlugField(forms.SlugField): + """ + Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. + """ + def __init__(self, slug_source='name', *args, **kwargs): + label = kwargs.pop('label', "Slug") + help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") + widget = kwargs.pop('widget', widgets.SlugWidget) + super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) + self.widget.attrs['slug-source'] = slug_source + + +class TagFilterField(forms.MultipleChoiceField): + """ + A filter field for the tags of a model. Only the tags used by a model are displayed. + + :param model: The model of the filter + """ + widget = widgets.StaticSelect2Multiple + + def __init__(self, model, *args, **kwargs): + def get_choices(): + tags = model.tags.annotate( + count=Count('extras_taggeditem_items') + ).order_by('name') + return [ + (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags + ] + + # Choices are fetched each time the form is initialized + super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) + + +class LaxURLField(forms.URLField): + """ + Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names + (e.g. http://myserver/ is valid) + """ + default_validators = [EnhancedURLValidator()] + + +class JSONField(_JSONField): + """ + Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Enter context data in JSON format.' + self.widget.attrs['placeholder'] = '' + + def prepare_value(self, value): + if isinstance(value, InvalidJSONInput): + return value + if value is None: + return '' + return json.dumps(value, sort_keys=True, indent=4) + + +class ContentTypeChoiceMixin: + + def __init__(self, queryset, *args, **kwargs): + # Order ContentTypes by app_label + queryset = queryset.order_by('app_label', 'model') + super().__init__(queryset, *args, **kwargs) + + def label_from_instance(self, obj): + try: + return content_type_name(obj) + except AttributeError: + return super().label_from_instance(obj) + + +class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): + pass + + +class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField): + pass + + +# +# CSV fields +# + class CSVDataField(forms.CharField): """ A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first @@ -67,49 +175,54 @@ class CSVDataField(forms.CharField): 'in double quotes.' def to_python(self, value): - - records = [] reader = csv.reader(StringIO(value.strip())) - # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional - # "to" field specifying how the related object is being referenced. For example, importing a Device might use a - # `site.slug` header, to indicate the related site is being referenced by its slug. - headers = {} - for header in next(reader): - if '.' in header: - field, to_field = header.split('.', 1) - headers[field] = to_field - else: - headers[header] = None + return parse_csv(reader) - # Parse CSV rows into a list of dictionaries mapped from the column headers. - for i, row in enumerate(reader, start=1): - if len(row) != len(headers): - raise forms.ValidationError( - f"Row {i}: Expected {len(headers)} columns but found {len(row)}" - ) - row = [col.strip() for col in row] - record = dict(zip(headers.keys(), row)) - records.append(record) + def validate(self, value): + headers, records = value + validate_csv(headers, self.fields, self.required_fields) + + return value + + +class CSVFileField(forms.FileField): + """ + A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns + data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute + by which they match a related object (where applicable). The second item is a list of dictionaries, each + representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. + """ + + def __init__(self, from_form, *args, **kwargs): + + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] + + super().__init__(*args, **kwargs) + + def to_python(self, file): + if file is None: + return None + + csv_str = file.read().decode('utf-8').strip() + reader = csv.reader(csv_str.splitlines()) + headers, records = parse_csv(reader) return headers, records def validate(self, value): + if value is None: + return None + headers, records = value - - # Validate provided column headers - for field, to_field in headers.items(): - if field not in self.fields: - raise forms.ValidationError(f'Unexpected column header "{field}" found.') - if to_field and not hasattr(self.fields[field], 'to_field_name'): - raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') - if to_field and not hasattr(self.fields[field].queryset.model, to_field): - raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') - - # Validate required fields - for f in self.required_fields: - if f not in headers: - raise forms.ValidationError(f'Required column header "{f}" not found.') + validate_csv(headers, self.fields, self.required_fields) return value @@ -125,6 +238,10 @@ class CSVChoiceField(forms.ChoiceField): self.choices = unpack_grouped_choices(choices) +class CSVTypedChoiceField(forms.TypedChoiceField): + STATIC_CHOICES = True + + class CSVModelChoiceField(forms.ModelChoiceField): """ Provides additional validation for model choices entered as CSV data. @@ -152,6 +269,8 @@ class CSVContentTypeField(CSVModelChoiceField): return f'{value.app_label}.{value.model}' def to_python(self, value): + if not value: + return None try: app_label, model = value.split('.') except ValueError: @@ -162,6 +281,10 @@ class CSVContentTypeField(CSVModelChoiceField): raise forms.ValidationError(f'Invalid object type') +# +# Expansion fields +# + class ExpandableNameField(forms.CharField): """ A field which allows for numeric range expansion @@ -207,56 +330,9 @@ class ExpandableIPAddressField(forms.CharField): return [value] -class CommentField(forms.CharField): - """ - A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. - """ - widget = forms.Textarea - default_label = '' - # TODO: Port Markdown cheat sheet to internal documentation - default_helptext = ' '\ - ''\ - 'Markdown syntax is supported' - - def __init__(self, *args, **kwargs): - required = kwargs.pop('required', False) - label = kwargs.pop('label', self.default_label) - help_text = kwargs.pop('help_text', self.default_helptext) - super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) - - -class SlugField(forms.SlugField): - """ - Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. - """ - def __init__(self, slug_source='name', *args, **kwargs): - label = kwargs.pop('label', "Slug") - help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") - widget = kwargs.pop('widget', widgets.SlugWidget) - super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) - self.widget.attrs['slug-source'] = slug_source - - -class TagFilterField(forms.MultipleChoiceField): - """ - A filter field for the tags of a model. Only the tags used by a model are displayed. - - :param model: The model of the filter - """ - widget = widgets.StaticSelect2Multiple - - def __init__(self, model, *args, **kwargs): - def get_choices(): - tags = model.tags.annotate( - count=Count('extras_taggeditem_items') - ).order_by('name') - return [ - (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags - ] - - # Choices are fetched each time the form is initialized - super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) - +# +# Dynamic fields +# class DynamicModelChoiceMixin: """ @@ -266,19 +342,18 @@ class DynamicModelChoiceMixin: :param null_option: The string used to represent a null selection (if any) :param disabled_indicator: The name of the field which, if populated, will disable selection of the choice (optional) - :param brief_mode: Use the "brief" format (?brief=true) when making API requests (default) """ filter = django_filters.ModelChoiceFilter widget = widgets.APISelect - def __init__(self, display_field='name', query_params=None, initial_params=None, null_option=None, - disabled_indicator=None, brief_mode=True, *args, **kwargs): + # TODO: Remove display_field in v3.0 + def __init__(self, display_field='display', query_params=None, initial_params=None, null_option=None, + disabled_indicator=None, *args, **kwargs): self.display_field = display_field self.query_params = query_params or {} self.initial_params = initial_params or {} self.null_option = null_option self.disabled_indicator = disabled_indicator - self.brief_mode = brief_mode # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference # by widget_attrs() @@ -303,10 +378,6 @@ class DynamicModelChoiceMixin: if self.disabled_indicator is not None: attrs['disabled-indicator'] = self.disabled_indicator - # Toggle brief mode - if not self.brief_mode: - attrs['data-full'] = 'true' - # Attach any static query parameters for key, value in self.query_params.items(): widget.add_query_param(key, value) @@ -373,29 +444,3 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip """ filter = django_filters.ModelMultipleChoiceFilter widget = widgets.APISelectMultiple - - -class LaxURLField(forms.URLField): - """ - Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names - (e.g. http://myserver/ is valid) - """ - default_validators = [EnhancedURLValidator()] - - -class JSONField(_JSONField): - """ - Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Enter context data in JSON format.' - self.widget.attrs['placeholder'] = '' - - def prepare_value(self, value): - if isinstance(value, InvalidJSONInput): - return value - if value is None: - return '' - return json.dumps(value, sort_keys=True, indent=4) diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index e674afdf7..1fd1346d4 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -161,13 +161,21 @@ class TableConfigForm(BootstrapMixin, forms.Form): """ Form for configuring user's table preferences. """ + available_columns = forms.MultipleChoiceField( + choices=[], + required=False, + widget=forms.SelectMultiple( + attrs={'size': 10} + ), + label='Available columns' + ) columns = forms.MultipleChoiceField( choices=[], required=False, widget=forms.SelectMultiple( attrs={'size': 10} ), - help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display." + label='Selected columns' ) def __init__(self, table, *args, **kwargs): @@ -176,8 +184,8 @@ class TableConfigForm(BootstrapMixin, forms.Form): super().__init__(*args, **kwargs) # Initialize columns field based on table attributes - self.fields['columns'].choices = table.configurable_columns - self.fields['columns'].initial = table.visible_columns + self.fields['available_columns'].choices = table.available_columns + self.fields['columns'].choices = table.selected_columns @property def table_name(self): diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index dc001be1a..90df55b9c 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -14,6 +14,8 @@ __all__ = ( 'parse_alphanumeric_range', 'parse_numeric_range', 'restrict_form_fields', + 'parse_csv', + 'validate_csv', ) @@ -30,7 +32,10 @@ def parse_numeric_range(string, base=10): begin, end = dash_range.split('-') except ValueError: begin, end = dash_range, dash_range - begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1 + try: + begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1 + except ValueError: + raise forms.ValidationError(f'Range "{dash_range}" is invalid.') values.extend(range(begin, end)) return list(set(values)) @@ -62,7 +67,7 @@ def parse_alphanumeric_range(string): else: # Not a valid range (more than a single character) if not len(begin) == len(end) == 1: - raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range)) + raise forms.ValidationError(f'Range "{dash_range}" is invalid.') for n in list(range(ord(begin), ord(end) + 1)): values.append(chr(n)) return values @@ -134,3 +139,55 @@ def restrict_form_fields(form, user, action='view'): for field in form.fields.values(): if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet): field.queryset = field.queryset.restrict(user, action) + + +def parse_csv(reader): + """ + Parse a csv_reader object into a headers dictionary and a list of records dictionaries. Raise an error + if the records are formatted incorrectly. Return headers and records as a tuple. + """ + records = [] + headers = {} + + # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional + # "to" field specifying how the related object is being referenced. For example, importing a Device might use a + # `site.slug` header, to indicate the related site is being referenced by its slug. + + for header in next(reader): + if '.' in header: + field, to_field = header.split('.', 1) + headers[field] = to_field + else: + headers[header] = None + + # Parse CSV rows into a list of dictionaries mapped from the column headers. + for i, row in enumerate(reader, start=1): + if len(row) != len(headers): + raise forms.ValidationError( + f"Row {i}: Expected {len(headers)} columns but found {len(row)}" + ) + row = [col.strip() for col in row] + record = dict(zip(headers.keys(), row)) + records.append(record) + + return headers, records + + +def validate_csv(headers, fields, required_fields): + """ + Validate that parsed csv data conforms to the object's available fields. Raise validation errors + if parsed csv data contains invalid headers or does not contain required headers. + """ + # Validate provided column headers + for field, to_field in headers.items(): + if field not in fields: + raise forms.ValidationError(f'Unexpected column header "{field}" found.') + if to_field and not hasattr(fields[field], 'to_field_name'): + raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') + if to_field and not hasattr(fields[field].queryset.model, to_field): + raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') + + # Validate required fields + for f in required_fields: + if f not in headers: + raise forms.ValidationError(f'Required column header "{f}" not found.') diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 1c456c74c..11418fd42 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -16,6 +16,7 @@ __all__ = ( 'DatePicker', 'DateTimePicker', 'NumericArrayField', + 'SelectSpeedWidget', 'SelectWithDisabled', 'SelectWithPK', 'SlugWidget', @@ -111,6 +112,13 @@ class ContentTypeSelect(StaticSelect2): option_template_name = 'widgets/select_contenttype.html' +class SelectSpeedWidget(forms.NumberInput): + """ + Speed field with dropdown selections for convenience. + """ + template_name = 'widgets/select_speed.html' + + class NumericArrayField(SimpleArrayField): def to_python(self, value): diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index cdad1f230..3b9e1cb37 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -4,7 +4,9 @@ from django.core.paginator import Paginator, Page class EnhancedPaginator(Paginator): - def __init__(self, object_list, per_page, **kwargs): + def __init__(self, object_list, per_page, orphans=None, **kwargs): + + # Determine the page size try: per_page = int(per_page) if per_page < 1: @@ -12,7 +14,13 @@ class EnhancedPaginator(Paginator): except ValueError: per_page = settings.PAGINATE_COUNT - super().__init__(object_list, per_page, **kwargs) + # Set orphans count based on page size + if orphans is None and per_page <= 50: + orphans = 5 + elif orphans is None: + orphans = 10 + + super().__init__(object_list, per_page, orphans=orphans, **kwargs) def _get_page(self, *args, **kwargs): return EnhancedPage(*args, **kwargs) diff --git a/netbox/utilities/query_functions.py b/netbox/utilities/query_functions.py index abdc61b6b..8ad7ceead 100644 --- a/netbox/utilities/query_functions.py +++ b/netbox/utilities/query_functions.py @@ -1,5 +1,4 @@ from django.contrib.postgres.aggregates import JSONBAgg -from django.contrib.postgres.aggregates.mixins import OrderableAggMixin from django.db.models import F, Func @@ -11,19 +10,10 @@ class CollateAsChar(Func): template = '(%(expressions)s) COLLATE "%(function)s"' -class OrderableJSONBAgg(OrderableAggMixin, JSONBAgg): - """ - TODO in Django 3.2 ordering is supported natively on JSONBAgg so this is no longer needed. - """ - template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)' - - -class EmptyGroupByJSONBAgg(OrderableJSONBAgg): +class EmptyGroupByJSONBAgg(JSONBAgg): """ JSONBAgg is a builtin aggregation function which means it includes the use of a GROUP BY clause. When used as an annotation for collecting config context data objects, the GROUP BY is incorrect. This subclass overrides the Django ORM aggregation control to remove the GROUP BY. - - TODO in Django 3.2 ordering is supported natively on JSONBAgg so we only need to inherit from JSONBAgg. """ contains_aggregate = False diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index bf087f2c9..b0797fa50 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,11 +1,18 @@ import django_tables2 as tables +from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist from django.db.models.fields.related import RelatedField from django.urls import reverse from django.utils.safestring import mark_safe +from django_tables2 import RequestConfig from django_tables2.data import TableQuerysetData +from django_tables2.utils import Accessor + +from extras.models import CustomField +from .paginator import EnhancedPaginator, get_paginate_count class BaseTable(tables.Table): @@ -14,17 +21,23 @@ class BaseTable(tables.Table): :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. """ + class Meta: attrs = { 'class': 'table table-hover table-headings', } def __init__(self, *args, user=None, **kwargs): + # Add custom field columns + obj_type = ContentType.objects.get_for_model(self._meta.model) + for cf in CustomField.objects.filter(content_types=obj_type): + self.base_columns[f'cf_{cf.name}'] = CustomFieldColumn(cf) + super().__init__(*args, **kwargs) # Set default empty_text if none was provided if self.empty_text is None: - self.empty_text = 'No {} found'.format(self._meta.model._meta.verbose_name_plural) + self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" # Hide non-default columns default_columns = getattr(self.Meta, 'default_columns', list()) @@ -57,6 +70,7 @@ class BaseTable(tables.Table): # Dynamically update the table's QuerySet to ensure related fields are pre-fetched if isinstance(self.data, TableQuerysetData): + prefetch_fields = [] for column in self.columns: if column.visible: @@ -80,19 +94,20 @@ class BaseTable(tables.Table): prefetch_fields.append('__'.join(prefetch_path)) self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) - @property - def configurable_columns(self): - selected_columns = [ - (name, self.columns[name].verbose_name) for name in self.sequence if name not in ['pk', 'actions'] - ] - available_columns = [ - (name, column.verbose_name) for name, column in self.columns.items() if name not in self.sequence and name not in ['pk', 'actions'] - ] - return selected_columns + available_columns + def _get_columns(self, visible=True): + columns = [] + for name, column in self.columns.items(): + if column.visible == visible and name not in ['pk', 'actions']: + columns.append((name, column.verbose_name)) + return columns @property - def visible_columns(self): - return [name for name in self.sequence if self.columns[name].visible] + def available_columns(self): + return self._get_columns(visible=False) + + @property + def selected_columns(self): + return self._get_columns(visible=True) # @@ -133,6 +148,9 @@ class BooleanColumn(tables.Column): rendered = '' return mark_safe(rendered) + def value(self, value): + return str(value) + class ButtonsColumn(tables.TemplateColumn): """ @@ -147,24 +165,23 @@ class ButtonsColumn(tables.TemplateColumn): # Note that braces are escaped to allow for string formatting prior to template rendering template_code = """ {{% if "changelog" in buttons %}} - + {{% endif %}} {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} - + {{% endif %}} {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} - + {{% endif %}} """ - def __init__(self, model, *args, pk_field='pk', buttons=None, prepend_template=None, return_url_extra='', - **kwargs): + def __init__(self, model, *args, buttons=None, prepend_template=None, return_url_extra='', **kwargs): if prepend_template: prepend_template = prepend_template.replace('{', '{{') prepend_template = prepend_template.replace('}', '}}') @@ -173,12 +190,15 @@ class ButtonsColumn(tables.TemplateColumn): template_code = self.template_code.format( app_label=model._meta.app_label, model_name=model._meta.model_name, - pk_field=pk_field, buttons=buttons ) super().__init__(template_code=template_code, *args, **kwargs) + # Exclude from export by default + if 'exclude_from_export' not in kwargs: + self.exclude_from_export = True + self.extra_context.update({ 'buttons': buttons or self.buttons, 'return_url_extra': return_url_extra, @@ -203,6 +223,20 @@ class ChoiceFieldColumn(tables.Column): ) return self.default + def value(self, value): + return value + + +class ContentTypeColumn(tables.Column): + """ + Display a ContentType instance. + """ + def render(self, value): + return value.name[0].upper() + value.name[1:] + + def value(self, value): + return f"{value.app_label}.{value.model}" + class ColorColumn(tables.Column): """ @@ -213,6 +247,9 @@ class ColorColumn(tables.Column): f' ' ) + def value(self, value): + return f'#{value}' + class ColoredLabelColumn(tables.TemplateColumn): """ @@ -226,6 +263,9 @@ class ColoredLabelColumn(tables.TemplateColumn): def __init__(self, *args, **kwargs): super().__init__(template_code=self.template_code, *args, **kwargs) + def value(self, value): + return str(value) + class LinkedCountColumn(tables.Column): """ @@ -245,10 +285,16 @@ class LinkedCountColumn(tables.Column): if value: url = reverse(self.viewname, kwargs=self.view_kwargs) if self.url_params: - url += '?' + '&'.join([f'{k}={getattr(record, v)}' for k, v in self.url_params.items()]) + url += '?' + '&'.join([ + f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}' + for k, v in self.url_params.items() + ]) return mark_safe(f'{value}') return value + def value(self, value): + return value + class TagColumn(tables.TemplateColumn): """ @@ -267,3 +313,75 @@ class TagColumn(tables.TemplateColumn): template_code=self.template_code, extra_context={'url_name': url_name} ) + + def value(self, value): + return ",".join([tag.name for tag in value.all()]) + + +class CustomFieldColumn(tables.Column): + """ + Display custom fields in the appropriate format. + """ + def __init__(self, customfield, *args, **kwargs): + self.customfield = customfield + kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}') + if 'verbose_name' not in kwargs: + kwargs['verbose_name'] = customfield.label or customfield.name + + super().__init__(*args, **kwargs) + + def render(self, value): + if isinstance(value, list): + return ', '.join(v for v in value) + return value or self.default + + +class MPTTColumn(tables.TemplateColumn): + """ + Display a nested hierarchy for MPTT-enabled models. + """ + template_code = """ + {% load helpers %} + {% for i in record.level|as_range %}{% endfor %} + {{ record.name }} + """ + + def __init__(self, *args, **kwargs): + super().__init__( + template_code=self.template_code, + orderable=False, + attrs={'td': {'class': 'text-nowrap'}}, + *args, + **kwargs + ) + + def value(self, value): + return value + + +class UtilizationColumn(tables.TemplateColumn): + """ + Display a colored utilization bar graph. + """ + template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}""" + + def __init__(self, *args, **kwargs): + super().__init__(template_code=self.template_code, *args, **kwargs) + + def value(self, value): + return f'{value}%' + + +# +# Pagination +# + +def paginate_table(table, request): + """ + Paginate a table given a request context. + """ + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(table) diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html index a6ec52d1f..5a8f89758 100644 --- a/netbox/utilities/templates/buttons/export.html +++ b/netbox/utilities/templates/buttons/export.html @@ -1,19 +1,20 @@ -{% if export_templates %} -
    - - -
    -{% else %} - - Export - -{% endif %} \ No newline at end of file +
    + + +
    diff --git a/netbox/utilities/templates/widgets/select_speed.html b/netbox/utilities/templates/widgets/select_speed.html new file mode 100644 index 000000000..8e259ca8d --- /dev/null +++ b/netbox/utilities/templates/widgets/select_speed.html @@ -0,0 +1,20 @@ +
    + {% include 'django/forms/widgets/number.html' %} + + + + +
    diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index e7f00cacf..e7ebd26a0 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -40,16 +40,9 @@ def clone_button(instance): @register.inclusion_tag('buttons/edit.html') -def edit_button(instance, use_pk=False): +def edit_button(instance): viewname = _get_viewname(instance, 'edit') - - # Assign kwargs - if hasattr(instance, 'slug') and not use_pk: - kwargs = {'slug': instance.slug} - else: - kwargs = {'pk': instance.pk} - - url = reverse(viewname, kwargs=kwargs) + url = reverse(viewname, kwargs={'pk': instance.pk}) return { 'url': url, @@ -57,16 +50,9 @@ def edit_button(instance, use_pk=False): @register.inclusion_tag('buttons/delete.html') -def delete_button(instance, use_pk=False): +def delete_button(instance): viewname = _get_viewname(instance, 'delete') - - # Assign kwargs - if hasattr(instance, 'slug') and not use_pk: - kwargs = {'slug': instance.slug} - else: - kwargs = {'pk': instance.pk} - - url = reverse(viewname, kwargs=kwargs) + url = reverse(viewname, kwargs={'pk': instance.pk}) return { 'url': url, @@ -96,13 +82,18 @@ def import_button(url): @register.inclusion_tag('buttons/export.html', takes_context=True) def export_button(context, content_type=None): + add_exporttemplate_link = None + if content_type is not None: user = context['request'].user export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) + if user.is_staff and user.has_perm('extras.add_exporttemplate'): + add_exporttemplate_link = f"{reverse('admin:extras_exporttemplate_add')}?content_type={content_type.pk}" else: export_templates = [] return { 'url_params': context['request'].GET, 'export_templates': export_templates, + 'add_exporttemplate_link': add_exporttemplate_link, } diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index d3451ce86..9d46e8c37 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -4,6 +4,14 @@ from django import template register = template.Library() +@register.filter() +def getfield(form, fieldname): + """ + Return the specified field of a Form. + """ + return form[fieldname] + + @register.inclusion_tag('utilities/render_field.html') def render_field(field, bulk_nullable=False): """ diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 01dce8479..849a12c24 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -5,7 +5,9 @@ import re import yaml from django import template from django.conf import settings +from django.template.defaultfilters import date from django.urls import NoReverseMatch, reverse +from django.utils import timezone from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown @@ -129,6 +131,20 @@ def humanize_speed(speed): return '{} Kbps'.format(speed) +@register.filter() +def humanize_megabytes(mb): + """ + Express a number of megabytes in the most suitable unit (e.g. gigabytes or terabytes). + """ + if not mb: + return '' + if mb >= 1048576: + return f'{int(mb / 1048576)} TB' + if mb >= 1024: + return f'{int(mb / 1024)} GB' + return f'{mb} MB' + + @register.filter() def tzoffset(value): """ @@ -137,6 +153,36 @@ def tzoffset(value): return datetime.datetime.now(value).strftime('%z') +@register.filter(expects_localtime=True) +def annotated_date(date_value): + """ + Returns date as HTML span with short date format as the content and the + (long) date format as the title. + """ + if not date_value: + return '' + + if type(date_value) == datetime.date: + long_ts = date(date_value, 'DATE_FORMAT') + short_ts = date(date_value, 'SHORT_DATE_FORMAT') + else: + long_ts = date(date_value, 'DATETIME_FORMAT') + short_ts = date(date_value, 'SHORT_DATETIME_FORMAT') + + span = f'{short_ts}' + + return mark_safe(span) + + +@register.simple_tag +def annotated_now(): + """ + Returns the current date piped through the annotated_date filter. + """ + tzinfo = timezone.get_current_timezone() if settings.USE_TZ else None + return annotated_date(datetime.datetime.now(tz=tzinfo)) + + @register.filter() def fgcolor(value): """ diff --git a/netbox/utilities/testing/__init__.py b/netbox/utilities/testing/__init__.py index 1c18a3481..6a52d93f3 100644 --- a/netbox/utilities/testing/__init__.py +++ b/netbox/utilities/testing/__init__.py @@ -1,3 +1,5 @@ from .api import * +from .base import * +from .filtersets import * from .utils import * from .views import * diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index f4f4ffefe..b57c273fd 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -6,9 +6,11 @@ from django.test import override_settings from rest_framework import status from rest_framework.test import APIClient +from extras.choices import ObjectChangeActionChoices +from extras.models import ObjectChange from users.models import ObjectPermission, Token +from .base import ModelTestCase from .utils import disable_warnings -from .views import ModelTestCase __all__ = ( @@ -223,13 +225,23 @@ class APIViewTestCases: response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(self._get_queryset().count(), initial_count + 1) + instance = self._get_queryset().get(pk=response.data['id']) self.assertInstanceEqual( - self._get_queryset().get(pk=response.data['id']), + instance, self.create_data[0], exclude=self.validation_excluded_fields, api=True ) + # Verify ObjectChange creation + if hasattr(self.model, 'to_objectchange'): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE) + def test_bulk_create_objects(self): """ POST a set of objects in a single request. @@ -304,6 +316,15 @@ class APIViewTestCases: api=True ) + # Verify ObjectChange creation + if hasattr(self.model, 'to_objectchange'): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) + def test_bulk_update_objects(self): """ PATCH a set of objects in a single request. @@ -367,6 +388,15 @@ class APIViewTestCases: self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists()) + # Verify ObjectChange creation + if hasattr(self.model, 'to_objectchange'): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE) + def test_bulk_delete_objects(self): """ DELETE a set of objects in a single request. diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py new file mode 100644 index 000000000..4ebb7ae09 --- /dev/null +++ b/netbox/utilities/testing/base.py @@ -0,0 +1,161 @@ +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import FieldDoesNotExist +from django.db.models import ManyToManyField +from django.forms.models import model_to_dict +from django.test import Client, TestCase as _TestCase +from netaddr import IPNetwork +from taggit.managers import TaggableManager + +from users.models import ObjectPermission +from utilities.permissions import resolve_permission_ct +from .utils import extract_form_failures + +__all__ = ( + 'ModelTestCase', + 'TestCase', +) + + +class TestCase(_TestCase): + user_permissions = () + + def setUp(self): + + # Create the test user and assign permissions + self.user = User.objects.create_user(username='testuser') + self.add_permissions(*self.user_permissions) + + # Initialize the test client + self.client = Client() + self.client.force_login(self.user) + + # + # Permissions management + # + + def add_permissions(self, *names): + """ + Assign a set of permissions to the test user. Accepts permission names in the form ._. + """ + for name in names: + ct, action = resolve_permission_ct(name) + obj_perm = ObjectPermission(name=name, actions=[action]) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ct) + + # + # Custom assertions + # + + def assertHttpStatus(self, response, expected_status): + """ + TestCase method. Provide more detail in the event of an unexpected HTTP response. + """ + err_message = None + # Construct an error message only if we know the test is going to fail + if response.status_code != expected_status: + if hasattr(response, 'data'): + # REST API response; pass the response data through directly + err = response.data + else: + # Attempt to extract form validation errors from the response HTML + form_errors = extract_form_failures(response.content) + err = form_errors or response.content or 'No data' + err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}" + self.assertEqual(response.status_code, expected_status, err_message) + + +class ModelTestCase(TestCase): + """ + Parent class for TestCases which deal with models. + """ + model = None + + def _get_queryset(self): + """ + Return a base queryset suitable for use in test methods. + """ + return self.model.objects.all() + + def prepare_instance(self, instance): + """ + Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation + against test data. For example, it can be used to decrypt a Secret's plaintext attribute. + """ + return instance + + def model_to_dict(self, instance, fields, api=False): + """ + Return a dictionary representation of an instance. + """ + # Prepare the instance and call Django's model_to_dict() to extract all fields + model_dict = model_to_dict(self.prepare_instance(instance), fields=fields) + + # Map any additional (non-field) instance attributes that were specified + for attr in fields: + if hasattr(instance, attr) and attr not in model_dict: + model_dict[attr] = getattr(instance, attr) + + for key, value in list(model_dict.items()): + try: + field = instance._meta.get_field(key) + except FieldDoesNotExist: + # Attribute is not a model field + continue + + # Handle ManyToManyFields + if value and type(field) in (ManyToManyField, TaggableManager): + + if field.related_model is ContentType: + model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value]) + else: + model_dict[key] = sorted([obj.pk for obj in value]) + + if api: + + # Replace ContentType numeric IDs with . + if type(getattr(instance, key)) is ContentType: + ct = ContentType.objects.get(pk=value) + model_dict[key] = f'{ct.app_label}.{ct.model}' + + # Convert IPNetwork instances to strings + elif type(value) is IPNetwork: + model_dict[key] = str(value) + + else: + + # Convert ArrayFields to CSV strings + if type(instance._meta.get_field(key)) is ArrayField: + model_dict[key] = ','.join([str(v) for v in value]) + + return model_dict + + # + # Custom assertions + # + + def assertInstanceEqual(self, instance, data, exclude=None, api=False): + """ + Compare a model instance to a dictionary, checking that its attribute values match those specified + in the dictionary. + + :param instance: Python object instance + :param data: Dictionary of test data used to define the instance + :param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed) + :param api: Set to True is the data is a JSON representation of the instance + """ + if exclude is None: + exclude = [] + + fields = [k for k in data.keys() if k not in exclude] + model_dict = self.model_to_dict(instance, fields=fields, api=api) + + # Omit any dictionary keys which are not instance attributes or have been excluded + relevant_data = { + k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude + } + + self.assertDictEqual(model_dict, relevant_data) diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py new file mode 100644 index 000000000..98bb0d1ac --- /dev/null +++ b/netbox/utilities/testing/filtersets.py @@ -0,0 +1,35 @@ +from datetime import date, datetime, timezone + + +__all__ = ( + 'BaseFilterSetTests', + 'ChangeLoggedFilterSetTests', +) + + +class BaseFilterSetTests: + queryset = None + filterset = None + + def test_id(self): + """ + Test filtering for two PKs from a set of >2 objects. + """ + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertGreater(self.queryset.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ChangeLoggedFilterSetTests(BaseFilterSetTests): + + def test_created(self): + pk_list = self.queryset.values_list('pk', flat=True)[:2] + self.queryset.filter(pk__in=pk_list).update(created=date(2021, 1, 1)) + params = {'created': '2021-01-01'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_last_updated(self): + pk_list = self.queryset.values_list('pk', flat=True)[:2] + self.queryset.filter(pk__in=pk_list).update(last_updated=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) + params = {'last_updated': '2021-01-01T00:00:00'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 9c30002b8..bec3bc762 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -3,6 +3,10 @@ import re from contextlib import contextmanager from django.contrib.auth.models import Permission, User +from django.utils.text import slugify + +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from extras.models import Tag def post_data(data): @@ -29,6 +33,19 @@ def post_data(data): return ret +def create_test_device(name): + """ + Convenience method for creating a Device (e.g. for component testing). + """ + site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') + manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') + devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) + devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') + device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) + + return device + + def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. @@ -44,6 +61,15 @@ def create_test_user(username='testuser', permissions=None): return user +def create_tags(*names): + """ + Create and return a Tag instance for each name given. + """ + tags = [Tag(name=name, slug=slugify(name)) for name in names] + Tag.objects.bulk_create(tags) + return tags + + def extract_form_failures(content): """ Given raw HTML content from an HTTP response, return a list of form errors. diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index d380996b7..4da724c7d 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1,181 +1,20 @@ -from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist -from django.db.models import ManyToManyField -from django.forms.models import model_to_dict -from django.test import Client, TestCase as _TestCase, override_settings -from django.urls import reverse, NoReverseMatch -from django.utils.text import slugify -from netaddr import IPNetwork -from taggit.managers import TaggableManager +from django.core.exceptions import ObjectDoesNotExist +from django.test import override_settings +from django.urls import reverse -from extras.models import Tag +from extras.choices import ObjectChangeActionChoices +from extras.models import ObjectChange from users.models import ObjectPermission -from utilities.permissions import resolve_permission_ct -from .utils import disable_warnings, extract_form_failures, post_data - +from .base import ModelTestCase +from .utils import disable_warnings, post_data __all__ = ( - 'TestCase', - 'ModelTestCase', 'ModelViewTestCase', 'ViewTestCases', ) -class TestCase(_TestCase): - user_permissions = () - - def setUp(self): - - # Create the test user and assign permissions - self.user = User.objects.create_user(username='testuser') - self.add_permissions(*self.user_permissions) - - # Initialize the test client - self.client = Client() - self.client.force_login(self.user) - - def prepare_instance(self, instance): - """ - Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation - against test data. For example, it can be used to decrypt a Secret's plaintext attribute. - """ - return instance - - def model_to_dict(self, instance, fields, api=False): - """ - Return a dictionary representation of an instance. - """ - # Prepare the instance and call Django's model_to_dict() to extract all fields - model_dict = model_to_dict(self.prepare_instance(instance), fields=fields) - - # Map any additional (non-field) instance attributes that were specified - for attr in fields: - if hasattr(instance, attr) and attr not in model_dict: - model_dict[attr] = getattr(instance, attr) - - for key, value in list(model_dict.items()): - try: - field = instance._meta.get_field(key) - except FieldDoesNotExist: - # Attribute is not a model field - continue - - # Handle ManyToManyFields - if value and type(field) in (ManyToManyField, TaggableManager): - - if field.related_model is ContentType: - model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value]) - else: - model_dict[key] = sorted([obj.pk for obj in value]) - - if api: - - # Replace ContentType numeric IDs with . - if type(getattr(instance, key)) is ContentType: - ct = ContentType.objects.get(pk=value) - model_dict[key] = f'{ct.app_label}.{ct.model}' - - # Convert IPNetwork instances to strings - elif type(value) is IPNetwork: - model_dict[key] = str(value) - - else: - - # Convert ArrayFields to CSV strings - if type(instance._meta.get_field(key)) is ArrayField: - model_dict[key] = ','.join([str(v) for v in value]) - - return model_dict - - # - # Permissions management - # - - def add_permissions(self, *names): - """ - Assign a set of permissions to the test user. Accepts permission names in the form ._. - """ - for name in names: - ct, action = resolve_permission_ct(name) - obj_perm = ObjectPermission(name=name, actions=[action]) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ct) - - # - # Custom assertions - # - - def assertHttpStatus(self, response, expected_status): - """ - TestCase method. Provide more detail in the event of an unexpected HTTP response. - """ - err_message = None - # Construct an error message only if we know the test is going to fail - if response.status_code != expected_status: - if hasattr(response, 'data'): - # REST API response; pass the response data through directly - err = response.data - else: - # Attempt to extract form validation errors from the response HTML - form_errors = extract_form_failures(response.content) - err = form_errors or response.content or 'No data' - err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}" - self.assertEqual(response.status_code, expected_status, err_message) - - def assertInstanceEqual(self, instance, data, exclude=None, api=False): - """ - Compare a model instance to a dictionary, checking that its attribute values match those specified - in the dictionary. - - :param instance: Python object instance - :param data: Dictionary of test data used to define the instance - :param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed) - :param api: Set to True is the data is a JSON representation of the instance - """ - if exclude is None: - exclude = [] - - fields = [k for k in data.keys() if k not in exclude] - model_dict = self.model_to_dict(instance, fields=fields, api=api) - - # Omit any dictionary keys which are not instance attributes or have been excluded - relevant_data = { - k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude - } - - self.assertDictEqual(model_dict, relevant_data) - - # - # Convenience methods - # - - @classmethod - def create_tags(cls, *names): - """ - Create and return a Tag instance for each name given. - """ - tags = [Tag(name=name, slug=slugify(name)) for name in names] - Tag.objects.bulk_create(tags) - return tags - - -class ModelTestCase(TestCase): - """ - Parent class for TestCases which deal with models. - """ - model = None - - def _get_queryset(self): - """ - Return a base queryset suitable for use in test methods. - """ - return self.model.objects.all() - - # # UI Tests # @@ -184,7 +23,6 @@ class ModelViewTestCase(ModelTestCase): """ Base TestCase for model views. Subclass to test individual views. """ - def _get_base_url(self): """ Return the base format for a URL for the test's model. Override this to test for a model which belongs @@ -205,14 +43,6 @@ class ModelViewTestCase(ModelTestCase): if instance is None: return reverse(url_format.format(action)) - # Attempt to resolve using slug as the unique identifier if one exists - if hasattr(self.model, 'slug'): - try: - return reverse(url_format.format(action), kwargs={'slug': instance.slug}) - except NoReverseMatch: - pass - - # Default to using the numeric PK to retrieve the URL for an object return reverse(url_format.format(action), kwargs={'pk': instance.pk}) @@ -331,7 +161,16 @@ class ViewTestCases: } self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(initial_count + 1, self._get_queryset().count()) - self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data) + instance = self._get_queryset().order_by('pk').last() + self.assertInstanceEqual(instance, self.form_data) + + # Verify ObjectChange creation + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_constrained_permission(self): @@ -418,6 +257,14 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(**request), 302) self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data) + # Verify ObjectChange creation + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] @@ -497,6 +344,14 @@ class ViewTestCases: with self.assertRaises(ObjectDoesNotExist): self._get_queryset().get(pk=instance.pk) + # Verify ObjectChange creation + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] @@ -567,12 +422,6 @@ class ViewTestCases: # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('list')), 200) - # Built-in CSV export - if hasattr(self.model, 'csv_headers'): - response = self.client.get('{}?export'.format(self._get_url('list'))) - self.assertHttpStatus(response, 200) - self.assertEqual(response.get('Content-Type'), 'text/csv') - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] @@ -594,10 +443,27 @@ class ViewTestCases: if hasattr(self.model, 'name'): self.assertIn(instance1.name, content) self.assertNotIn(instance2.name, content) - else: + elif hasattr(self.model, 'get_absolute_url'): self.assertIn(instance1.get_absolute_url(), content) self.assertNotIn(instance2.get_absolute_url(), content) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_export_objects(self): + url = self._get_url('list') + + # Test default CSV export + response = self.client.get(f'{url}?export') + self.assertHttpStatus(response, 200) + if hasattr(self.model, 'csv_headers'): + self.assertEqual(response.get('Content-Type'), 'text/csv') + content = response.content.decode('utf-8') + self.assertEqual(content.splitlines()[0], ','.join(self.model.csv_headers)) + + # Test table-based export + response = self.client.get(f'{url}?export=table') + self.assertHttpStatus(response, 200) + self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') + class CreateMultipleObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances using a single form. Expects the creation of three new instances by default. @@ -1015,12 +881,14 @@ class ViewTestCases: maxDiff = None class OrganizationalObjectViewTestCase( + GetObjectViewTestCase, GetObjectChangelogViewTestCase, CreateObjectViewTestCase, EditObjectViewTestCase, DeleteObjectViewTestCase, ListObjectsViewTestCase, BulkImportObjectsViewTestCase, + BulkEditObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): """ diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 56eaabd4c..374167f1c 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -7,14 +7,16 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.fields import MACAddressField -from dcim.filters import DeviceFilterSet, SiteFilterSet +from dcim.filtersets import DeviceFilterSet, SiteFilterSet from dcim.models import ( Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, Region, Site ) +from extras.filters import TagFilter from extras.models import TaggedItem +from netbox.filtersets import BaseFilterSet from utilities.filters import ( - BaseFilterSet, MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, - MultiValueNumberFilter, MultiValueTimeFilter, TagFilter, TreeNodeMultipleChoiceFilter, + MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, + MultiValueTimeFilter, TreeNodeMultipleChoiceFilter, ) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index d76b469b2..1b43c8b6b 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -6,7 +6,8 @@ from itertools import count, groupby from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce -from jinja2 import Environment +from jinja2.sandbox import SandboxedEnvironment +from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices from extras.utils import is_taggable @@ -83,7 +84,7 @@ def count_related(model, field): return Coalesce(subquery, 0) -def serialize_object(obj, extra=None, exclude=None): +def serialize_object(obj, extra=None): """ Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys @@ -93,13 +94,18 @@ def serialize_object(obj, extra=None, exclude=None): json_str = serialize('json', [obj]) data = json.loads(json_str)[0]['fields'] + # Exclude any MPTTModel fields + if issubclass(obj.__class__, MPTTModel): + for field in ['level', 'lft', 'rght', 'tree_id']: + data.pop(field) + # Include custom_field_data as "custom_fields" if hasattr(obj, 'custom_field_data'): data['custom_fields'] = data.pop('custom_field_data') # Include any tags. Check for tags cached on the instance; fall back to using the manager. if is_taggable(obj): - tags = getattr(obj, '_tags', obj.tags.all()) + tags = getattr(obj, '_tags', None) or obj.tags.all() data['tags'] = [tag.name for tag in tags] # Append any extra data @@ -112,10 +118,6 @@ def serialize_object(obj, extra=None, exclude=None): if isinstance(key, str) and key.startswith('_'): data.pop(key) - # Explicitly excluded keys - if isinstance(exclude, (list, tuple)) and key in exclude: - data.pop(key) - return data @@ -211,7 +213,7 @@ def render_jinja2(template_code, context): """ Render a Jinja2 template with the provided context. Return the rendered content. """ - return Environment().from_string(source=template_code).render(**context) + return SandboxedEnvironment().from_string(source=template_code).render(**context) def prepare_cloned_fields(instance): @@ -224,12 +226,12 @@ def prepare_cloned_fields(instance): field = instance._meta.get_field(field_name) field_value = field.value_from_object(instance) - # Swap out False with URL-friendly value + # Pass False as null for boolean fields if field_value is False: - field_value = '' + params.append((field_name, '')) # Omit empty values - if field_value not in (None, ''): + elif field_value not in (None, ''): params.append((field_name, field_value)) # Copy tags @@ -294,6 +296,14 @@ def array_to_string(array): return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) +def content_type_name(contenttype): + """ + Return a proper ContentType name. + """ + meta = contenttype.model_class()._meta + return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + + # # Fake request object # diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index c291a3cf2..a3afcb1c6 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,8 +1,10 @@ from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.urls.exceptions import NoReverseMatch from django.utils.http import is_safe_url +from django.views.generic import View from .permissions import resolve_permission @@ -123,3 +125,14 @@ class GetReturnURLMixin: # If all else fails, return home. Ideally this should never happen. return reverse('home') + + +# +# Views +# + +class SlugRedirectView(View): + + def get(self, request, model, slug): + obj = get_object_or_404(model.objects.restrict(request.user, 'view'), slug=slug) + return redirect(obj.get_absolute_url()) diff --git a/netbox/vapor/api/serializers.py b/netbox/vapor/api/serializers.py index e28ff9996..9693ade9c 100644 --- a/netbox/vapor/api/serializers.py +++ b/netbox/vapor/api/serializers.py @@ -1,8 +1,7 @@ from rest_framework import serializers -from extras.api.serializers import TaggedObjectSerializer +from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer from drf_yasg.utils import swagger_serializer_method -from extras.api.customfields import CustomFieldModelSerializer from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedInterfaceSerializer, @@ -33,7 +32,6 @@ def get_serializer_for_model(model, prefix=''): override_serializer_name = 'vapor.api.serializers.{}VLAN{}Serializer'.format( prefix, model_name ) - # To extend Circuit model to support tenant field if model_name == 'CircuitTermination': override_serializer_name = 'vapor.api.serializers.{}Vapor{}Serializer'.format( @@ -125,6 +123,7 @@ class NestedVaporCableSerializer(serializers.ModelSerializer): class CableTerminationSerializer(serializers.ModelSerializer): cable_peer_type = serializers.SerializerMethodField(read_only=True) cable_peer = serializers.SerializerMethodField(read_only=True) + _occupied = serializers.SerializerMethodField(read_only=True) def get_cable_peer_type(self, obj): if obj._cable_peer is not None: @@ -142,6 +141,10 @@ class CableTerminationSerializer(serializers.ModelSerializer): return serializer(obj._cable_peer, context=context).data return None + @swagger_serializer_method(serializer_or_field=serializers.BooleanField) + def get__occupied(self, obj): + return obj._occupied + class ConnectedEndpointSerializer(ValidatedModelSerializer): connected_endpoint_type = serializers.SerializerMethodField(read_only=True) @@ -171,7 +174,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): return None -class CustomerSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class CustomerSerializer(PrimaryModelSerializer): group = NestedTenantGroupSerializer(required=False) devices = NestedDeviceSerializer(required=False, many=True) @@ -189,7 +192,7 @@ class NestedCircuitSerializer(WritableNestedSerializer): class Meta: model = Circuit - fields = ['id', 'url', 'cid', 'tenant'] + fields = ['id', 'url', 'cid', 'tenant', 'status'] class NestedVaporCircuitTerminationSerializer(WritableNestedSerializer): @@ -201,7 +204,7 @@ class NestedVaporCircuitTerminationSerializer(WritableNestedSerializer): fields = ['id', 'url', 'circuit', 'term_side'] -class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedVaporDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) @@ -221,8 +224,8 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co model = Interface fields = [ 'id', 'url', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', - 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', + 'description', 'cable', 'cable_peer', 'cable_peer_type', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', + 'count_ipaddresses', '_occupied', 'mark_connected', ] ref_name = 'VaporInterfaceSerializer' diff --git a/netbox/vapor/filters.py b/netbox/vapor/filters.py index ff5f50c72..0def0718e 100644 --- a/netbox/vapor/filters.py +++ b/netbox/vapor/filters.py @@ -1,19 +1,18 @@ import django_filters from django.db.models import Q -from extras.filters import CustomFieldFilterSet -from utilities.filters import NameSlugSearchFilterSet, TagFilter, MultiValueNumberFilter +from extras.filters import TagFilter, CustomFieldFilter +from utilities.filters import MultiValueNumberFilter, MultiValueMACAddressFilter, MultiValueCharFilter from tenancy.models import Tenant, TenantGroup from dcim.models import Site, Device, DeviceRole, Interface, Region from dcim.choices import ( InterfaceTypeChoices, ) -from dcim.filters import MultiValueMACAddressFilter from netbox_virtual_circuit_plugin.models import VirtualCircuit -class CustomerFilter(CustomFieldFilterSet): +class CustomerFilter(CustomFieldFilter): q = django_filters.CharFilter( method='search', label='Search', @@ -60,7 +59,7 @@ class InterfaceFilter(django_filters.FilterSet): method='search', label='Search', ) - device = django_filters.CharFilter( + device = MultiValueCharFilter( method='filter_device', field_name='name', label='Device', @@ -161,8 +160,10 @@ class InterfaceFilter(django_filters.FilterSet): def filter_device(self, queryset, name, value): try: - device = Device.objects.get(**{name: value}) - vc_interface_ids = device.vc_interfaces.values_list('id', flat=True) + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + vc_interface_ids = [] + for device in devices: + vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() @@ -173,7 +174,7 @@ class InterfaceFilter(django_filters.FilterSet): try: devices = Device.objects.filter(pk__in=id_list) for device in devices: - vc_interface_ids += device.vc_interfaces.values_list('id', flat=True) + vc_interface_ids += device.vc_interfaces().values_list('id', flat=True) return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index 7763f0ef4..16e10b5fd 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -1,8 +1,7 @@ from rest_framework import serializers -from dcim.models import Interface from netbox.api import WritableNestedSerializer -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface __all__ = [ 'NestedClusterGroupSerializer', @@ -23,7 +22,7 @@ class NestedClusterTypeSerializer(WritableNestedSerializer): class Meta: model = ClusterType - fields = ['id', 'url', 'name', 'slug', 'cluster_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count'] class NestedClusterGroupSerializer(WritableNestedSerializer): @@ -32,7 +31,7 @@ class NestedClusterGroupSerializer(WritableNestedSerializer): class Meta: model = ClusterGroup - fields = ['id', 'url', 'name', 'slug', 'cluster_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count'] class NestedClusterSerializer(WritableNestedSerializer): @@ -41,7 +40,7 @@ class NestedClusterSerializer(WritableNestedSerializer): class Meta: model = Cluster - fields = ['id', 'url', 'name', 'virtualmachine_count'] + fields = ['id', 'url', 'display', 'name', 'virtualmachine_count'] # @@ -53,7 +52,7 @@ class NestedVirtualMachineSerializer(WritableNestedSerializer): class Meta: model = VirtualMachine - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedVMInterfaceSerializer(WritableNestedSerializer): @@ -61,5 +60,5 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer): virtual_machine = NestedVirtualMachineSerializer(read_only=True) class Meta: - model = Interface - fields = ['id', 'url', 'virtual_machine', 'name'] + model = VMInterface + fields = ['id', 'url', 'display', 'virtual_machine', 'name'] diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 518b7086c..adad9bf4d 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,11 +3,10 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from extras.api.customfields import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN -from netbox.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -18,25 +17,31 @@ from .nested_serializers import * # Clusters # -class ClusterTypeSerializer(ValidatedModelSerializer): +class ClusterTypeSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType - fields = ['id', 'url', 'name', 'slug', 'description', 'cluster_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'cluster_count', + ] -class ClusterGroupSerializer(ValidatedModelSerializer): +class ClusterGroupSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup - fields = ['id', 'url', 'name', 'slug', 'description', 'cluster_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'cluster_count', + ] -class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class ClusterSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True) @@ -48,8 +53,8 @@ class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -57,7 +62,7 @@ class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Virtual machines # -class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VirtualMachineSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) site = NestedSiteSerializer(read_only=True) @@ -72,9 +77,9 @@ class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerialize class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', + 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', + 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -84,9 +89,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields', - 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', + 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', + 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -98,9 +103,10 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): # VM interfaces # -class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class VMInterfaceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() + parent = NestedVMInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -109,12 +115,14 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): required=False, many=True ) + count_ipaddresses = serializers.IntegerField(read_only=True) class Meta: model = VMInterface fields = [ - 'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags', + 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description', + 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', + 'count_ipaddresses', ] def validate(self, data): diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 586ad5028..8eebd2120 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView from dcim.models import Device from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet from utilities.utils import count_related -from virtualization import filters +from virtualization import filtersets from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from . import serializers @@ -20,20 +20,20 @@ class VirtualizationRootView(APIRootView): # Clusters # -class ClusterTypeViewSet(ModelViewSet): +class ClusterTypeViewSet(CustomFieldModelViewSet): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') ) serializer_class = serializers.ClusterTypeSerializer - filterset_class = filters.ClusterTypeFilterSet + filterset_class = filtersets.ClusterTypeFilterSet -class ClusterGroupViewSet(ModelViewSet): +class ClusterGroupViewSet(CustomFieldModelViewSet): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') ) serializer_class = serializers.ClusterGroupSerializer - filterset_class = filters.ClusterGroupFilterSet + filterset_class = filtersets.ClusterGroupFilterSet class ClusterViewSet(CustomFieldModelViewSet): @@ -44,7 +44,7 @@ class ClusterViewSet(CustomFieldModelViewSet): virtualmachine_count=count_related(VirtualMachine, 'cluster') ) serializer_class = serializers.ClusterSerializer - filterset_class = filters.ClusterFilterSet + filterset_class = filtersets.ClusterFilterSet # @@ -55,7 +55,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet) queryset = VirtualMachine.objects.prefetch_related( 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) - filterset_class = filters.VirtualMachineFilterSet + filterset_class = filtersets.VirtualMachineFilterSet def get_serializer_class(self): """ @@ -80,8 +80,8 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet) class VMInterfaceViewSet(ModelViewSet): queryset = VMInterface.objects.prefetch_related( - 'virtual_machine', 'tags', 'tagged_vlans' + 'virtual_machine', 'parent', 'tags', 'tagged_vlans', 'ip_addresses' ) serializer_class = serializers.VMInterfaceSerializer - filterset_class = filters.VMInterfaceFilterSet + filterset_class = filtersets.VMInterfaceFilterSet brief_prefetch_fields = ['virtual_machine'] diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filtersets.py similarity index 80% rename from netbox/virtualization/filters.py rename to netbox/virtualization/filtersets.py index af53afad5..6d930b69e 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filtersets.py @@ -1,13 +1,12 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Platform, Region, Site -from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet -from tenancy.filters import TenancyFilterSet -from utilities.filters import ( - BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, TagFilter, - TreeNodeMultipleChoiceFilter, -) +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from extras.filters import TagFilter +from extras.filtersets import LocalConfigContextFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from tenancy.filtersets import TenancyFilterSet +from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -20,21 +19,21 @@ __all__ = ( ) -class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class ClusterTypeFilterSet(OrganizationalModelFilterSet): class Meta: model = ClusterType fields = ['id', 'name', 'slug', 'description'] -class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class ClusterGroupFilterSet(OrganizationalModelFilterSet): class Meta: model = ClusterGroup fields = ['id', 'name', 'slug', 'description'] -class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): +class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -52,6 +51,19 @@ class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSe to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -97,13 +109,7 @@ class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSe ) -class VirtualMachineFilterSet( - BaseFilterSet, - LocalConfigContextFilterSet, - TenancyFilterSet, - CustomFieldModelFilterSet, - CreatedUpdatedFilterSet -): +class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -151,6 +157,19 @@ class VirtualMachineFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='cluster__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='cluster__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__site', queryset=Site.objects.all(), @@ -211,7 +230,7 @@ class VirtualMachineFilterSet( return queryset.exclude(params) -class VMInterfaceFilterSet(BaseFilterSet): +class VMInterfaceFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -238,6 +257,11 @@ class VMInterfaceFilterSet(BaseFilterSet): to_field_name='name', label='Virtual machine', ) + parent_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent', + queryset=VMInterface.objects.all(), + label='Parent interface (ID)', + ) mac_address = MultiValueMACAddressFilter( label='MAC address', ) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index edacb3e07..870de9b3c 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,23 +1,25 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.forms import ( - AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, + AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, + CustomFieldFilterForm, ) from extras.models import Tag -from ipam.models import IPAddress, VLAN +from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, - ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, - StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, ConfirmationForm, + CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, + form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -27,7 +29,7 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf # Cluster types # -class ClusterTypeForm(BootstrapMixin, forms.ModelForm): +class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -37,7 +39,7 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): ] -class ClusterTypeCSVForm(CSVModelForm): +class ClusterTypeCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -45,11 +47,25 @@ class ClusterTypeCSVForm(CSVModelForm): fields = ClusterType.csv_headers +class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ClusterType.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Cluster groups # -class ClusterGroupForm(BootstrapMixin, forms.ModelForm): +class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -59,7 +75,7 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): ] -class ClusterGroupCSVForm(CSVModelForm): +class ClusterGroupCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -67,6 +83,20 @@ class ClusterGroupCSVForm(CSVModelForm): fields = ClusterGroup.csv_headers +class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Clusters # @@ -86,11 +116,19 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) comments = CommentField() @@ -102,7 +140,11 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + ) + fieldsets = ( + ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), ) @@ -156,13 +198,17 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, - to_field_name='slug' + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) comments = CommentField( @@ -179,33 +225,36 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Cluster field_order = [ - 'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant' + 'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id', ] - q = forms.CharField(required=False, label='Search') - type = DynamicModelMultipleChoiceField( + q = forms.CharField( + required=False, + label=_('Search') + ) + type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Type') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, null_option='None', query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) - group = DynamicModelMultipleChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Group') ) tag = TagFilterField(model) @@ -216,25 +265,29 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): required=False, null_option='None' ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + null_option='None' + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, null_option='None', - display_field='display_name', query_params={ 'site_id': '$site' } ) devices = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), - display_field='display_name', query_params={ 'site_id': '$site', 'rack_id': '$rack', @@ -321,6 +374,14 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] + fieldsets = ( + ('Virtual Machine', ('name', 'role', 'status', 'tags')), + ('Cluster', ('cluster_group', 'cluster')), + ('Tenancy', ('tenant_group', 'tenant')), + ('Management', ('platform', 'primary_ip4', 'primary_ip6')), + ('Resources', ('vcpus', 'memory', 'disk')), + ('Config Context', ('local_context_data',)), + ) help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " "config context", @@ -467,63 +528,69 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VirtualMachine field_order = [ - 'q', 'cluster_group', 'cluster_type', 'cluster_id', 'status', 'role', 'region', 'site', 'tenant_group', - 'tenant', 'platform', 'mac_address', + 'q', 'cluster_group_id', 'cluster_type_id', 'cluster_id', 'status', 'role_id', 'region_id', 'site_group_id', + 'site_id', 'tenant_group_id', 'tenant_id', 'platform_id', 'mac_address', ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - cluster_group = DynamicModelMultipleChoiceField( + cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Cluster group') ) - cluster_type = DynamicModelMultipleChoiceField( + cluster_type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Cluster type') ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster' + label=_('Cluster') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, null_option='None', query_params={ - 'region': '$region' - } + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site') ) - role = DynamicModelMultipleChoiceField( - queryset=DeviceRole.objects.filter(vm_role=True), - to_field_name='slug', + role_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), required=False, null_option='None', query_params={ 'vm_role': "True" - } + }, + label=_('Role') ) status = forms.MultipleChoiceField( choices=VirtualMachineStatusChoices, required=False, widget=StaticSelect2Multiple() ) - platform = DynamicModelMultipleChoiceField( + platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Platform') ) mac_address = forms.CharField( required=False, @@ -543,25 +610,31 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil # VM interfaces # -class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): +class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Parent interface' + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, label='Untagged VLAN', - display_field='display_name', - brief_mode=False, query_params={ - 'site_id': 'null', + 'group_id': '$vlan_group', } ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, label='Tagged VLANs', - display_field='display_name', - brief_mode=False, query_params={ - 'site_id': 'null', + 'group_id': '$vlan_group', } ) tags = DynamicModelMultipleChoiceField( @@ -572,8 +645,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): class Meta: model = VMInterface fields = [ - 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan', - 'tagged_vlans', + 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags', + 'untagged_vlan', 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), @@ -588,19 +661,18 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - virtual_machine = VirtualMachine.objects.get( - pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine') - ) + # Restrict parent interface assignment by VM + self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) - # Add current site to VLANs query params - site = virtual_machine.site - if site: - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + # Limit VLAN choices by virtual machine + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) -class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): +class VMInterfaceCreateForm(BootstrapMixin, CustomFieldForm, InterfaceCommonForm): + model = VMInterface virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all() ) @@ -611,18 +683,20 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): required=False, initial=True ) - mtu = forms.IntegerField( + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' + display_field='display_name', + query_params={ + 'virtual_machine_id': '$virtual_machine', + } ) mac_address = forms.CharField( required=False, label='MAC Address' ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) mode = forms.ChoiceField( @@ -632,42 +706,31 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - display_field='display_name', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - display_field='display_name', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False ) + field_order = ( + 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode', + 'untagged_vlan', 'tagged_vlans', 'tags' + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - virtual_machine = VirtualMachine.objects.get( - pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine') - ) - - # Add current site to VLANs query params - site = virtual_machine.site - if site: - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + # Limit VLAN choices by virtual machine + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) -class VMInterfaceCSVForm(CSVModelForm): +class VMInterfaceCSVForm(CustomFieldModelCSVForm): virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), to_field_name='name' @@ -690,7 +753,7 @@ class VMInterfaceCSVForm(CSVModelForm): return self.cleaned_data['enabled'] -class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput() @@ -701,6 +764,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): disabled=True, widget=forms.HiddenInput() ) + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + display_field='display_name' + ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() @@ -722,40 +790,50 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - display_field='display_name', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - display_field='display_name', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) class Meta: nullable_fields = [ - 'mtu', 'description', + 'parent', 'mtu', 'description', ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - # Limit available VLANs based on the parent VirtualMachine if 'virtual_machine' in self.initial: - parent_obj = VirtualMachine.objects.filter(pk=self.initial['virtual_machine']).first() + vm_id = self.initial.get('virtual_machine') - site = getattr(parent_obj.cluster, 'site', None) - if site is not None: - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + # Restrict parent interface assignment by VM + self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + + # Limit VLAN choices by virtual machine + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) + + else: + # See 5643 + if 'pk' in self.initial: + site = None + interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related( + 'virtual_machine__cluster__site' + ) + + # Check interface sites. First interface should set site, further interfaces will either continue the + # loop or reset back to no site and break the loop. + for interface in interfaces: + if site is None: + site = interface.virtual_machine.cluster.site + elif interface.virtual_machine.cluster.site is not site: + site = None + break + + if site is not None: + self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) + self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) class VMInterfaceBulkRenameForm(BulkRenameForm): @@ -765,20 +843,24 @@ class VMInterfaceBulkRenameForm(BulkRenameForm): ) -class VMInterfaceFilterForm(forms.Form): +class VMInterfaceFilterForm(BootstrapMixin, forms.Form): model = VMInterface + q = forms.CharField( + required=False, + label=_('Search') + ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster' + label=_('Cluster') ) virtual_machine_id = DynamicModelMultipleChoiceField( queryset=VirtualMachine.objects.all(), required=False, - label='Virtual machine', query_params={ 'cluster_id': '$cluster_id' - } + }, + label=_('Virtual machine') ) enabled = forms.NullBooleanField( required=False, @@ -786,6 +868,10 @@ class VMInterfaceFilterForm(forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + mac_address = forms.CharField( + required=False, + label='MAC address' + ) tag = TagFilterField(model) diff --git a/netbox/virtualization/migrations/0020_standardize_models.py b/netbox/virtualization/migrations/0020_standardize_models.py new file mode 100644 index 000000000..bea551a72 --- /dev/null +++ b/netbox/virtualization/migrations/0020_standardize_models.py @@ -0,0 +1,68 @@ +import django.core.serializers.json +import taggit.managers +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0019_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='clustertype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='cluster', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustergroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustertype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualmachine', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vminterface', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AddField( + model_name='vminterface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='vminterface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='vminterface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='vminterface', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/virtualization/migrations/0021_virtualmachine_vcpus_decimal.py b/netbox/virtualization/migrations/0021_virtualmachine_vcpus_decimal.py new file mode 100644 index 000000000..0d82fcb43 --- /dev/null +++ b/netbox/virtualization/migrations/0021_virtualmachine_vcpus_decimal.py @@ -0,0 +1,17 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0020_standardize_models'), + ] + + operations = [ + migrations.AlterField( + model_name='virtualmachine', + name='vcpus', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, validators=[django.core.validators.MinValueValidator(0.01)]), + ), + ] diff --git a/netbox/virtualization/migrations/0022_vminterface_parent.py b/netbox/virtualization/migrations/0022_vminterface_parent.py new file mode 100644 index 000000000..d1249985f --- /dev/null +++ b/netbox/virtualization/migrations/0022_vminterface_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0021_virtualmachine_vcpus_decimal'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='virtualization.vminterface'), + ), + ] diff --git a/netbox/virtualization/migrations/0023_virtualmachine_natural_ordering.py b/netbox/virtualization/migrations/0023_virtualmachine_natural_ordering.py new file mode 100644 index 000000000..2fc2a38e0 --- /dev/null +++ b/netbox/virtualization/migrations/0023_virtualmachine_natural_ordering.py @@ -0,0 +1,32 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def naturalize_virtualmachines(apps, schema_editor): + VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') + for name in VirtualMachine.objects.values_list('name', flat=True).order_by('name').distinct(): + VirtualMachine.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0022_vminterface_parent'), + ] + + operations = [ + migrations.AlterModelOptions( + name='virtualmachine', + options={'ordering': ('_name', 'pk')}, + ), + migrations.AddField( + model_name='virtualmachine', + name='_name', + field=utilities.fields.NaturalOrderingField('name', max_length=100, blank=True, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_virtualmachines, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index edca7e1fe..0b679bac0 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,19 +1,19 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator from django.db import models from django.urls import reverse -from taggit.managers import TaggableManager from dcim.models import BaseInterface, Device -from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from .choices import * @@ -30,7 +30,8 @@ __all__ = ( # Cluster types # -class ClusterType(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ClusterType(OrganizationalModel): """ A type of Cluster. """ @@ -58,7 +59,7 @@ class ClusterType(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug) + return reverse('virtualization:clustertype', args=[self.pk]) def to_csv(self): return ( @@ -72,7 +73,8 @@ class ClusterType(ChangeLoggedModel): # Cluster groups # -class ClusterGroup(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. """ @@ -100,7 +102,7 @@ class ClusterGroup(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug) + return reverse('virtualization:clustergroup', args=[self.pk]) def to_csv(self): return ( @@ -114,8 +116,8 @@ class ClusterGroup(ChangeLoggedModel): # Clusters # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cluster(ChangeLoggedModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Cluster(PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -152,7 +154,6 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -198,8 +199,8 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # Virtual machines # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -225,6 +226,11 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) status = models.CharField( max_length=50, choices=VirtualMachineStatusChoices, @@ -255,10 +261,15 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): null=True, verbose_name='Primary IPv6' ) - vcpus = models.PositiveSmallIntegerField( + vcpus = models.DecimalField( + max_digits=6, + decimal_places=2, blank=True, null=True, - verbose_name='vCPUs' + verbose_name='vCPUs', + validators=( + MinValueValidator(0.01), + ) ) memory = models.PositiveIntegerField( blank=True, @@ -279,7 +290,6 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): object_id_field='assigned_object_id', related_query_name='virtual_machine' ) - tags = TaggableManager(through=TaggedItem) objects = ConfigContextModelQuerySet.as_manager() @@ -291,7 +301,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ] class Meta: - ordering = ('name', 'pk') # Name may be non-unique + ordering = ('_name', 'pk') # Name may be non-unique unique_together = [ ['cluster', 'tenant', 'name'] ] @@ -370,8 +380,8 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Interfaces # -@extras_features('export_templates', 'webhooks') -class VMInterface(BaseInterface): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class VMInterface(PrimaryModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, @@ -390,6 +400,14 @@ class VMInterface(BaseInterface): max_length=200, blank=True ) + parent = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name='Parent interface' + ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -410,10 +428,6 @@ class VMInterface(BaseInterface): object_id_field='assigned_object_id', related_query_name='vminterface' ) - tags = TaggableManager( - through=TaggedItem, - related_name='vminterface' - ) objects = RestrictedQuerySet.as_manager() @@ -437,6 +451,7 @@ class VMInterface(BaseInterface): self.virtual_machine.name, self.name, self.enabled, + self.parent.name if self.parent else None, self.mac_address, self.mtu, self.description, @@ -446,6 +461,17 @@ class VMInterface(BaseInterface): def clean(self): super().clean() + # An interface's parent must belong to the same virtual machine + if self.parent and self.parent.virtual_machine != self.virtual_machine: + raise ValidationError({ + 'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine " + f"({self.parent.virtual_machine})." + }) + + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: raise ValidationError({ @@ -455,18 +481,8 @@ class VMInterface(BaseInterface): def to_objectchange(self, action): # Annotate the parent VirtualMachine - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=self.virtual_machine, - object_data=serialize_object(self) - ) + return super().to_objectchange(action, related_object=self.virtual_machine) @property - def parent(self): + def parent_object(self): return self.virtual_machine - - @property - def count_ipaddresses(self): - return self.ip_addresses.count() diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index e183765f1..65bd2b5d1 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django.conf import settings from dcim.tables.devices import BaseInterfaceTable -from tenancy.tables import COL_TENANT +from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, ) @@ -32,11 +32,13 @@ VMINTERFACE_BUTTONS = """ class ClusterTypeTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) cluster_count = tables.Column( verbose_name='Clusters' ) - actions = ButtonsColumn(ClusterType, pk_field='slug') + actions = ButtonsColumn(ClusterType) class Meta(BaseTable.Meta): model = ClusterType @@ -50,11 +52,13 @@ class ClusterTypeTable(BaseTable): class ClusterGroupTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) cluster_count = tables.Column( verbose_name='Clusters' ) - actions = ButtonsColumn(ClusterGroup, pk_field='slug') + actions = ButtonsColumn(ClusterGroup) class Meta(BaseTable.Meta): model = ClusterGroup @@ -68,7 +72,9 @@ class ClusterGroupTable(BaseTable): class ClusterTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) tenant = tables.Column( linkify=True ) @@ -101,15 +107,15 @@ class ClusterTable(BaseTable): class VirtualMachineTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) status = ChoiceFieldColumn() cluster = tables.Column( linkify=True ) role = ColoredLabelColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() class Meta(BaseTable.Meta): model = VirtualMachine @@ -158,10 +164,15 @@ class VirtualMachineDetailTable(VirtualMachineTable): class VMInterfaceTable(BaseInterfaceTable): pk = ToggleColumn() - virtual_machine = tables.LinkColumn() + virtual_machine = tables.Column( + linkify=True + ) name = tables.Column( linkify=True ) + parent = tables.Column( + linkify=True + ) tags = TagColumn( url_name='virtualization:vminterface_list' ) @@ -169,10 +180,10 @@ class VMInterfaceTable(BaseInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'pk', 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) - default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description') + default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'parent', 'description') class VirtualMachineVMInterfaceTable(VMInterfaceTable): diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 716b6af4d..cb45a192a 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -19,7 +19,7 @@ class AppTest(APITestCase): class ClusterTypeTest(APIViewTestCases.APIViewTestCase): model = ClusterType - brief_fields = ['cluster_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['cluster_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Cluster Type 4', @@ -51,7 +51,7 @@ class ClusterTypeTest(APIViewTestCases.APIViewTestCase): class ClusterGroupTest(APIViewTestCases.APIViewTestCase): model = ClusterGroup - brief_fields = ['cluster_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['cluster_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Cluster Group 4', @@ -83,7 +83,7 @@ class ClusterGroupTest(APIViewTestCases.APIViewTestCase): class ClusterTest(APIViewTestCases.APIViewTestCase): model = Cluster - brief_fields = ['id', 'name', 'url', 'virtualmachine_count'] + brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { 'comments': 'New comment', } @@ -131,7 +131,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): class VirtualMachineTest(APIViewTestCases.APIViewTestCase): model = VirtualMachine - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'staged', } @@ -207,7 +207,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): class VMInterfaceTest(APIViewTestCases.APIViewTestCase): model = VMInterface - brief_fields = ['id', 'name', 'url', 'virtual_machine'] + brief_fields = ['display', 'id', 'name', 'url', 'virtual_machine'] bulk_update_data = { 'description': 'New description', } @@ -251,6 +251,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): { 'virtual_machine': virtualmachine.pk, 'name': 'Interface 6', + 'parent': interfaces[0].pk, 'mode': InterfaceModeChoices.MODE_TAGGED, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filtersets.py similarity index 88% rename from netbox/virtualization/tests/test_filters.py rename to netbox/virtualization/tests/test_filtersets.py index e27c063e1..0ca6364a5 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,14 +1,15 @@ from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup +from utilities.testing import ChangeLoggedFilterSetTests from virtualization.choices import * -from virtualization.filters import * +from virtualization.filtersets import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface -class ClusterTypeTestCase(TestCase): +class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ClusterType.objects.all() filterset = ClusterTypeFilterSet @@ -22,10 +23,6 @@ class ClusterTypeTestCase(TestCase): ) ClusterType.objects.bulk_create(cluster_types) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Cluster Type 1', 'Cluster Type 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -39,7 +36,7 @@ class ClusterTypeTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ClusterGroupTestCase(TestCase): +class ClusterGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ClusterGroup.objects.all() filterset = ClusterGroupFilterSet @@ -53,10 +50,6 @@ class ClusterGroupTestCase(TestCase): ) ClusterGroup.objects.bulk_create(cluster_groups) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Cluster Group 1', 'Cluster Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -70,7 +63,7 @@ class ClusterGroupTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ClusterTestCase(TestCase): +class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Cluster.objects.all() filterset = ClusterFilterSet @@ -96,14 +89,21 @@ class ClusterTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -129,10 +129,6 @@ class ClusterTestCase(TestCase): ) Cluster.objects.bulk_create(clusters) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Cluster 1', 'Cluster 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -144,6 +140,13 @@ class ClusterTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -180,7 +183,7 @@ class ClusterTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class VirtualMachineTestCase(TestCase): +class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualMachine.objects.all() filterset = VirtualMachineFilterSet @@ -206,14 +209,21 @@ class VirtualMachineTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -276,10 +286,6 @@ class VirtualMachineTestCase(TestCase): VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0]) VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1]) - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -329,6 +335,13 @@ class VirtualMachineTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -381,7 +394,7 @@ class VirtualMachineTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class VMInterfaceTestCase(TestCase): +class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VMInterface.objects.all() filterset = VMInterfaceFilterSet @@ -416,21 +429,29 @@ class VMInterfaceTestCase(TestCase): ) VMInterface.objects.bulk_create(interfaces) - def test_id(self): - id_list = self.queryset.values_list('id', flat=True)[:2] - params = {'id': [str(id) for id in id_list]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): params = {'name': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_assigned_to_interface(self): + def test_enabled(self): params = {'enabled': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'enabled': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_parent(self): + # Create child interfaces + parent_interface = VMInterface.objects.first() + child_interfaces = ( + VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 1', parent=parent_interface), + VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 2', parent=parent_interface), + VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 3', parent=parent_interface), + ) + VMInterface.objects.bulk_create(child_interfaces) + + params = {'parent_id': [parent_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_mtu(self): params = {'mtu': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index e35f977fd..86be5159f 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,9 +1,11 @@ +from django.test import override_settings +from django.urls import reverse from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN -from utilities.testing import ViewTestCases +from utilities.testing import ViewTestCases, create_tags from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -33,6 +35,10 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Group 6,cluster-group-6,Sixth cluster group", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = ClusterType @@ -59,6 +65,10 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Type 6,cluster-type-6,Sixth cluster type", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cluster @@ -90,7 +100,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Cluster X', @@ -117,6 +127,20 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_cluster_virtualmachines(self): + cluster = Cluster.objects.first() + + url = reverse('virtualization:cluster_virtualmachines', kwargs={'pk': cluster.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_cluster_devices(self): + cluster = Cluster.objects.first() + + url = reverse('virtualization:cluster_devices', kwargs={'pk': cluster.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualMachine @@ -150,7 +174,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'cluster': clusters[1].pk, @@ -188,6 +212,19 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_virtualmachine_interfaces(self): + virtualmachine = VirtualMachine.objects.first() + vminterfaces = ( + VMInterface(virtual_machine=virtualmachine, name='Interface 1'), + VMInterface(virtual_machine=virtualmachine, name='Interface 2'), + VMInterface(virtual_machine=virtualmachine, name='Interface 3'), + ) + VMInterface.objects.bulk_create(vminterfaces) + + url = reverse('virtualization:virtualmachine_interfaces', kwargs={'pk': virtualmachine.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = VMInterface @@ -219,14 +256,14 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VLAN.objects.bulk_create(vlans) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', 'enabled': False, 'mac_address': EUI('01-02-03-04-05-06'), - 'mtu': 2000, + 'mtu': 65000, 'description': 'New description', 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 3d6f07566..d1f8e76e3 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from extras.views import ObjectChangeLogView +from extras.views import ObjectChangeLogView, ObjectJournalView from ipam.views import ServiceEditView from . import views from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -12,19 +12,23 @@ urlpatterns = [ path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'), path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'), path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), + path('cluster-types/edit/', views.ClusterTypeBulkEditView.as_view(), name='clustertype_bulk_edit'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), - path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), - path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), - path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), + path('cluster-types//', views.ClusterTypeView.as_view(), name='clustertype'), + path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), + path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), + path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), # Cluster groups path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'), path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), + path('cluster-groups/edit/', views.ClusterGroupBulkEditView.as_view(), name='clustergroup_bulk_edit'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), - path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), - path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), - path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), + path('cluster-groups//', views.ClusterGroupView.as_view(), name='clustergroup'), + path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), + path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), + path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), # Clusters path('clusters/', views.ClusterListView.as_view(), name='cluster_list'), @@ -33,9 +37,12 @@ urlpatterns = [ path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), path('clusters//', views.ClusterView.as_view(), name='cluster'), + path('clusters//devices/', views.ClusterDevicesView.as_view(), name='cluster_devices'), + path('clusters//virtual-machines/', views.ClusterVirtualMachinesView.as_view(), name='cluster_virtualmachines'), path('clusters//edit/', views.ClusterEditView.as_view(), name='cluster_edit'), path('clusters//delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'), path('clusters//changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}), + path('clusters//journal/', ObjectJournalView.as_view(), name='cluster_journal', kwargs={'model': Cluster}), path('clusters//devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), path('clusters//devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), @@ -46,10 +53,12 @@ urlpatterns = [ path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), path('virtual-machines//', views.VirtualMachineView.as_view(), name='virtualmachine'), + path('virtual-machines//interfaces/', views.VirtualMachineInterfacesView.as_view(), name='virtualmachine_interfaces'), path('virtual-machines//edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), path('virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), + path('virtual-machines//journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}), path('virtual-machines//services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'), # VM interfaces diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index dcf9ebcda..0d1b72051 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,8 +11,9 @@ from ipam.models import IPAddress, Service from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from netbox.views import generic from secrets.models import Secret +from utilities.tables import paginate_table from utilities.utils import count_related -from . import filters, forms, tables +from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -27,6 +28,26 @@ class ClusterTypeListView(generic.ObjectListView): table = tables.ClusterTypeTable +class ClusterTypeView(generic.ObjectView): + queryset = ClusterType.objects.all() + + def get_extra_context(self, request, instance): + clusters = Cluster.objects.restrict(request.user, 'view').filter( + type=instance + ).annotate( + device_count=count_related(Device, 'cluster'), + vm_count=count_related(VirtualMachine, 'cluster') + ) + + clusters_table = tables.ClusterTable(clusters) + clusters_table.columns.hide('type') + paginate_table(clusters_table, request) + + return { + 'clusters_table': clusters_table, + } + + class ClusterTypeEditView(generic.ObjectEditView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeForm @@ -42,6 +63,15 @@ class ClusterTypeBulkImportView(generic.BulkImportView): table = tables.ClusterTypeTable +class ClusterTypeBulkEditView(generic.BulkEditView): + queryset = ClusterType.objects.annotate( + cluster_count=count_related(Cluster, 'type') + ) + filterset = filtersets.ClusterTypeFilterSet + table = tables.ClusterTypeTable + form = forms.ClusterTypeBulkEditForm + + class ClusterTypeBulkDeleteView(generic.BulkDeleteView): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') @@ -60,6 +90,26 @@ class ClusterGroupListView(generic.ObjectListView): table = tables.ClusterGroupTable +class ClusterGroupView(generic.ObjectView): + queryset = ClusterGroup.objects.all() + + def get_extra_context(self, request, instance): + clusters = Cluster.objects.restrict(request.user, 'view').filter( + group=instance + ).annotate( + device_count=count_related(Device, 'cluster'), + vm_count=count_related(VirtualMachine, 'cluster') + ) + + clusters_table = tables.ClusterTable(clusters) + clusters_table.columns.hide('group') + paginate_table(clusters_table, request) + + return { + 'clusters_table': clusters_table, + } + + class ClusterGroupEditView(generic.ObjectEditView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupForm @@ -70,11 +120,22 @@ class ClusterGroupDeleteView(generic.ObjectDeleteView): class ClusterGroupBulkImportView(generic.BulkImportView): - queryset = ClusterGroup.objects.all() + queryset = ClusterGroup.objects.annotate( + cluster_count=count_related(Cluster, 'group') + ) model_form = forms.ClusterGroupCSVForm table = tables.ClusterGroupTable +class ClusterGroupBulkEditView(generic.BulkEditView): + queryset = ClusterGroup.objects.annotate( + cluster_count=count_related(Cluster, 'group') + ) + filterset = filtersets.ClusterGroupFilterSet + table = tables.ClusterGroupTable + form = forms.ClusterGroupBulkEditForm + + class ClusterGroupBulkDeleteView(generic.BulkDeleteView): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') @@ -93,28 +154,47 @@ class ClusterListView(generic.ObjectListView): vm_count=count_related(VirtualMachine, 'cluster') ) table = tables.ClusterTable - filterset = filters.ClusterFilterSet + filterset = filtersets.ClusterFilterSet filterset_form = forms.ClusterFilterForm class ClusterView(generic.ObjectView): queryset = Cluster.objects.all() + +class ClusterVirtualMachinesView(generic.ObjectView): + queryset = Cluster.objects.all() + template_name = 'virtualization/cluster/virtual_machines.html' + + def get_extra_context(self, request, instance): + virtualmachines = VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=instance) + virtualmachines_table = tables.VirtualMachineTable(virtualmachines, orderable=False) + + return { + 'virtualmachines_table': virtualmachines_table, + 'active_tab': 'virtual-machines', + } + + +class ClusterDevicesView(generic.ObjectView): + queryset = Cluster.objects.all() + template_name = 'virtualization/cluster/devices.html' + def get_extra_context(self, request, instance): devices = Device.objects.restrict(request.user, 'view').filter(cluster=instance).prefetch_related( 'site', 'rack', 'tenant', 'device_type__manufacturer' ) - device_table = DeviceTable(list(devices), orderable=False) + devices_table = DeviceTable(list(devices), orderable=False) if request.user.has_perm('virtualization.change_cluster'): - device_table.columns.show('pk') + devices_table.columns.show('pk') return { - 'device_table': device_table, + 'devices_table': devices_table, + 'active_tab': 'devices', } class ClusterEditView(generic.ObjectEditView): - template_name = 'virtualization/cluster_edit.html' queryset = Cluster.objects.all() model_form = forms.ClusterForm @@ -131,14 +211,14 @@ class ClusterBulkImportView(generic.BulkImportView): class ClusterBulkEditView(generic.BulkEditView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site') - filterset = filters.ClusterFilterSet + filterset = filtersets.ClusterFilterSet table = tables.ClusterTable form = forms.ClusterBulkEditForm class ClusterBulkDeleteView(generic.BulkDeleteView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site') - filterset = filters.ClusterFilterSet + filterset = filtersets.ClusterFilterSet table = tables.ClusterTable @@ -230,7 +310,7 @@ class ClusterRemoveDevicesView(generic.ObjectEditView): class VirtualMachineListView(generic.ObjectListView): queryset = VirtualMachine.objects.all() - filterset = filters.VirtualMachineFilterSet + filterset = filtersets.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineDetailTable template_name = 'virtualization/virtualmachine_list.html' @@ -268,6 +348,30 @@ class VirtualMachineView(generic.ObjectView): } +class VirtualMachineInterfacesView(generic.ObjectView): + queryset = VirtualMachine.objects.all() + template_name = 'virtualization/virtualmachine/interfaces.html' + + def get_extra_context(self, request, instance): + interfaces = instance.interfaces.restrict(request.user, 'view').prefetch_related( + Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), + 'tags', + ) + interface_table = tables.VirtualMachineVMInterfaceTable( + data=interfaces, + user=request.user, + orderable=False + ) + if request.user.has_perm('virtualization.change_vminterface') or \ + request.user.has_perm('virtualization.delete_vminterface'): + interface_table.columns.show('pk') + + return { + 'interface_table': interface_table, + 'active_tab': 'interfaces', + } + + class VirtualMachineConfigContextView(ObjectConfigContextView): queryset = VirtualMachine.objects.annotate_config_context_data() base_template = 'virtualization/virtualmachine.html' @@ -276,7 +380,6 @@ class VirtualMachineConfigContextView(ObjectConfigContextView): class VirtualMachineEditView(generic.ObjectEditView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineForm - template_name = 'virtualization/virtualmachine_edit.html' class VirtualMachineDeleteView(generic.ObjectDeleteView): @@ -291,14 +394,14 @@ class VirtualMachineBulkImportView(generic.BulkImportView): class VirtualMachineBulkEditView(generic.BulkEditView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') - filterset = filters.VirtualMachineFilterSet + filterset = filtersets.VirtualMachineFilterSet table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm class VirtualMachineBulkDeleteView(generic.BulkDeleteView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') - filterset = filters.VirtualMachineFilterSet + filterset = filtersets.VirtualMachineFilterSet table = tables.VirtualMachineTable @@ -308,10 +411,10 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView): class VMInterfaceListView(generic.ObjectListView): queryset = VMInterface.objects.all() - filterset = filters.VMInterfaceFilterSet + filterset = filtersets.VMInterfaceFilterSet filterset_form = forms.VMInterfaceFilterForm table = tables.VMInterfaceTable - action_buttons = ('export',) + action_buttons = ('import', 'export') class VMInterfaceView(generic.ObjectView): @@ -324,6 +427,14 @@ class VMInterfaceView(generic.ObjectView): orderable=False ) + # Get child interfaces + child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance) + child_interfaces_tables = tables.VMInterfaceTable( + child_interfaces, + orderable=False + ) + child_interfaces_tables.columns.hide('virtual_machine') + # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] if instance.untagged_vlan is not None: @@ -340,6 +451,7 @@ class VMInterfaceView(generic.ObjectView): return { 'ipaddress_table': ipaddress_table, + 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, } @@ -349,7 +461,7 @@ class VMInterfaceCreateView(generic.ComponentCreateView): queryset = VMInterface.objects.all() form = forms.VMInterfaceCreateForm model_form = forms.VMInterfaceForm - template_name = 'virtualization/virtualmachine_component_add.html' + template_name = 'dcim/device_component_add.html' class VMInterfaceEditView(generic.ObjectEditView): @@ -394,7 +506,7 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView): form = forms.VMInterfaceBulkCreateForm queryset = VMInterface.objects.all() model_form = forms.VMInterfaceForm - filterset = filters.VirtualMachineFilterSet + filterset = filtersets.VirtualMachineFilterSet table = tables.VirtualMachineTable def get_required_permission(self): diff --git a/requirements.extras.txt b/requirements.extras.txt index 44fa4aa4b..012c70d59 100644 --- a/requirements.extras.txt +++ b/requirements.extras.txt @@ -1,4 +1,4 @@ django-storages[google]==1.11.1 -netbox-virtual-circuit-plugin==1.6.2 +netbox-virtual-circuit-plugin==1.7.0 django-allauth==0.42.0 -netbox-bgp==0.3.7 +netbox-bgp==0.3.9 diff --git a/requirements.txt b/requirements.txt index ae16926b7..009d69117 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,24 @@ -Django==3.1.7 -django-cacheops==5.1 -django-cors-headers==3.7.0 -django-debug-toolbar==3.2 +Django==3.2.6 +django-cacheops==6.0 +django-cors-headers==3.8.0 +django-debug-toolbar==3.2.2 django-filter==2.4.0 -django-mptt==0.12.0 +django-mptt==0.13.1 django-pglocks==1.0.4 django-prometheus==2.1.0 -django-rq==2.4.0 -django-tables2==2.3.4 -django-taggit==1.3.0 -django-timezone-field==4.1.1 -djangorestframework==3.12.2 +django-rq==2.4.1 +django-tables2==2.4.0 +django-taggit==1.5.1 +django-timezone-field==4.1.2 +djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 -gunicorn==20.0.4 -Jinja2==2.11.3 +gunicorn==20.1.0 +Jinja2==3.0.1 Markdown==3.3.4 netaddr==0.8.0 -Pillow==8.1.2 -psycopg2-binary==2.8.6 +Pillow==8.3.1 +psycopg2-binary==2.9.1 pycryptodome==3.10.1 PyYAML==5.4.1 svgwrite==1.4.1 +tablib==3.0.0 diff --git a/tox.ini b/tox.ini index 9e8fd306f..347e0f7d7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,15 @@ [tox] -envlist=py3 +envlist=py37 skipsdist=True -minversion=2.5.0 +minversion=3.7.0 [testenv] description= run tests with django. deps= + setuptools + pip-tools + psycopg2-binary==2.9.1 -r{toxinidir}/requirements.txt -r{toxinidir}/requirements.extras.txt pycodestyle diff --git a/upgrade.sh b/upgrade.sh index 648f825a0..bd8a8d2d7 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -15,7 +15,7 @@ else fi # Create a new virtual environment -COMMAND="/usr/bin/python3 -m venv ${VIRTUALENV}" +COMMAND="python3 -m venv ${VIRTUALENV}" echo "Creating a new virtual environment at ${VIRTUALENV}..." eval $COMMAND || { echo "--------------------------------------------------------------------"