diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index ec7d667e6..48c14a2da 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.1 + placeholder: v3.6.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index dc27ebd26..0525659ae 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.1 + placeholder: v3.6.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/translation.yaml b/.github/ISSUE_TEMPLATE/translation.yaml new file mode 100644 index 000000000..d07bc399d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation.yaml @@ -0,0 +1,37 @@ +--- +name: 🌍 Translation +description: Request support for a new language in the user interface +labels: ["type: translation"] +body: + - type: markdown + attributes: + value: > + **NOTE:** This template is used only for proposing the addition of *new* languages. Please do + not use it to request changes to existing translations. + - type: input + attributes: + label: Language + description: What is the name of the language in English? + validations: + required: true + - type: input + attributes: + label: ISO 639-1 code + description: > + What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) + assigned to the language? + validations: + required: true + - type: dropdown + attributes: + label: Volunteer + description: Are you a fluent speaker of this language **and** willing to contribute a translation map? + options: + - "Yes" + - "No" + validations: + required: true + - type: textarea + attributes: + label: Comments + description: Any other notes you would like to share diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d9692194..9d580baa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,15 +31,15 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -47,7 +47,7 @@ jobs: run: npm install -g yarn - name: Setup Node.js with Yarn Caching - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: yarn diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 6019cef5d..a3e66a429 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,7 +14,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v3 + - uses: dessant/lock-threads@v4 with: issue-inactive-days: 90 pr-inactive-days: 30 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3b37aae56..22de146a2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v6 + - uses: actions/stale@v8 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an diff --git a/README.md b/README.md index 54b3e727e..6e50e5687 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
NetBox logo -

The premiere source of truth powering network automation

+

The premier source of truth powering network automation

CI status

diff --git a/base_requirements.txt b/base_requirements.txt index 4b75b1313..6e3c5ba19 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -23,8 +23,9 @@ django-filter django-graphiql-debug-toolbar # Modified Preorder Tree Traversal (recursive nesting of objects) +# Pinned to 0.14.0; 0.15.0 requires Python 3.9+ # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst -django-mptt +django-mptt==0.14.0 # Context managers for PostgreSQL advisory locks # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt @@ -52,7 +53,8 @@ django-tables2 # User-defined tags for objects # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst -django-taggit +# TODO: Upgrade to v5.0 for NetBox v3.7 beta +django-taggit<5.0 # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ @@ -120,6 +122,10 @@ psycopg[binary,pool] # https://github.com/yaml/pyyaml/blob/master/CHANGES PyYAML +# Requests +# https://github.com/psf/requests/blob/main/HISTORY.md +requests + # Sentry SDK # https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md sentry-sdk diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 9a6e2417a..5e8507798 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -342,8 +342,10 @@ "100gbase-x-qsfpdd", "200gbase-x-qsfp56", "200gbase-x-qsfpdd", + "400gbase-x-qsfp112", "400gbase-x-qsfpdd", "400gbase-x-osfp", + "400gbase-x-osfp-rhs", "400gbase-x-cdfp", "400gbase-x-cfp8", "800gbase-x-qsfpdd", diff --git a/docs/configuration/default-values.md b/docs/configuration/default-values.md index e76930208..d90e6eafc 100644 --- a/docs/configuration/default-values.md +++ b/docs/configuration/default-values.md @@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [ { 'widget': 'extras.ObjectCountsWidget', 'width': 4, - 'height': 2, + 'height': 3, 'title': 'Organization', 'config': { 'models': [ @@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [ }, { 'widget': 'extras.ObjectCountsWidget', + 'width': 4, + 'height': 3, 'title': 'IPAM', 'color': 'blue', 'config': { diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index fd410a9d4..f143be139 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -80,6 +80,14 @@ changes in the database indefinitely. --- +## DATA_UPLOAD_MAX_MEMORY_SIZE + +Default: `2621440` (2.5 MB) + +The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception. + +--- + ## ENFORCE_GLOBAL_UNIQUE !!! tip "Dynamic Configuration Parameter" @@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address --- -## `FILE_UPLOAD_MAX_MEMORY_SIZE` +## FILE_UPLOAD_MAX_MEMORY_SIZE -Default: `2621440` (2.5 MB). +Default: `2621440` (2.5 MB) The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing. diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 3811474d2..0b1ed11df 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a ## Running Custom Scripts !!! note - To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below. + To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below. ![Adding the run action to a permission](../media/admin_ui_run_permission.png) diff --git a/docs/customization/reports.md b/docs/customization/reports.md index 7e3681304..a821c5da7 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r ## Running Reports !!! note - To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below. + To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below. ![Adding the run action to a permission](../media/admin_ui_run_permission.png) diff --git a/docs/index.md b/docs/index.md index 6a53403d6..05cd79f23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} -# The Premiere Network Source of Truth +# The Premier Network Source of Truth NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure. diff --git a/docs/installation/index.md b/docs/installation/index.md index da50fa5fa..5affdf247 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -1,5 +1,8 @@ # Installation +!!! info "NetBox Cloud" + The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs. + The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md index 514006b01..fc571c05e 100644 --- a/docs/reference/conditions.md +++ b/docs/reference/conditions.md @@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This ] }, { - "attr": "tags", + "attr": "tags.slug", "value": "exempt", "op": "contains" } diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 9b00ac2e5..b8d316218 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,25 +1,121 @@ # NetBox v3.6 -## v3.6.2 (FUTURE) +## v3.6.6 (FUTURE) + +--- + +## v3.6.5 (2023-11-09) ### Enhancements +* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms +* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services +* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns +* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view +* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table +* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table +* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs +* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form +* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()` +* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses +* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view +* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table +* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form +* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table + +### Bug Fixes + +* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object +* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created +* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled +* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache +* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view +* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object + +--- + +## v3.6.4 (2023-10-17) + +### Enhancements + +* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image +* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter +* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI +* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list +* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens +* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables + +### Bug Fixes + +* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form +* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects +* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects +* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering +* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API +* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API +* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views +* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API +* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view +* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API +* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table +* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters +* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable +* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another +* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes +* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk + +--- + +## v3.6.3 (2023-09-26) + +### Enhancements + +* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view + +### Bug Fixes + +* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel +* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API +* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API +* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined +* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements +* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit +* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type +* [#13849](https://github.com/netbox-community/netbox/issues/13849) - Fix `KeyError` exception when deleting an object which references a configured choice value that has been removed +* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches +* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers +* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import +* [#13891](https://github.com/netbox-community/netbox/issues/13891) - Allow designating an IP address as primary for device/VM while assigning it to an interface + +--- + +## v3.6.2 (2023-09-20) + +### Enhancements + +* [#13245](https://github.com/netbox-community/netbox/issues/13245) - Add interface types for QSFP112 and OSFP-RHS * [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import ### Bug Fixes * [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range +* [#11617](https://github.com/netbox-community/netbox/issues/11617) - Raise validation error on the presence of an unknown CSV header during bulk import * [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode +* [#12685](https://github.com/netbox-community/netbox/issues/12685) - Render Markdown in custom field help text on object edit forms +* [#13653](https://github.com/netbox-community/netbox/issues/13653) - Tweak color of error text to improve legibility * [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view * [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list * [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix * [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned -* [#13741](https://github.com/netbox-community/netbox/issues/13741) - Enforce unique names for inventory items with no parent item * [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases * [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status +* [#13757](https://github.com/netbox-community/netbox/issues/13757) - Fix RecursionError exception when assigning config context to a device type * [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI * [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments * [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI +* [#13809](https://github.com/netbox-community/netbox/issues/13809) - Avoid TypeError exception when editing active configuration with statically defined `CUSTOM_VALIDATORS` +* [#13813](https://github.com/netbox-community/netbox/issues/13813) - Fix member count for newly created virtual chassis +* [#13818](https://github.com/netbox-community/netbox/issues/13818) - Restore missing tags field on L2VPN termination edit form --- diff --git a/netbox/core/management/commands/clearcache.py b/netbox/core/management/commands/clearcache.py index 22843c490..dd95013af 100644 --- a/netbox/core/management/commands/clearcache.py +++ b/netbox/core/management/commands/clearcache.py @@ -1,11 +1,20 @@ from django.core.cache import cache from django.core.management.base import BaseCommand +from extras.models import ConfigRevision + class Command(BaseCommand): """Command to clear the entire cache.""" help = 'Clears the cache.' def handle(self, *args, **kwargs): + # Fetch the current config revision from the cache + config_version = cache.get('config_version') + # Clear the cache cache.clear() self.stdout.write('Cache has been cleared.', ending="\n") + if config_version: + # Activate the current config revision + ConfigRevision.objects.get(id=config_version).activate() + self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n") diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index 32ca67f7f..f65964f77 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -19,7 +19,8 @@ class JobTable(NetBoxTable): ) object = tables.Column( verbose_name=_('Object'), - linkify=True + linkify=True, + orderable=False ) status = columns.ChoiceFieldColumn( verbose_name=_('Status'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f045f1bb4..80a991736 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -20,7 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.renderers import TextRenderer -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model @@ -98,7 +98,7 @@ class PassThroughPortMixin(object): # Regions # -class RegionViewSet(NetBoxModelViewSet): +class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -114,7 +114,7 @@ class RegionViewSet(NetBoxModelViewSet): # Site groups # -class SiteGroupViewSet(NetBoxModelViewSet): +class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = SiteGroup.objects.add_related_count( SiteGroup.objects.all(), Site, @@ -149,7 +149,7 @@ class SiteViewSet(NetBoxModelViewSet): # Locations # -class LocationViewSet(NetBoxModelViewSet): +class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = Location.objects.add_related_count( Location.objects.add_related_count( Location.objects.all(), @@ -350,7 +350,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet): filterset_class = filtersets.DeviceBayTemplateFilterSet -class InventoryItemTemplateViewSet(NetBoxModelViewSet): +class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') serializer_class = serializers.InventoryItemTemplateSerializer filterset_class = filtersets.InventoryItemTemplateFilterSet @@ -538,7 +538,7 @@ class DeviceBayViewSet(NetBoxModelViewSet): brief_prefetch_fields = ['device'] -class InventoryItemViewSet(NetBoxModelViewSet): +class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filtersets.InventoryItemFilterSet diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 1bcf61b20..e1d4a330a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' TYPE_400GE_CFP2 = '400gbase-x-cfp2' + TYPE_400GE_QSFP112 = '400gbase-x-qsfp112' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_OSFP = '400gbase-x-osfp' + TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs' TYPE_400GE_CDFP = '400gbase-x-cdfp' TYPE_400GE_CFP8 = '400gbase-x-cfp8' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' @@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'), + (TYPE_400GE_QSFP112, 'QSFP112 (400GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'), + (TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'), (TYPE_400GE_CDFP, 'CDFP (400GE)'), (TYPE_400GE_CFP8, 'CPF8 (400GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0261998db..b5bdaf269 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate +from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, L2VPN, IPAddress, VRF from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, @@ -817,7 +818,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): +class DeviceFilterSet( + NetBoxModelFilterSet, + TenancyFilterSet, + ContactModelFilterSet, + LocalConfigContextFilterSet, + PrimaryIPFilterSet, +): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer', queryset=Manufacturer.objects.all(), @@ -993,16 +1000,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter method='_device_bays', label=_('Has device bays'), ) - primary_ip4_id = django_filters.ModelMultipleChoiceFilter( - field_name='primary_ip4', - queryset=IPAddress.objects.all(), - label=_('Primary IPv4 (ID)'), - ) - primary_ip6_id = django_filters.ModelMultipleChoiceFilter( - field_name='primary_ip6', - queryset=IPAddress.objects.all(), - label=_('Primary IPv6 (ID)'), - ) oob_ip_id = django_filters.ModelMultipleChoiceFilter( field_name='oob_ip', queryset=IPAddress.objects.all(), @@ -1069,7 +1066,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter return queryset.exclude(devicebays__isnull=value) -class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): +class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet): device_id = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), @@ -1745,6 +1742,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): method='filter_by_cable_end_b', field_name='terminations__termination_id' ) + unterminated = django_filters.BooleanFilter( + method='_unterminated', + label=_('Unterminated'), + ) type = django_filters.MultipleChoiceFilter( choices=CableTypeChoices ) @@ -1812,6 +1813,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): # Filter by termination id and cable_end type return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B) + def _unterminated(self, queryset, name, value): + if value: + terminated_ids = ( + queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A) + .filter(terminations__cable_end=CableEndChoices.SIDE_B) + .values("id") + ) + return queryset.exclude(id__in=terminated_ids) + else: + return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter( + terminations__cable_end=CableEndChoices.SIDE_B + ) + class CableTerminationFilterSet(BaseFilterSet): termination_type = ContentTypeFilter() diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 74af0696b..e41e875e4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -549,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm): params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), } - if 'location' in data: + if location := data.get('location'): params.update({ - f"location__{self.fields['location'].to_field_name}": data.get('location'), + f"location__{self.fields['location'].to_field_name}": location, }) self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) @@ -1192,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm): termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name) else: termination_object = model.objects.get(device=device, name=name) - if termination_object.cable is not None: + if termination_object.cable is not None and termination_object.cable != self.instance: raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") except ObjectDoesNotExist: raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}") diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 43e5f4481..d0d321187 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Device type') ) - role_id = DynamicModelMultipleChoiceField( + device_role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False, label=_('Device role') @@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')), - (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')), + (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')), (_('Tenant'), ('tenant_group_id', 'tenant_id')), ) region_id = DynamicModelMultipleChoiceField( @@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): choices=add_blank_choice(CableLengthUnitChoices), required=False ) + unterminated = forms.NullBooleanField( + label=_('Unterminated'), + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) tag = TagFilterField(model) @@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type', 'speed')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type', 'speed')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (_('PoE'), ('poe_mode', 'poe_type')), (_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) vdc_id = DynamicModelMultipleChoiceField( @@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type', 'color')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Cable'), ('cabled', 'occupied')), ) model = FrontPort @@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type', 'color')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Cable'), ('cabled', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'position')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) tag = TagFilterField(model) position = forms.CharField( @@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) tag = TagFilterField(model) @@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 93e214598..219e1f6c3 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -442,7 +442,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm): platform = DynamicModelChoiceField( label=_('Platform'), queryset=Platform.objects.all(), - required=False + required=False, + selector=True ) cluster = DynamicModelChoiceField( label=_('Cluster'), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index abd7bd6f6..ea842508f 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp ) self.fields['rear_port'].choices = choices + def clean(self): + + # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate + # positions + frontport_count = len(self.cleaned_data['name']) + rearport_count = len(self.cleaned_data['rear_port']) + if frontport_count != rearport_count: + raise forms.ValidationError({ + 'rear_port': _( + "The number of front port templates to be created ({frontport_count}) must match the selected " + "number of rear port positions ({rearport_count})." + ).format( + frontport_count=frontport_count, + rearport_count=rearport_count + ) + }) + def get_iterative_data(self, iteration): # Assign rear port and position from selected set @@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): ) self.fields['rear_port'].choices = choices + def clean(self): + + # Check that the number of FrontPorts to be created matches the selected number of RearPort positions + frontport_count = len(self.cleaned_data['name']) + rearport_count = len(self.cleaned_data['rear_port']) + if frontport_count != rearport_count: + raise forms.ValidationError({ + 'rear_port': _( + "The number of front ports to be created ({frontport_count}) must match the selected number of " + "rear port positions ({rearport_count})." + ).format( + frontport_count=frontport_count, + rearport_count=rearport_count + ) + }) + def get_iterative_data(self, iteration): # Assign rear port and position from selected set diff --git a/netbox/dcim/migrations/0182_inventoryitem_uniqueness_fix.py b/netbox/dcim/migrations/0182_inventoryitem_uniqueness_fix.py deleted file mode 100644 index 85cd2196b..000000000 --- a/netbox/dcim/migrations/0182_inventoryitem_uniqueness_fix.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.5 on 2023-09-15 14:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0181_rename_device_role_device_role'), - ] - - operations = [ - migrations.AddConstraint( - model_name='inventoryitem', - constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('device', 'name'), name='dcim_inventoryitem_unique_device_name'), - ), - ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index de7ba0eb6..f240659dd 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -20,7 +20,7 @@ from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters from wireless.models import WirelessLink -from .device_components import FrontPort, RearPort +from .device_components import FrontPort, RearPort, PathEndpoint __all__ = ( 'Cable', @@ -98,10 +98,10 @@ class Cable(PrimaryModel): super().__init__(*args, **kwargs) # A copy of the PK to be used by __str__ in case the object is deleted - self._pk = self.pk + self._pk = self.__dict__.get('id') # Cache the original status so we can check later if it's been changed - self._orig_status = self.status + self._orig_status = self.__dict__.get('status') self._terminations_modified = False @@ -180,6 +180,17 @@ class Cable(PrimaryModel): if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") + if a_type == b_type: + # can't directly use self.a_terminations here as possible they + # don't have pk yet + a_pks = set(obj.pk for obj in self.a_terminations if obj.pk) + b_pks = set(obj.pk for obj in self.b_terminations if obj.pk) + + if (a_pks & b_pks): + raise ValidationError( + _("A and B terminations cannot connect to the same object.") + ) + # Run clean() on any new CableTerminations for termination in self.a_terminations: CableTermination(cable=self, cable_end='A', termination=termination).clean() @@ -518,9 +529,16 @@ class CablePath(models.Model): # Terminations must all be of the same type assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + # All mid-span terminations must all be attached to the same device + if not isinstance(terminations[0], PathEndpoint): + assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:]) + # Check for a split path (e.g. rear port fanning out to multiple front ports with # different cables attached) - if len(set(t.link for t in terminations)) > 1: + if len(set(t.link for t in terminations)) > 1 and ( + position_stack and len(terminations) != len(position_stack[-1]) + ): is_split = True break @@ -529,46 +547,68 @@ class CablePath(models.Model): object_to_path_node(t) for t in terminations ]) - # Step 2: Determine the attached link (Cable or WirelessLink), if any - link = terminations[0].link - if link is None and len(path) == 1: - # If this is the start of the path and no link exists, return None - return None - elif link is None: + # Step 2: Determine the attached links (Cable or WirelessLink), if any + links = [termination.link for termination in terminations if termination.link is not None] + if len(links) == 0: + if len(path) == 1: + # If this is the start of the path and no link exists, return None + return None # Otherwise, halt the trace if no link exists break - assert type(link) in (Cable, WirelessLink) + assert all(type(link) in (Cable, WirelessLink) for link in links) + assert all(isinstance(link, type(links[0])) for link in links) - # Step 3: Record the link and update path status if not "connected" - path.append([object_to_path_node(link)]) - if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED: + # Step 3: Record asymmetric paths as split + not_connected_terminations = [termination.link for termination in terminations if termination.link is None] + if len(not_connected_terminations) > 0: + is_complete = False + is_split = True + + # Step 4: Record the links, keeping cables in order to allow for SVG rendering + cables = [] + for link in links: + if object_to_path_node(link) not in cables: + cables.append(object_to_path_node(link)) + path.append(cables) + + # Step 5: Update the path status if a link is not connected + links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED] + if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]): is_active = False - # Step 4: Determine the far-end terminations - if isinstance(link, Cable): + # Step 6: Determine the far-end terminations + if isinstance(links[0], Cable): termination_type = ContentType.objects.get_for_model(terminations[0]) local_cable_terminations = CableTermination.objects.filter( termination_type=termination_type, termination_id__in=[t.pk for t in terminations] ) - # Terminations must all belong to same end of Cable - local_cable_end = local_cable_terminations[0].cable_end - assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:]) - remote_cable_terminations = CableTermination.objects.filter( - cable=link, - cable_end='A' if local_cable_end == 'B' else 'B' - ) + + q_filter = Q() + for lct in local_cable_terminations: + cable_end = 'A' if lct.cable_end == 'B' else 'B' + q_filter |= Q(cable=lct.cable, cable_end=cable_end) + + remote_cable_terminations = CableTermination.objects.filter(q_filter) remote_terminations = [ct.termination for ct in remote_cable_terminations] else: # WirelessLink - remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a] + remote_terminations = [ + link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links + ] - # Step 5: Record the far-end termination object(s) + # Remote Terminations must all be of the same type, otherwise return a split path + if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): + is_complete = False + is_split = True + break + + # Step 7: Record the far-end termination object(s) path.append([ object_to_path_node(t) for t in remote_terminations if t is not None ]) - # Step 6: Determine the "next hop" terminations, if applicable + # Step 8: Determine the "next hop" terminations, if applicable if not remote_terminations: break @@ -577,20 +617,32 @@ class CablePath(models.Model): rear_ports = RearPort.objects.filter( pk__in=[t.rear_port_id for t in remote_terminations] ) - if len(rear_ports) > 1: - assert all(rp.positions == 1 for rp in rear_ports) - elif rear_ports[0].positions > 1: + if len(rear_ports) > 1 or rear_ports[0].positions > 1: position_stack.append([fp.rear_port_position for fp in remote_terminations]) terminations = rear_ports elif isinstance(remote_terminations[0], RearPort): - - if len(remote_terminations) > 1 or remote_terminations[0].positions == 1: + if len(remote_terminations) == 1 and remote_terminations[0].positions == 1: front_ports = FrontPort.objects.filter( rear_port_id__in=[rp.pk for rp in remote_terminations], rear_port_position=1 ) + # Obtain the individual front ports based on the termination and all positions + elif len(remote_terminations) > 1 and position_stack: + positions = position_stack.pop() + + # Ensure we have a number of positions equal to the amount of remote terminations + assert len(remote_terminations) == len(positions) + + # Get our front ports + q_filter = Q() + for rt in remote_terminations: + position = positions.pop() + q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) + assert q_filter is not Q() + front_ports = FrontPort.objects.filter(q_filter) + # Obtain the individual front ports based on the termination and position elif position_stack: front_ports = FrontPort.objects.filter( rear_port_id=remote_terminations[0].pk, @@ -632,9 +684,16 @@ class CablePath(models.Model): terminations = [circuit_termination] - # Anything else marks the end of the path else: - is_complete = True + # Check for non-symmetric path + if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): + is_complete = True + elif len(remote_terminations) == 0: + is_complete = False + else: + # Unsupported topology, mark as split and exit + is_complete = False + is_split = True break return cls( @@ -740,3 +799,15 @@ class CablePath(models.Model): return [ ct.get_peer_termination() for ct in nodes ] + + def get_asymmetric_nodes(self): + """ + Return all available next segments in a split cable path. + """ + from circuits.models import CircuitTermination + asymmetric_nodes = [] + for nodes in self.path_objects: + if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]: + asymmetric_nodes.extend([node for node in nodes if node.link is None]) + + return asymmetric_nodes diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index f58d2bbca..86b6d85fe 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): super().__init__(*args, **kwargs) # Cache the original DeviceType ID for reference under clean() - self._original_device_type = self.device_type_id + self._original_device_type = self.__dict__.get('device_type_id') def to_objectchange(self, action): objectchange = super().to_objectchange(action) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 76c378cc0..639f8aadb 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel): super().__init__(*args, **kwargs) # Cache the original Device ID for reference under clean() - self._original_device = self.device_id + self._original_device = self.__dict__.get('device_id') def __str__(self): if self.label: @@ -1246,11 +1246,6 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): fields=('device', 'parent', 'name'), name='%(app_label)s_%(class)s_unique_device_parent_name' ), - models.UniqueConstraint( - fields=('device', 'name'), - name='%(app_label)s_%(class)s_unique_device_name', - condition=Q(parent__isnull=True) - ), ) verbose_name = _('inventory item') verbose_name_plural = _('inventory items') diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 857251caf..c9ebf898d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -4,6 +4,7 @@ import yaml from functools import cached_property from django.core.exceptions import ValidationError +from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F, ProtectedError @@ -205,11 +206,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): super().__init__(*args, **kwargs) # Save a copy of u_height for validation in clean() - self._original_u_height = self.u_height + self._original_u_height = self.__dict__.get('u_height') # Save references to the original front/rear images - self._original_front_image = self.front_image - self._original_rear_image = self.rear_image + self._original_front_image = self.__dict__.get('front_image') + self._original_rear_image = self.__dict__.get('rear_image') def get_absolute_url(self): return reverse('dcim:devicetype', args=[self.pk]) @@ -332,10 +333,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): ret = super().save(*args, **kwargs) # Delete any previously uploaded image files that are no longer in use - if self.front_image != self._original_front_image: - self._original_front_image.delete(save=False) - if self.rear_image != self._original_rear_image: - self._original_rear_image.delete(save=False) + if self._original_front_image and self.front_image != self._original_front_image: + default_storage.delete(self._original_front_image) + if self._original_rear_image and self.rear_image != self._original_rear_image: + default_storage.delete(self._original_rear_image) return ret diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 9413726fa..acc4fcad9 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -32,11 +32,18 @@ class Node(Hyperlink): color: Box fill color (RRGGBB format) labels: An iterable of text strings. Each label will render on a new line within the box. radius: Box corner radius, for rounded corners (default: 10) + object: A copy of the object to allow reference when drawing cables to determine which cables are connected to + which terminations. """ - def __init__(self, position, width, url, color, labels, radius=10, **extra): + object = None + + def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra): super(Node, self).__init__(href=url, target='_parent', **extra) + # Save object for reference by cable systems + self.object = object + x, y = position # Add the box @@ -77,7 +84,7 @@ class Connector(Group): labels: Iterable of text labels """ - def __init__(self, start, url, color, labels=[], **extra): + def __init__(self, start, url, color, labels=[], description=[], **extra): super().__init__(class_='connector', **extra) self.start = start @@ -104,6 +111,8 @@ class Connector(Group): text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) text = Text(label, insert=text_coords, class_='bold' if not i else []) link.add(text) + if len(description) > 0: + link.set_desc("\n".join(description)) self.add(link) @@ -151,6 +160,8 @@ class CableTraceSVG: elif instance._meta.model_name == 'circuit': labels[0] = f'Circuit {instance}' labels.append(instance.provider) + if instance.description: + labels.append(instance.description) elif instance._meta.model_name == 'circuittermination': if instance.xconnect_id: labels.append(f'{instance.xconnect_id}') @@ -206,7 +217,8 @@ class CableTraceSVG: url=f'{self.base_url}{term.get_absolute_url()}', color=self._get_color(term), labels=self._get_labels(term), - radius=5 + radius=5, + object=term ) nodes_height = max(nodes_height, node.box['height']) nodes.append(node) @@ -238,22 +250,65 @@ class CableTraceSVG: Polyline(points=points, style=f'stroke: #{connector.color}'), )) - def draw_cable(self, cable): - labels = [ - f'Cable {cable}', - cable.get_status_display() - ] - if cable.type: - labels.append(cable.get_type_display()) - if cable.length and cable.length_unit: - labels.append(f'{cable.length} {cable.get_length_unit_display()}') + def draw_cable(self, cable, terminations, cable_count=0): + """ + Draw a single cable. Terminations and cable count are passed for determining position and padding + + :param cable: The cable to draw + :param terminations: List of terminations to build positioning data off of + :param cable_count: Count of all cables on this layer for determining whether to collapse description into a + tooltip. + """ + + # If the cable count is higher than 2, collapse the description into a tooltip + if cable_count > 2: + # Use the cable __str__ function to denote the cable + labels = [f'{cable}'] + + # Include the label and the status description in the tooltip + description = [ + f'Cable {cable}', + cable.get_status_display() + ] + + if cable.type: + # Include the cable type in the tooltip + description.append(cable.get_type_display()) + if cable.length and cable.length_unit: + # Include the cable length in the tooltip + description.append(f'{cable.length} {cable.get_length_unit_display()}') + else: + labels = [ + f'Cable {cable}', + cable.get_status_display() + ] + description = [] + if cable.type: + labels.append(cable.get_type_display()) + if cable.length and cable.length_unit: + # Include the cable length in the tooltip + labels.append(f'{cable.length} {cable.get_length_unit_display()}') + + # If there is only one termination, center on that termination + # Otherwise average the center across the terminations + if len(terminations) == 1: + center = terminations[0].bottom_center[0] + else: + # Get a list of termination centers + termination_centers = [term.bottom_center[0] for term in terminations] + # Average the centers + center = sum(termination_centers) / len(termination_centers) + + # Create the connector connector = Connector( - start=(self.center + OFFSET, self.cursor), + start=(center, self.cursor), color=cable.color or '000000', url=f'{self.base_url}{cable.get_absolute_url()}', - labels=labels + labels=labels, + description=description ) + # Set the cursor position self.cursor += connector.height return connector @@ -334,34 +389,52 @@ class CableTraceSVG: # Connector (a Cable or WirelessLink) if links: - link = links[0] # Remove Cable from list + link_cables = {} + fanin = False + fanout = False - # Cable - if type(link) is Cable: + # Determine if we have fanins or fanouts + if len(near_ends) > len(set(links)): + self.cursor += FANOUT_HEIGHT + fanin = True + if len(far_ends) > len(set(links)): + fanout = True + cursor = self.cursor + for link in links: + # Cable + if type(link) is Cable and not link_cables.get(link.pk): + # Reset cursor + self.cursor = cursor + # Generate a list of terminations connected to this cable + near_end_link_terminations = [term for term in terminations if term.object.cable == link] + # Draw the cable + cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) + # Add cable to the list of cables + link_cables.update({link.pk: cable}) + # Add cable to drawing + self.connectors.append(cable) - # Account for fan-ins height - if len(near_ends) > 1: - self.cursor += FANOUT_HEIGHT + # Draw fan-ins + if len(near_ends) > 1 and fanin: + for term in terminations: + if term.object.cable == link: + self.draw_fanin(term, cable) - cable = self.draw_cable(link) - self.connectors.append(cable) - - # Draw fan-ins - if len(near_ends) > 1: - for term in terminations: - self.draw_fanin(term, cable) - - # WirelessLink - elif type(link) is WirelessLink: - wirelesslink = self.draw_wirelesslink(link) - self.connectors.append(wirelesslink) + # WirelessLink + elif type(link) is WirelessLink: + wirelesslink = self.draw_wirelesslink(link) + self.connectors.append(wirelesslink) # Far end termination(s) if len(far_ends) > 1: - self.cursor += FANOUT_HEIGHT - terminations = self.draw_terminations(far_ends) - for term in terminations: - self.draw_fanout(term, cable) + if fanout: + self.cursor += FANOUT_HEIGHT + terminations = self.draw_terminations(far_ends) + for term in terminations: + if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk): + self.draw_fanout(term, link_cables.get(term.object.cable.pk)) + else: + self.draw_terminations(far_ends) elif far_ends: self.draw_terminations(far_ends) else: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 34dbcbf30..b72c37daa 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -64,9 +64,19 @@ def get_interface_state_attribute(record): Get interface enabled state as string to attach to DOM element. """ if record.enabled: - return "enabled" + return 'enabled' else: - return "disabled" + return 'disabled' + + +def get_interface_connected_attribute(record): + """ + Get interface disconnected state as string to attach to DOM element. + """ + if record.mark_connected or record.cable: + return 'connected' + else: + return 'disconnected' # @@ -456,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) + maximum_draw = tables.Column( + verbose_name=_('Maximum draw (W)') + ) + allocated_draw = tables.Column( + verbose_name=_('Allocated draw (W)') + ) tags = columns.TagColumn( url_name='dcim:powerport_list' ) @@ -615,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi verbose_name=_('VRF'), linkify=True ) + inventory_items = tables.ManyToManyColumn( + linkify_item=True, + verbose_name=_('Inventory Items'), + ) tags = columns.TagColumn( url_name='dcim:interface_list' ) @@ -626,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -674,6 +694,7 @@ class DeviceInterfaceTable(InterfaceTable): 'data-name': lambda record: record.name, 'data-enabled': get_interface_state_attribute, 'data-type': lambda record: record.type, + 'data-connected': get_interface_connected_attribute } @@ -922,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable): discovered = columns.BooleanColumn( verbose_name=_('Discovered'), ) + parent = tables.Column( + linkify=True, + verbose_name=_('Parent'), + ) tags = columns.TagColumn( url_name='dcim:inventoryitem_list' ) @@ -930,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable): class Meta(NetBoxTable.Meta): model = models.InventoryItem fields = ( - 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', + 'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index e4735bd57..40a58ad81 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable): linkify=True, verbose_name=_('Tenant') ) + site = tables.Column( + accessor='rack__site', + linkify=True, + verbose_name=_('Site'), + ) comments = columns.MarkdownColumn( verbose_name=_('Comments'), ) @@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable): class Meta(NetBoxTable.Meta): model = PowerFeed fields = ( - 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant', - 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', + 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', + 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index d25333aed..a827939f7 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -15,6 +15,7 @@ class CablePathTestCase(TestCase): 1XX: Test direct connections between different endpoint types 2XX: Test different cable topologies 3XX: Test responses to changes in existing objects + 4XX: Test to exclude specific cable topologies """ @classmethod def setUpTestData(cls): @@ -33,12 +34,11 @@ class CablePathTestCase(TestCase): circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') - def assertPathExists(self, nodes, **kwargs): + def _get_cablepath(self, nodes, **kwargs): """ - Assert that a CablePath from origin to destination with a specific intermediate path exists. + Return a given cable path :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) :return: The matching CablePath (if any) """ @@ -48,12 +48,29 @@ class CablePathTestCase(TestCase): path.append([object_to_path_node(node) for node in step]) else: path.append([object_to_path_node(step)]) + return CablePath.objects.filter(path=path, **kwargs).first() - cablepath = CablePath.objects.filter(path=path, **kwargs).first() + def assertPathExists(self, nodes, **kwargs): + """ + Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the + first matching CablePath, if found. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) self.assertIsNotNone(cablepath, msg='CablePath not found') return cablepath + def assertPathDoesNotExist(self, nodes, **kwargs): + """ + Assert that a specific CablePath does *not* exist. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) + self.assertIsNone(cablepath, msg='Unexpected CablePath found') + def assertPathIsSet(self, origin, cablepath, msg=None): """ Assert that a specific CablePath instance is set as the path on the origin. @@ -1695,6 +1712,291 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface3, path3) self.assertPathIsSet(interface4, path4) + def test_219_interface_to_interface_duplex_via_multiple_rearports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [FP3] [RP3] --C4-- [RP4] [FP4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport3] + ) + cable1.save() + self.assertPathExists( + (interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + self.assertPathExists( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] + """ + 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') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3] + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3) + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + + def test_221_non_symmetric_paths(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/ + """ + 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') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) + rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + frontport5 = FrontPort.objects.create( + device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1 + ) + frontport6 = FrontPort.objects.create( + device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + cable6 = Cable( + a_terminations=[frontport4], + b_terminations=[frontport5], + label='C6' + ) + cable6.save() + cable7 = Cable( + a_terminations=[rearport5], + b_terminations=[rearport6], + label='C7' + ) + cable7.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1], + label='C1' + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3], + label='C5' + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport6], + b_terminations=[interface2], + label='C3' + ) + cable3.save() + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7), + (rearport1, rearport5), (frontport1, frontport5), (cable1, cable6) + ), + is_complete=False, + is_split=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] @@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase): is_complete=True, is_active=True ) + + def test_401_exclude_midspan_devices(self): + """ + [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2] + [FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] / + """ + device = Device.objects.create( + site=self.site, + device_type=self.device.device_type, + device_role=self.device.device_role, + name='Test mid-span Device' + ) + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport3], + label='C1' + ) + with self.assertRaises(AssertionError): + cable1.save() + + self.assertPathDoesNotExist( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4) + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2], + label='C3' + ) + + with self.assertRaises(AssertionError): + cable3.save() + + self.assertPathDoesNotExist( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport2), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertPathDoesNotExist( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 0) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index dd5ff7bc2..8fbef126e 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4275,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED), ) Interface.objects.bulk_create(interfaces) @@ -4290,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save() + # Cable for unterminated test + Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save() + def test_label(self): params = {'label': ['Cable 1', 'Cable 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -4368,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): } self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_unterminated(self): + params = {'unterminated': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'unterminated': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) + class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerPanel.objects.all() @@ -4702,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): addresses = ( IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), + IPAddress(assigned_object=None, address='10.1.1.3/24'), + IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'), + IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'), + IPAddress(assigned_object=None, address='2001:db8::3/64'), ) IPAddress.objects.bulk_create(addresses) vdcs[0].primary_ip4 = addresses[0] + vdcs[0].primary_ip6 = addresses[3] vdcs[0].save() vdcs[1].primary_ip4 = addresses[1] + vdcs[1].primary_ip6 = addresses[4] vdcs[1].save() def test_device(self): @@ -4728,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'has_primary_ip': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_primary_ip4(self): + addresses = IPAddress.objects.filter(address__family=4) + params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip4_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + + def test_primary_ip6(self): + addresses = IPAddress.objects.filter(address__family=6) + params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip6_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2f661e613..c67dfaade 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -122,16 +122,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View) if form.is_valid(): with transaction.atomic(): - count = 0 + cable_ids = set() for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): - if obj.cable is None: - continue - obj.cable.delete() - count += 1 + if obj.cable: + cable_ids.add(obj.cable.pk) + count += 1 + for cable in Cable.objects.filter(pk__in=cable_ids): + cable.delete() - messages.success(request, "Disconnected {} {}".format( - count, self.queryset.model._meta.verbose_name_plural + messages.success(request, _("Disconnected {count} {type}").format( + count=count, + type=self.queryset.model._meta.verbose_name_plural )) return redirect(return_url) @@ -2991,6 +2993,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView): template_name = 'dcim/inventoryitem_bulk_delete.html' +@register_model_view(InventoryItem, 'children') +class InventoryItemChildrenView(generic.ObjectChildrenView): + queryset = InventoryItem.objects.all() + child_model = InventoryItem + table = tables.InventoryItemTable + filterset = filtersets.InventoryItemFilterSet + template_name = 'generic/object_children.html' + tab = ViewTab( + label=_('Children'), + badge=lambda obj: obj.child_items.count(), + permission='dcim.view_inventoryitem', + hide_if_empty=True, + weight=5000 + ) + + def get_children(self, request, parent): + return parent.child_items.restrict(request.user, 'view') + + # # Inventory item roles # diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index e007db43d..c1fad99ee 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer required=False ) data_file = NestedDataFileSerializer( - read_only=True + required=False ) class Meta: diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 06797891e..f518275e0 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -82,7 +82,10 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet): data = [ {'id': c[0], 'display': c[1]} for c in page ] - return self.get_paginated_response(data) + else: + data = [] + + return self.get_paginated_response(data) # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 1061bf871..0572a33a1 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -244,3 +244,39 @@ class ChangeActionChoices(ChoiceSet): (ACTION_UPDATE, _('Update'), 'blue'), (ACTION_DELETE, _('Delete'), 'red'), ) + + +# +# Dashboard widgets +# + +class DashboardWidgetColorChoices(ChoiceSet): + BLUE = 'blue' + INDIGO = 'indigo' + PURPLE = 'purple' + PINK = 'pink' + RED = 'red' + ORANGE = 'orange' + YELLOW = 'yellow' + GREEN = 'green' + TEAL = 'teal' + CYAN = 'cyan' + GRAY = 'gray' + BLACK = 'black' + WHITE = 'white' + + CHOICES = ( + (BLUE, _('Blue')), + (INDIGO, _('Indigo')), + (PURPLE, _('Purple')), + (PINK, _('Pink')), + (RED, _('Red')), + (ORANGE, _('Orange')), + (YELLOW, _('Yellow')), + (GREEN, _('Green')), + (TEAL, _('Teal')), + (CYAN, _('Cyan')), + (GRAY, _('Gray')), + (BLACK, _('Black')), + (WHITE, _('White')), + ) diff --git a/netbox/extras/dashboard/forms.py b/netbox/extras/dashboard/forms.py index 1e9f15408..ab708228c 100644 --- a/netbox/extras/dashboard/forms.py +++ b/netbox/extras/dashboard/forms.py @@ -2,9 +2,9 @@ from django import forms from django.urls import reverse_lazy from django.utils.translation import gettext as _ +from extras.choices import DashboardWidgetColorChoices from netbox.registry import registry from utilities.forms import BootstrapMixin, add_blank_choice -from utilities.choices import ButtonColorChoices __all__ = ( 'DashboardWidgetAddForm', @@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form): required=False ) color = forms.ChoiceField( - choices=add_blank_choice(ButtonColorChoices), + choices=add_blank_choice(DashboardWidgetColorChoices), required=False, ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index c8afeb090..83a346420 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -76,7 +76,8 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): 'type': _( "The type of data stored in this field. For object/multi-object fields, select the related object " "type below." - ) + ), + 'description': _("This will be displayed as help text for the form field. Markdown is supported.") } def __init__(self, *args, **kwargs): @@ -517,22 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe config = get_config() for param in PARAMS: value = getattr(config, param.name) - is_static = hasattr(settings, param.name) - if value: - help_text = self.fields[param.name].help_text - if help_text: - help_text += '
' # Line break - help_text += _('Current value: {value}').format(value=value) - if is_static: - help_text += _(' (defined statically)') - elif value == param.default: - help_text += _(' (default)') - self.fields[param.name].help_text = help_text + + # Set the field's initial value, if it can be serialized. (This may not be the case e.g. for + # CUSTOM_VALIDATORS, which may reference Python objects.) + try: + json.dumps(value) if type(value) in (tuple, list): - value = ', '.join(value) - self.fields[param.name].initial = value - if is_static: + self.fields[param.name].initial = ', '.join(value) + else: + self.fields[param.name].initial = value + except TypeError: + pass + + # Check whether this parameter is statically configured (e.g. in configuration.py) + if hasattr(settings, param.name): self.fields[param.name].disabled = True + self.fields[param.name].help_text = _( + 'This parameter has been defined statically and cannot be modified.' + ) + continue + + # Set the field's help text + help_text = self.fields[param.name].help_text + if help_text: + help_text += '
' # Line break + help_text += _('Current value: {value}').format(value=value or '—') + if value == param.default: + help_text += _(' (default)') + self.fields[param.name].help_text = help_text def save(self, commit=True): instance = super().save(commit=False) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 0c4a0c615..2bed464bb 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -28,6 +28,7 @@ from utilities.forms.fields import ( from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker from utilities.querysets import RestrictedQuerySet +from utilities.templatetags.builtins.filters import render_markdown from utilities.validators import validate_regex __all__ = ( @@ -219,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): super().__init__(*args, **kwargs) # Cache instance's original name so we can check later whether it has changed - self._name = self.name + self._name = self.__dict__.get('name') @property def search_type(self): @@ -231,6 +232,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): return self.choice_set.choices return [] + def get_choice_label(self, value): + if not hasattr(self, '_choice_map'): + self._choice_map = dict(self.choices) + return self._choice_map.get(value, value) + def populate_initial_data(self, content_types): """ Populate initial custom field data upon either a) the creation of a new CustomField, or @@ -498,7 +504,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): field.model = self field.label = str(self) if self.description: - field.help_text = escape(self.description) + field.help_text = render_markdown(self.description) # Annotate read-only fields if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 6af81a9d9..cc279a49a 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) def get_module_and_report(module_name, report_name): module = ReportModule.objects.get(file_path=f'{module_name}.py') - report = module.reports.get(report_name) + report = module.reports.get(report_name)() return module, report @@ -106,8 +106,6 @@ class Report(object): 'failure': 0, 'log': [], } - if not test_methods: - raise Exception("A report must contain at least one test method.") self.test_methods = test_methods @classproperty @@ -137,6 +135,13 @@ class Report(object): def source(self): return inspect.getsource(self.__class__) + @property + def is_valid(self): + """ + Indicates whether the report can be run. + """ + return bool(self.test_methods) + # # Logging methods # diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 01ef9a2a6..e034abff5 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from dcim.models import Site +from dcim.models import DeviceType, Manufacturer, Site from extras.choices import * from extras.models import * from utilities.testing import ViewTestCases, TestCase @@ -434,7 +434,8 @@ class ConfigContextTestCase( @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') # Create three ConfigContexts for i in range(1, 4): @@ -443,7 +444,7 @@ class ConfigContextTestCase( data={'foo': i} ) configcontext.save() - configcontext.sites.add(site) + configcontext.device_types.add(devicetype) cls.form_data = { 'name': 'Config Context X', @@ -451,11 +452,12 @@ class ConfigContextTestCase( 'description': 'A new config context', 'is_active': True, 'regions': [], - 'sites': [site.pk], + 'sites': [], 'roles': [], 'platforms': [], 'tenant_groups': [], 'tenants': [], + 'device_types': [devicetype.id], 'tags': [], 'data': '{"foo": 123}', } diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9efcc02dc..55b73d29d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -978,6 +978,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): }) +def get_report_module(module, request): + return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") + + class ReportView(ContentTypePermissionRequiredMixin, View): """ Display a single Report and its associated Job (if any). @@ -986,7 +990,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() object_type = ContentType.objects.get(app_label='extras', model='reportmodule') @@ -1007,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): if not request.user.has_perm('extras.run_report'): return HttpResponseForbidden() - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled) @@ -1046,7 +1050,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() return render(request, 'extras/report/source.html', { @@ -1062,7 +1066,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() object_type = ContentType.objects.get(app_label='extras', model='reportmodule') @@ -1151,13 +1155,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View): }) +def get_script_module(module, request): + return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") + + class ScriptView(ContentTypePermissionRequiredMixin, View): def get_required_permission(self): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() form = script.as_form(initial=normalize_querydict(request.GET)) @@ -1181,7 +1189,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View): if not request.user.has_perm('extras.run_script'): return HttpResponseForbidden() - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() form = script.as_form(request.POST, request.FILES) @@ -1218,7 +1226,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() return render(request, 'extras/script/source.html', { @@ -1234,7 +1242,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() object_type = ContentType.objects.get(app_label='extras', model='scriptmodule') diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index da6463e23..662b393de 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -266,6 +266,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): # Normalize request data to a list of objects requested_objects = request.data if isinstance(request.data, list) else [request.data] + limit = len(requested_objects) # Serialize and validate the request data serializer = self.write_serializer_class(data=requested_objects, many=True, context={ @@ -279,7 +280,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): ) with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]): - available_objects = self.get_available_objects(parent) + available_objects = self.get_available_objects(parent, limit) # Determine if the requested number of objects is available if not self.check_sufficient_available(serializer.validated_data, available_objects): @@ -289,7 +290,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): ) # Prepare object data for deserialization - requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) + requested_objects = self.prep_object_data(requested_objects, available_objects, parent) # Initialize the serializer with a list or a single object depending on what was requested serializer_class = get_serializer_for_model(self.queryset.model) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index bc9181286..ba944e3ad 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -29,6 +29,7 @@ __all__ = ( 'L2VPNFilterSet', 'L2VPNTerminationFilterSet', 'PrefixFilterSet', + 'PrimaryIPFilterSet', 'RIRFilterSet', 'RoleFilterSet', 'RouteTargetFilterSet', @@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) mask_length = MultiValueNumberFilter( field_name='prefix', - lookup_expr='net_mask_length' + lookup_expr='net_mask_length', + label=_('Mask length') ) mask_length__gte = django_filters.NumberFilter( field_name='prefix', @@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): method='filter_address', label=_('Address'), ) - mask_length = django_filters.NumberFilter( - method='filter_mask_length', - label=_('Mask length'), + mask_length = MultiValueNumberFilter( + field_name='address', + lookup_expr='net_mask_length', + label=_('Mask length') + ) + mask_length__gte = django_filters.NumberFilter( + field_name='address', + lookup_expr='net_mask_length__gte' + ) + mask_length__lte = django_filters.NumberFilter( + field_name='address', + lookup_expr='net_mask_length__lte' ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), @@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): except ValidationError: return queryset.none() - def filter_mask_length(self, queryset, name, value): - if not value: - return queryset - return queryset.filter(address__net_mask_length=value) - @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: @@ -1227,3 +1233,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): ) ) return qs + + +class PrimaryIPFilterSet(django_filters.FilterSet): + """ + An inheritable FilterSet for models which support primary IP assignment. + """ + primary_ip4_id = django_filters.ModelMultipleChoiceFilter( + field_name='primary_ip4', + queryset=IPAddress.objects.all(), + label=_('Primary IPv4 (ID)'), + ) + primary_ip6_id = django_filters.ModelMultipleChoiceFilter( + field_name='primary_ip6', + queryset=IPAddress.objects.all(), + label=_('Primary IPv6 (ID)'), + ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 548d01afa..f0a8286fc 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,7 +1,8 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ -from dcim.models import Region, Site, SiteGroup +from dcim.models import Location, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -10,9 +11,10 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import ( - CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, + CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, ) from utilities.forms.widgets import BulkEditNullBooleanSelect +from virtualization.models import Cluster, ClusterGroup __all__ = ( 'AggregateBulkEditForm', @@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False - ) min_vid = forms.IntegerField( min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, @@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + scope_type = ContentTypeChoiceField( + label=_('Scope type'), + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + required=False + ) + scope_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput() + ) + region = DynamicModelChoiceField( + label=_('Region'), + queryset=Region.objects.all(), + required=False + ) + sitegroup = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site = DynamicModelChoiceField( + label=_('Site'), + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$sitegroup', + } + ) + location = DynamicModelChoiceField( + label=_('Location'), + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + } + ) + rack = DynamicModelChoiceField( + label=_('Rack'), + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + clustergroup = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + label=_('Cluster group') + ) + cluster = DynamicModelChoiceField( + label=_('Cluster'), + queryset=Cluster.objects.all(), + required=False, + query_params={ + 'group_id': '$clustergroup', + } + ) model = VLANGroup fieldsets = ( (None, ('site', 'min_vid', 'max_vid', 'description')), + (_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) - nullable_fields = ('site', 'description') + nullable_fields = ('description',) + + def clean(self): + super().clean() + + # Assign scope based on scope_type + if self.cleaned_data.get('scope_type'): + scope_field = self.cleaned_data['scope_type'].model + if scope_obj := self.cleaned_data.get(scope_field): + self.cleaned_data['scope_id'] = scope_obj.pk + self.changed_data.append('scope_id') + else: + self.cleaned_data.pop('scope_type') + self.changed_data.remove('scope_type') class VLANBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index ac3c99468..ed3ceec2b 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm): choices=ServiceProtocolChoices, help_text=_('IP protocol') ) + ipaddresses = CSVModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + required=False, + to_field_name='address', + help_text=_('IP Address'), + ) class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') + fields = ( + 'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', + ) + + def clean_ipaddresses(self): + parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') + for ip_address in self.cleaned_data['ipaddresses']: + if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent: + raise forms.ValidationError( + _("{ip} is not assigned to this device/VM.").format(ip=ip_address) + ) + + return self.cleaned_data['ipaddresses'] class L2VPNImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index e4e967f81..a8ca91901 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -295,7 +295,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), + (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')), (_('VRF'), ('vrf_id', 'present_in_vrf_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Device/VM'), ('device_id', 'virtual_machine_id')), @@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + dns_name = forms.CharField( + required=False, + label=_('DNS Name') + ) tag = TagFilterField(model) @@ -519,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Attributes'), ('protocol', 'port')), + (_('Assignment'), ('device_id', 'virtual_machine_id')), + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device'), + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label=_('Virtual Machine'), + ) tag = TagFilterField(model) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index e965bf7b1..bfd4f952d 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -354,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): }) elif selected_objects: assigned_object = self.cleaned_data[selected_objects[0]] - if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: raise ValidationError( _("Cannot reassign IP address while it is designated as the primary IP for the parent object") ) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 2456fa021..d176d3bff 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -290,8 +290,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): super().__init__(*args, **kwargs) # Cache the original prefix and VRF so we can check if they have changed on post_save - self._prefix = self.prefix - self._vrf_id = self.vrf_id + self._prefix = self.__dict__.get('prefix') + self._vrf_id = self.__dict__.get('vrf_id') def __str__(self): return str(self.prefix) @@ -782,6 +782,13 @@ class IPAddress(PrimaryModel): def __str__(self): return str(self.address) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Denote the original assigned object (if any) for validation in clean() + self._original_assigned_object_id = self.__dict__.get('assigned_object_id') + self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') + def get_absolute_url(self): return reverse('ipam:ipaddress', args=[self.pk]) @@ -843,6 +850,26 @@ class IPAddress(PrimaryModel): ) }) + if self._original_assigned_object_id and self._original_assigned_object_type_id: + parent = getattr(self.assigned_object, 'parent_object', None) + ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) + original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) + original_parent = getattr(original_assigned_object, 'parent_object', None) + + # can't use is_primary_ip as self.assigned_object might be changed + is_primary = False + if self.family == 4 and hasattr(original_parent, 'primary_ip4') and original_parent.primary_ip4_id == self.pk: + is_primary = True + if self.family == 6 and hasattr(original_parent, 'primary_ip6') and original_parent.primary_ip6_id == self.pk: + is_primary = True + + if is_primary and (parent != original_parent): + raise ValidationError({ + 'assigned_object': _( + "Cannot reassign IP address while it is designated as the primary IP for the parent object" + ) + }) + # Validate IP status selection if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: raise ValidationError({ diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 24d219ca0..d696c8dae 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): ) IPAddress.objects.bulk_create(ip_addresses) + def test_assign_object(self): + """ + Test the creation of available IP addresses within a parent IP range. + """ + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + role = DeviceRole.objects.create(name='Switch') + device1 = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + role=role, + status='active' + ) + interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset') + interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset') + device2 = Device.objects.create( + name='Device 2', + site=site, + device_type=device_type, + role=role, + status='active' + ) + interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset') + + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1), + IPAddress(address=IPNetwork('192.168.1.4/24')), + ) + IPAddress.objects.bulk_create(ip_addresses) + + ip1 = ip_addresses[0] + ip1.assigned_object = interface1 + device1.primary_ip4 = ip_addresses[0] + device1.save() + + ip2 = ip_addresses[1] + + url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk}) + self.add_permissions('ipam.change_ipaddress') + + # assign to same parent + data = { + 'assigned_object_id': interface2.pk + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + # assign to same different parent - should error + data = { + 'assigned_object_id': interface3.pk + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class FHRPGroupTest(APIViewTestCases.APIViewTestCase): model = FHRPGroup diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 596356906..952376056 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': ['24']} + params = {'mask_length': [24]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'mask_length__gte': 32} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'mask_length__lte': 24} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) def test_vrf(self): vrfs = VRF.objects.all()[:2] @@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': '24'} + params = {'mask_length': [24]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'mask_length__gte': 64} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'mask_length__lte': 25} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_vrf(self): vrfs = VRF.objects.all()[:2] diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index afc97cc63..a37584f0f 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,6 +4,7 @@ from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork +from dcim.constants import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface from ipam.choices import * from ipam.models import * @@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role) + interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL) services = ( Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), @@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Service.objects.bulk_create(services) + ip_addresses = ( + IPAddress(assigned_object=interface, address='192.0.2.1/24'), + IPAddress(assigned_object=interface, address='192.0.2.2/24'), + ) + IPAddress.objects.bulk_create(ip_addresses) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "device,name,protocol,ports,description", - "Device 1,Service 1,tcp,1,First service", - "Device 1,Service 2,tcp,2,Second service", - "Device 1,Service 3,udp,3,Third service", + "device,name,protocol,ports,ipaddresses,description", + "Device 1,Service 1,tcp,1,192.0.2.1/24,First service", + "Device 1,Service 2,tcp,2,192.0.2.2/24,Second service", + "Device 1,Service 3,udp,3,,Third service", ) cls.csv_update_data = ( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 7cf785521..48ea637d9 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView): tab = ViewTab( label=_('ASNs'), badge=lambda x: x.get_child_asns().count(), - permission='ipam.view_asns', + permission='ipam.view_asn', weight=500 ) diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 347ed55bd..d6e43ea75 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -46,12 +46,13 @@ class ChoiceField(serializers.Field): return super().validate_empty_values(data) def to_representation(self, obj): - if obj == '': - return None - return { - 'value': obj, - 'label': self._choices[obj], - } + if obj != '': + # Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously + # configured choice has been removed from FIELD_CHOICES). + return { + 'value': obj, + 'label': self._choices.get(obj, ''), + } def to_internal_value(self, data): if data == '': diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 5fe81b1f5..c6794bb61 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -3,6 +3,8 @@ import logging from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.db.models import ProtectedError +from django_pglocks import advisory_lock +from netbox.constants import ADVISORY_LOCK_KEYS from rest_framework import mixins as drf_mixins from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet @@ -157,3 +159,22 @@ class NetBoxModelViewSet( logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") return super().perform_destroy(instance) + + +class MPTTLockedMixin: + """ + Puts pglock on objects that derive from MPTTModel for parallel API calling. + Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS + """ + + def create(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().destroy(request, *args, **kwargs) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index d69edc69c..2f4ee8e6b 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -11,8 +11,19 @@ RQ_QUEUE_LOW = 'low' # When adding a new key, pick something arbitrary and unique so that it is easily searchable in # query logs. ADVISORY_LOCK_KEYS = { + # Available object locks 'available-prefixes': 100100, 'available-ips': 100200, 'available-vlans': 100300, 'available-asns': 100400, + + # MPTT locks + 'region': 105100, + 'sitegroup': 105200, + 'location': 105300, + 'tenantgroup': 105400, + 'contactgroup': 105500, + 'wirelesslangroup': 105600, + 'inventoryitem': 105700, + 'inventoryitemtemplate': 105800, } diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 596357ea4..9d7696696 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey +from django.core.exceptions import ObjectDoesNotExist from django.core.validators import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): if ct_value and fk_value: klass = getattr(self, field.ct_field).model_class() - if not klass.objects.filter(pk=fk_value).exists(): + try: + obj = klass.objects.get(pk=fk_value) + except ObjectDoesNotExist: raise ValidationError({ field.fk_field: f"Related object not found using the provided value: {fk_value}." }) + # update the GFK field value + setattr(self, field.name, obj) + # # NetBox internal base models diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c22281e9e..5a5a89984 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.6.2-dev' +VERSION = '3.6.6-dev' # Hostname HOSTNAME = platform.node() @@ -95,6 +95,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) +DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) @@ -355,6 +356,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + 'django.forms', 'corsheaders', 'debug_toolbar', 'graphiql_debug_toolbar', @@ -430,6 +432,9 @@ TEMPLATES = [ }, ] +# This allows us to override Django's stock form widget templates +FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' + # Set up authentication backends if type(REMOTE_AUTH_BACKEND) not in (list, tuple): REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND] @@ -497,6 +502,9 @@ AUTH_EXEMPT_PATHS = ( MAINTENANCE_EXEMPT_PATHS = ( f'/{BASE_PATH}admin/', f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration + LOGIN_URL, + LOGIN_REDIRECT_URL, + LOGOUT_REDIRECT_URL ) SERIALIZATION_MODULES = { diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 9e348fb23..d2cd0a0d4 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -483,8 +483,10 @@ class CustomFieldColumn(tables.Column): return mark_safe('') if self.customfield.type == CustomFieldTypeChoices.TYPE_URL: return mark_safe(f'{escape(value)}') + if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT: + return self.customfield.get_choice_label(value) if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - return ', '.join(v for v in value) + return ', '.join(self.customfield.get_choice_label(v) for v in value) if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: return mark_safe(', '.join( self._linkify_item(obj) for obj in self.customfield.deserialize(value) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 52ff69aa9..97ab44362 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -119,7 +119,7 @@ class BaseTable(tables.Table): @property def available_columns(self): - return self._get_columns(visible=False) + return sorted(self._get_columns(visible=False)) @property def selected_columns(self): diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py index 6594409f2..bd07886e8 100644 --- a/netbox/netbox/tests/test_import.py +++ b/netbox/netbox/tests/test_import.py @@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase): def _get_csv_data(self, csv_data): return '\n'.join(csv_data) + def test_invalid_headers(self): + """ + Test that import form validation fails when an unknown CSV header is present. + """ + self.add_permissions('dcim.add_region') + + csv_data = [ + 'name,slug,INVALIDHEADER', + 'Region 1,region-1,abc', + 'Region 2,region-2,def', + 'Region 3,region-3,ghi', + ] + data = { + 'format': ImportFormatChoices.CSV, + 'data': self._get_csv_data(csv_data), + 'csv_delimiter': CSVDelimiterChoices.AUTO, + } + + # Form validation should fail with invalid header present + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(Region.objects.count(), 0) + + # Correct the CSV header name + csv_data[0] = 'name,slug,description' + data['data'] = self._get_csv_data(csv_data) + + # Validation should succeed + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302) + self.assertEqual(Region.objects.count(), 3) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_valid_tags(self): csv_data = ( diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index bef524bce..676e3f5af 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -3,6 +3,7 @@ import re from copy import deepcopy from django.contrib import messages +from django.contrib.contenttypes.fields import GenericRel from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError @@ -519,9 +520,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): model_field = self.queryset.model._meta.get_field(name) if isinstance(model_field, (ManyToManyField, ManyToManyRel)): m2m_fields[name] = model_field + elif isinstance(model_field, GenericRel): + # Ignore generic relations (these may be used for other purposes in the form) + continue else: model_fields[name] = model_field - except FieldDoesNotExist: # This form field is used to modify a field rather than set its value directly model_fields[name] = None diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 2aa24b72c..84d1600e3 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index ffdd83285..9048a3286 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index b492e4d1d..7a3cd7859 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 84bfecae3..426302ea8 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 7f2400ed2..077c4bcc0 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/clipboard.ts b/netbox/project-static/src/clipboard.ts index 46ca5e36c..ddcb7b96e 100644 --- a/netbox/project-static/src/clipboard.ts +++ b/netbox/project-static/src/clipboard.ts @@ -2,7 +2,7 @@ import Clipboard from 'clipboard'; import { getElements } from './util'; export function initClipboard(): void { - for (const element of getElements('a.copy-content')) { + for (const element of getElements('.copy-content')) { new Clipboard(element); } } diff --git a/netbox/project-static/src/forms/scopeSelector.ts b/netbox/project-static/src/forms/scopeSelector.ts index 14ef972f8..f7b77f041 100644 --- a/netbox/project-static/src/forms/scopeSelector.ts +++ b/netbox/project-static/src/forms/scopeSelector.ts @@ -88,6 +88,7 @@ const showHideLayout: ShowHideLayout = { const showHideMap: ShowHideMap = { vlangroup_add: 'vlangroup', vlangroup_edit: 'vlangroup', + vlangroup_bulk_edit: 'vlangroup', }; /** diff --git a/netbox/project-static/src/tables/interfaceTable.ts b/netbox/project-static/src/tables/interfaceTable.ts index 56a0ae754..70243cf41 100644 --- a/netbox/project-static/src/tables/interfaceTable.ts +++ b/netbox/project-static/src/tables/interfaceTable.ts @@ -141,9 +141,10 @@ class TableState { private virtualButton: ButtonState; /** - * Underlying DOM Table Caption Element. + * Instance of ButtonState for the 'show/hide virtual rows' button. */ - private caption: Nullable = null; + // @ts-expect-error null handling is performed in the constructor + private disconnectedButton: ButtonState; /** * All table rows in table @@ -166,9 +167,10 @@ class TableState { this.table, 'button.toggle-virtual', ); - - const caption = this.table.querySelector('caption'); - this.caption = caption; + const toggleDisconnectedButton = findFirstAdjacent( + this.table, + 'button.toggle-disconnected', + ); if (toggleEnabledButton === null) { throw new TableStateError("Table is missing a 'toggle-enabled' button.", table); @@ -182,10 +184,15 @@ class TableState { throw new TableStateError("Table is missing a 'toggle-virtual' button.", table); } + if (toggleDisconnectedButton === null) { + throw new TableStateError("Table is missing a 'toggle-disconnected' button.", table); + } + // Attach event listeners to the buttons elements. toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this)); + toggleDisconnectedButton.addEventListener('click', event => this.handleClick(event, this)); // Instantiate ButtonState for each button for state management. this.enabledButton = new ButtonState( @@ -200,6 +207,10 @@ class TableState { toggleVirtualButton, table.querySelectorAll('tr[data-type="virtual"]'), ); + this.disconnectedButton = new ButtonState( + toggleDisconnectedButton, + table.querySelectorAll('tr[data-connected="disconnected"]'), + ); } catch (err) { if (err instanceof TableStateError) { // This class is useless for tables that don't have toggle buttons. @@ -211,52 +222,6 @@ class TableState { } } - /** - * Get the table caption's text. - */ - private get captionText(): string { - if (this.caption !== null) { - return this.caption.innerText; - } - return ''; - } - - /** - * Set the table caption's text. - */ - private set captionText(value: string) { - if (this.caption !== null) { - this.caption.innerText = value; - } - } - - /** - * Update the table caption's text based on the state of each toggle button. - */ - private toggleCaption(): void { - const showEnabled = this.enabledButton.buttonState === 'show'; - const showDisabled = this.disabledButton.buttonState === 'show'; - const showVirtual = this.virtualButton.buttonState === 'show'; - - if (showEnabled && !showDisabled && !showVirtual) { - this.captionText = 'Showing Enabled Interfaces'; - } else if (showEnabled && showDisabled && !showVirtual) { - this.captionText = 'Showing Enabled & Disabled Interfaces'; - } else if (!showEnabled && showDisabled && !showVirtual) { - this.captionText = 'Showing Disabled Interfaces'; - } else if (!showEnabled && !showDisabled && !showVirtual) { - this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces'; - } else if (!showEnabled && !showDisabled && showVirtual) { - this.captionText = 'Showing Virtual Interfaces'; - } else if (showEnabled && !showDisabled && showVirtual) { - this.captionText = 'Showing Enabled & Virtual Interfaces'; - } else if (showEnabled && showDisabled && showVirtual) { - this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces'; - } else { - this.captionText = ''; - } - } - /** * When toggle buttons are clicked, reapply visability all rows and * pass the event to all button handlers @@ -272,7 +237,7 @@ class TableState { instance.enabledButton.handleClick(event); instance.disabledButton.handleClick(event); instance.virtualButton.handleClick(event); - instance.toggleCaption(); + instance.disconnectedButton.handleClick(event); } } diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 94fddc32c..a38633b5c 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -167,6 +167,12 @@ table td > .progress { } } +.alert { + code { + color: $gray-600; + } +} + span.profile-button .dropdown-menu { right: 0; left: auto; diff --git a/netbox/templates/account/token.html b/netbox/templates/account/token.html index d83e13ff5..57d1de3f4 100644 --- a/netbox/templates/account/token.html +++ b/netbox/templates/account/token.html @@ -15,11 +15,6 @@ {% block content %}
- {% if key and not settings.ALLOW_TOKEN_RETRIEVAL %} - - {% endif %}
{% trans "Token" %}
diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 676f8a3e5..b004634bb 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -23,7 +23,15 @@
- {% if path.is_split %} + {% if path.is_split and path.get_asymmetric_nodes %} +

{% trans "Asymmetric Path" %}!

+

{% trans "The nodes below have no links and result in an asymmetric path" %}:

+
    + {% for next_node in path.get_asymmetric_nodes %} +
  • {{ next_node|linkify }}
  • + {% endfor %} +
+ {% elif path.is_split %}

{% trans "Path split" %}!

{% trans "Select a node below to continue" %}:

    diff --git a/netbox/templates/dcim/device/inc/interface_table_controls.html b/netbox/templates/dcim/device/inc/interface_table_controls.html index 36605cd25..7868d99db 100644 --- a/netbox/templates/dcim/device/inc/interface_table_controls.html +++ b/netbox/templates/dcim/device/inc/interface_table_controls.html @@ -9,5 +9,6 @@ +
{% endblock extra_table_controls %} diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index 29f405b6e..9448ad3e5 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -13,7 +13,7 @@ {% block extra_controls %} {% if perms.dcim.add_device %} - + {% trans "Add Device" %} {% endif %} diff --git a/netbox/templates/django/forms/widgets/checkbox.html b/netbox/templates/django/forms/widgets/checkbox.html new file mode 100644 index 000000000..bbe201a29 --- /dev/null +++ b/netbox/templates/django/forms/widgets/checkbox.html @@ -0,0 +1,6 @@ +{% comment %} + Include a hidden field of the same name to ensure that unchecked checkboxes + are always included in the submitted form data. +{% endcomment %} + +{% include "django/forms/widgets/input.html" %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 07d6fcfd5..806279d20 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -8,11 +8,17 @@ {% if perms.extras.run_report %}
+ {% if not report.is_valid %} +
+ + {% trans "This report is invalid and cannot be run." %} +
+ {% endif %}
{% csrf_token %} {% render_form form %}
- @@ -88,6 +89,7 @@ Context: {% render_field form.data_source %} {% render_field form.data_file %} {% render_field form.format %} + {% render_field form.csv_delimiter %}
diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html index 3db0d0102..0df2c883e 100644 --- a/netbox/templates/ipam/l2vpntermination_edit.html +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -41,6 +41,7 @@
{% render_field form.vminterface %}
+ {% render_field form.tags %}
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 39c86d80e..71a4961c3 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView from circuits.models import Circuit from dcim.models import Device, Rack, Site from ipam.models import IPAddress, Prefix, VLAN, VRF -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from tenancy import filtersets from tenancy.models import * from utilities.utils import count_related @@ -23,7 +23,7 @@ class TenancyRootView(APIRootView): # Tenants # -class TenantGroupViewSet(NetBoxModelViewSet): +class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -58,7 +58,7 @@ class TenantViewSet(NetBoxModelViewSet): # Contacts # -class ContactGroupViewSet(NetBoxModelViewSet): +class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 2e7525481..a22c04569 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable): verbose_name=_('Role'), linkify=True ) + contact_group = tables.Column( + accessor=Accessor('contact__group'), + verbose_name=_('Group'), + linkify=True + ) contact_title = tables.Column( accessor=Accessor('contact__title'), verbose_name=_('Contact Title') @@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable): model = ContactAssignment fields = ( 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone', - 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions' + 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags', + 'actions' ) default_columns = ( 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 76a86146c..d0a8c2b89 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -386,7 +386,7 @@ class ContactAssignmentListView(generic.ObjectListView): filterset = filtersets.ContactAssignmentFilterSet filterset_form = forms.ContactAssignmentFilterForm table = tables.ContactAssignmentTable - actions = ('export', 'bulk_edit', 'bulk_delete') + actions = ('export', 'bulk_edit', 'bulk_delete', 'import') @register_model_view(ContactAssignment, 'edit') diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 5fe84ad5f..1c3233f87 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -114,6 +114,9 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm): help_text=_( 'Keys must be at least 40 characters in length. Be sure to record your key prior to ' 'submitting this form, as it may no longer be accessible once the token has been created.' + ), + widget=forms.TextInput( + attrs={'data-clipboard': 'true'} ) ) allowed_ips = SimpleArrayField( diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 3b418715a..afb270568 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -52,7 +52,7 @@ class UserTable(NetBoxTable): model = NetBoxUser fields = ( 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff', - 'is_superuser', + 'is_superuser', 'last_login', ) default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active') diff --git a/netbox/utilities/counters.py b/netbox/utilities/counters.py index b0bd2560b..0ee2606db 100644 --- a/netbox/utilities/counters.py +++ b/netbox/utilities/counters.py @@ -52,16 +52,17 @@ def post_save_receiver(sender, instance, created, **kwargs): for field_name, counter_name in get_counters_for_model(sender): parent_model = sender._meta.get_field(field_name).related_model new_pk = getattr(instance, field_name, None) - old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None + has_old_field = field_name in instance.tracker + old_pk = instance.tracker.get(field_name) if has_old_field else None # Update the counters on the old and/or new parents as needed if old_pk is not None: update_counter(parent_model, old_pk, counter_name, -1) - if new_pk is not None and (old_pk or created): + if new_pk is not None and (has_old_field or created): update_counter(parent_model, new_pk, counter_name, 1) -def post_delete_receiver(sender, instance, **kwargs): +def post_delete_receiver(sender, instance, origin, **kwargs): """ Update counter fields on related objects when a TrackingModelMixin subclass is deleted. """ @@ -71,7 +72,9 @@ def post_delete_receiver(sender, instance, **kwargs): # Decrement the parent's counter by one if parent_pk is not None: - update_counter(parent_model, parent_pk, counter_name, -1) + # MPTT sends two delete signals for child elements so guard against multiple decrements + if not origin or origin == instance: + update_counter(parent_model, parent_pk, counter_name, -1) # diff --git a/netbox/utilities/forms/bulk_import.py b/netbox/utilities/forms/bulk_import.py index 63cec2ba2..57362d3dd 100644 --- a/netbox/utilities/forms/bulk_import.py +++ b/netbox/utilities/forms/bulk_import.py @@ -129,6 +129,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): headers, records = parse_csv(reader) # Set CSV headers for reference by the model form + headers.pop('id', None) self._csv_headers = headers return records diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 04d956a49..54c9e41cb 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -70,22 +70,24 @@ class CSVModelForm(forms.ModelForm): """ ModelForm used for the import of objects in CSV format. """ - def __init__(self, *args, headers=None, fields=None, **kwargs): - headers = headers or {} - fields = fields or [] + def __init__(self, *args, headers=None, **kwargs): + self.headers = headers or {} super().__init__(*args, **kwargs) # Modify the model form to accommodate any customized to_field_name properties - for field, to_field in headers.items(): + for field, to_field in self.headers.items(): if to_field is not None: self.fields[field].to_field_name = to_field - # Omit any fields not specified (e.g. because the form is being used to - # updated rather than create objects) - if fields: - for field in list(self.fields.keys()): - if field not in fields: - del self.fields[field] + def clean(self): + # Flag any invalid CSV headers + for header in self.headers: + if header not in self.fields: + raise forms.ValidationError( + _("Unrecognized header: {name}").format(name=header) + ) + + return super().clean() class FilterForm(BootstrapMixin, forms.Form): diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py index 3b8e1edde..0f8ee9cae 100644 --- a/netbox/utilities/request.py +++ b/netbox/utilities/request.py @@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()): ) for header in HTTP_HEADERS: if header in request.META: - client_ip = request.META[header].split(',')[0] + client_ip = request.META[header].split(',')[0].partition(':')[0] try: return IPAddress(client_ip) except ValueError: diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html index 379dcc021..e5a564a3d 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -29,6 +29,14 @@ {{ label }}
+ {# Include a copy-to-clipboard button #} + {% elif 'data-clipboard' in field.field.widget.attrs %} +
+ {{ field }} + +
{# Default field rendering #} {% else %} {{ field }} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 35aec1000..68541ae5a 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -1,6 +1,7 @@ from django import template from django.http import QueryDict +from extras.choices import CustomFieldTypeChoices from utilities.utils import dict_to_querydict __all__ = ( @@ -38,6 +39,11 @@ def customfield_value(customfield, value): customfield: A CustomField instance value: The custom field value applied to an object """ + if value: + if customfield.type == CustomFieldTypeChoices.TYPE_SELECT: + value = customfield.get_choice_label(value) + elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + value = [customfield.get_choice_label(v) for v in value] return { 'customfield': customfield, 'value': value, diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py index 0c61c0890..014c758e9 100644 --- a/netbox/utilities/tests/test_counters.py +++ b/netbox/utilities/tests/test_counters.py @@ -1,7 +1,11 @@ -from django.test import TestCase +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings +from django.urls import reverse from dcim.models import * -from utilities.testing.utils import create_test_device +from users.models import ObjectPermission +from utilities.testing.base import TestCase +from utilities.testing.utils import create_test_device, create_test_user class CountersTest(TestCase): @@ -10,7 +14,6 @@ class CountersTest(TestCase): """ @classmethod def setUpTestData(cls): - # Create devices device1 = create_test_device('Device 1') device2 = create_test_device('Device 2') @@ -36,10 +39,18 @@ class CountersTest(TestCase): self.assertEqual(device1.interface_count, 3) self.assertEqual(device2.interface_count, 3) + # test saving an existing object - counter should not change interface1.save() device1.refresh_from_db() self.assertEqual(device1.interface_count, 3) + # test save where tracked object FK back pointer is None + vc = VirtualChassis.objects.create(name='Virtual Chassis 1') + device1.virtual_chassis = vc + device1.save() + vc.refresh_from_db() + self.assertEqual(vc.member_count, 1) + def test_interface_count_deletion(self): """ When a tracked object (Interface) is deleted the tracking counter should be updated. @@ -71,3 +82,25 @@ class CountersTest(TestCase): device2.refresh_from_db() self.assertEqual(device1.interface_count, 1) self.assertEqual(device2.interface_count, 3) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_mptt_child_delete(self): + device1, device2 = Device.objects.all() + inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1') + inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1) + device1.refresh_from_db() + self.assertEqual(device1.inventory_item_count, 2) + + # Setup bulk_delete for the inventory items + self.add_permissions('dcim.delete_inventoryitem') + pk_list = device1.inventoryitems.values_list('pk', flat=True) + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button + } + + # Try POST with model-level permission + self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data) + device1.refresh_from_db() + self.assertEqual(device1.inventory_item_count, 0) diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 571dbe64b..b23808b31 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -6,6 +6,7 @@ from dcim.filtersets import CommonInterfaceFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate +from ipam.filtersets import PrimaryIPFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter @@ -114,7 +115,8 @@ class VirtualMachineFilterSet( NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, - LocalConfigContextFilterSet + LocalConfigContextFilterSet, + PrimaryIPFilterSet, ): status = django_filters.MultipleChoiceFilter( choices=VirtualMachineStatusChoices, diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 21dbc895a..91f5b06ad 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -200,7 +200,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): platform = DynamicModelChoiceField( label=_('Platform'), queryset=Platform.objects.all(), - required=False + required=False, + selector=True ) local_context_data = JSONField( required=False, diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index d474af21a..e6fe90297 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ipaddresses = ( IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]), IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]), + IPAddress(address='192.0.2.3/24', assigned_object=None), + IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]), + IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]), + IPAddress(address='2001:db8::3/64', assigned_object=None), ) IPAddress.objects.bulk_create(ipaddresses) - VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0]) - VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1]) + VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3]) + VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4]) def test_name(self): params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']} @@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_primary_ip4(self): + addresses = IPAddress.objects.filter(address__family=4) + params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip4_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + + def test_primary_ip6(self): + addresses = IPAddress.objects.filter(address__family=6) + params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip6_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VMInterface.objects.all() diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index 1103cec37..a6cc9f535 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -1,6 +1,6 @@ from rest_framework.routers import APIRootView -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from wireless import filtersets from wireless.models import * from . import serializers @@ -14,7 +14,7 @@ class WirelessRootView(APIRootView): return 'Wireless' -class WirelessLANGroupViewSet(NetBoxModelViewSet): +class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = WirelessLANGroup.objects.add_related_count( WirelessLANGroup.objects.all(), WirelessLAN, diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 046918535..e8e48eef8 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from mptt.models import MPTTModel from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES diff --git a/requirements.txt b/requirements.txt index 54f1334ed..16bafe62f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,35 +1,36 @@ -bleach==6.0.0 -Django==4.2.5 -django-cors-headers==4.2.0 +bleach==6.1.0 +Django==4.2.7 +django-cors-headers==4.3.0 django-debug-toolbar==4.2.0 -django-filter==23.2 +django-filter==23.3 django-graphiql-debug-toolbar==0.2.0 -django-mptt==0.14 +django-mptt==0.14.0 django-pglocks==1.0.4 django-prometheus==2.3.1 -django-redis==5.3.0 -django-rich==1.7.0 +django-redis==5.4.0 +django-rich==1.8.0 django-rq==2.8.1 django-tables2==2.6.0 django-taggit==4.0.0 -django-timezone-field==6.0 +django-timezone-field==6.0.1 djangorestframework==3.14.0 -drf-spectacular==0.26.4 -drf-spectacular-sidecar==2023.9.1 +drf-spectacular==0.26.5 +drf-spectacular-sidecar==2023.10.1 feedparser==6.0.10 graphene-django==3.0.0 gunicorn==21.2.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.2.7 +mkdocs-material==9.4.8 mkdocstrings[python-legacy]==0.23.0 -netaddr==0.8.0 -Pillow==10.0.0 -psycopg[binary,pool]==3.1.10 +netaddr==0.9.0 +Pillow==10.1.0 +psycopg[binary,pool]==3.1.12 PyYAML==6.0.1 -sentry-sdk==1.30.0 -social-auth-app-django==5.3.0 -social-auth-core[openidconnect]==4.4.2 +requests==2.31.0 +sentry-sdk==1.34.0 +social-auth-app-django==5.4.0 +social-auth-core[openidconnect]==4.5.0 svgwrite==1.4.3 tablib==3.5.0 tzdata==2023.3